Initial commit: VK media tools
Скрипты для выгрузки фото и видео из диалогов ВКонтакте, обработки (дедупликация + CLIP-классификация) и загрузки в Immich. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
35
.gitignore
vendored
Normal file
35
.gitignore
vendored
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# Виртуальное окружение
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
env/
|
||||||
|
|
||||||
|
# Python кеш
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
*.pyo
|
||||||
|
|
||||||
|
# Файлы прогресса (генерируются скриптами)
|
||||||
|
progress.json
|
||||||
|
process_progress.json
|
||||||
|
video_progress.json
|
||||||
|
immich_upload_progress.json
|
||||||
|
rollback_log.json
|
||||||
|
|
||||||
|
# Логи
|
||||||
|
optimizer_failed.txt
|
||||||
|
skipped_videos.txt
|
||||||
|
|
||||||
|
# Загруженные медиафайлы
|
||||||
|
downloads/
|
||||||
|
downloads_video/
|
||||||
|
output/
|
||||||
|
|
||||||
|
# macOS
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
85
config.py
Normal file
85
config.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Конфигурация скрипта для выгрузки фото из диалогов ВКонтакте.
|
||||||
|
|
||||||
|
Как получить токен:
|
||||||
|
1. Перейди по ссылке в браузере:
|
||||||
|
https://oauth.vk.com/authorize?client_id=2685278&scope=messages,photos,offline&redirect_uri=https://oauth.vk.com/blank.html&display=page&response_type=token&v=5.199
|
||||||
|
2. Авторизуйся и разреши доступ
|
||||||
|
3. Скопируй access_token из адресной строки (значение между access_token= и &expires_in)
|
||||||
|
4. Вставь его ниже в переменную VK_TOKEN
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Токен доступа VK API (обязательно заполнить)
|
||||||
|
VK_TOKEN: str = ""
|
||||||
|
|
||||||
|
# Папка для сохранения фото (относительный или абсолютный путь)
|
||||||
|
DOWNLOAD_DIR: str = "downloads"
|
||||||
|
|
||||||
|
# Папка для результатов обработки (дубликаты, мусор, review) — рядом с downloads
|
||||||
|
OUTPUT_DIR: str = "output"
|
||||||
|
|
||||||
|
# Файл прогресса для механизма resume
|
||||||
|
PROGRESS_FILE: str = "progress.json"
|
||||||
|
|
||||||
|
# Версия VK API
|
||||||
|
API_VERSION: str = "5.199"
|
||||||
|
|
||||||
|
# Минимум свободного места на диске (в МБ), при котором скрипт остановится
|
||||||
|
MIN_FREE_SPACE_MB: int = 500
|
||||||
|
|
||||||
|
# Задержка между скачиваниями файлов (секунды) — чтобы не перегружать сеть
|
||||||
|
DOWNLOAD_DELAY: float = 0.1
|
||||||
|
|
||||||
|
# Количество попыток при сетевых ошибках
|
||||||
|
MAX_RETRIES: int = 3
|
||||||
|
|
||||||
|
# Таймаут для скачивания одного фото (секунды)
|
||||||
|
DOWNLOAD_TIMEOUT: int = 30
|
||||||
|
|
||||||
|
# Количество параллельных потоков для скачивания фото
|
||||||
|
DOWNLOAD_WORKERS: int = 8
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Настройки обработки фото (process_photos.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Файл прогресса обработки
|
||||||
|
PROCESS_PROGRESS_FILE: str = "process_progress.json"
|
||||||
|
|
||||||
|
# Файл лога перемещений (для отката)
|
||||||
|
ROLLBACK_LOG_FILE: str = "rollback_log.json"
|
||||||
|
|
||||||
|
# Размер хеша (hash_size x hash_size бит, 8 = 64 бита)
|
||||||
|
HASH_SIZE: int = 8
|
||||||
|
|
||||||
|
# Порог расстояния Хэмминга для near-дубликатов (0 = только точные, 8 = средний)
|
||||||
|
DEDUP_THRESHOLD: int = 8
|
||||||
|
|
||||||
|
# Потоки для параллельного хеширования
|
||||||
|
HASH_WORKERS: int = 8
|
||||||
|
|
||||||
|
# Размер батча для CLIP-классификации
|
||||||
|
CLIP_BATCH_SIZE: int = 16
|
||||||
|
|
||||||
|
# Минимальный порог уверенности CLIP (ниже → папка _review)
|
||||||
|
# Для CLIP ViT-B-32 cosine similarity обычно в диапазоне 0.12-0.35
|
||||||
|
CLIP_CONFIDENCE_MIN: float = 0.15
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Настройки скачивания видео (main_video.py)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Папка для сохранения видео
|
||||||
|
VIDEO_DOWNLOAD_DIR: str = "downloads_video"
|
||||||
|
|
||||||
|
# Файл прогресса для видео
|
||||||
|
VIDEO_PROGRESS_FILE: str = "video_progress.json"
|
||||||
|
|
||||||
|
# Потоки скачивания (меньше чем для фото — видео тяжёлые)
|
||||||
|
VIDEO_DOWNLOAD_WORKERS: int = 4
|
||||||
|
|
||||||
|
# Таймаут скачивания одного видео (секунды, видео крупнее фото)
|
||||||
|
VIDEO_DOWNLOAD_TIMEOUT: int = 300
|
||||||
|
|
||||||
|
# Минимум свободного места для видео (МБ)
|
||||||
|
VIDEO_MIN_FREE_SPACE_MB: int = 2000
|
||||||
647
immich_upload.py
Normal file
647
immich_upload.py
Normal file
@@ -0,0 +1,647 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Массовая загрузка фото и видео в Immich с прогресс-баром и возобновлением.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
1. pip install requests tqdm
|
||||||
|
2. python immich_upload.py
|
||||||
|
3. Ctrl+C для остановки (прогресс сохраняется автоматически)
|
||||||
|
4. Повторный запуск продолжит с места остановки
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Конфигурация
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Immich сервер (через Upload Optimizer прокси)
|
||||||
|
IMMICH_URL: str = "http://YOUR_IMMICH_HOST:2283"
|
||||||
|
# Прямой доступ к Immich (минуя оптимизатор) — fallback при ошибках оптимизатора
|
||||||
|
IMMICH_DIRECT_URL: str = "http://YOUR_IMMICH_HOST:2284"
|
||||||
|
API_KEY: str = ""
|
||||||
|
|
||||||
|
# Папки для загрузки (рекурсивный поиск медиафайлов)
|
||||||
|
UPLOAD_FOLDERS: list[str] = [
|
||||||
|
"downloads_video",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Файл прогресса (для resume)
|
||||||
|
PROGRESS_FILE: str = "immich_upload_progress.json"
|
||||||
|
|
||||||
|
# Лог файлов, которые оптимизатор не смог обработать (загружены напрямую)
|
||||||
|
OPTIMIZER_FAILED_LOG: str = "optimizer_failed.txt"
|
||||||
|
|
||||||
|
# Параллельные загрузки (8 потоков — оптимально для 1 Гбит LAN)
|
||||||
|
MAX_WORKERS: int = 8
|
||||||
|
|
||||||
|
# Повторные попытки при ошибке
|
||||||
|
MAX_RETRIES: int = 3
|
||||||
|
|
||||||
|
# Таймаут загрузки одного файла (секунды)
|
||||||
|
UPLOAD_TIMEOUT: int = 300
|
||||||
|
|
||||||
|
# Расширения изображений
|
||||||
|
IMAGE_EXTENSIONS: set[str] = {
|
||||||
|
".jpg", ".jpeg", ".png", ".heic", ".heif", ".webp", ".gif",
|
||||||
|
".avif", ".bmp", ".tiff", ".tif", ".jxl", ".raw", ".rw2",
|
||||||
|
".dng", ".nef", ".cr2", ".arw", ".orf", ".pef", ".raf",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Расширения видео
|
||||||
|
VIDEO_EXTENSIONS: set[str] = {
|
||||||
|
".mp4", ".mov", ".avi", ".mkv", ".webm", ".m4v", ".3gp",
|
||||||
|
".flv", ".wmv", ".mts", ".m2ts", ".mpg", ".mpeg",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Все допустимые расширения медиафайлов
|
||||||
|
MEDIA_EXTENSIONS: set[str] = IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
|
||||||
|
|
||||||
|
|
||||||
|
# Режим загрузки: "photos" — только фото (видео пропускаются и логируются),
|
||||||
|
# "videos" — только видео, "all" — всё
|
||||||
|
UPLOAD_MODE: str = "videos"
|
||||||
|
|
||||||
|
# Лог пропущенных видео (при UPLOAD_MODE="photos")
|
||||||
|
SKIPPED_VIDEOS_LOG: str = "skipped_videos.txt"
|
||||||
|
|
||||||
|
# Идентификатор устройства (для Immich)
|
||||||
|
DEVICE_ID: str = "python-bulk-uploader"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Модели данных
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class UploadStats:
|
||||||
|
"""Статистика загрузки (потокобезопасная)."""
|
||||||
|
|
||||||
|
uploaded: int = 0
|
||||||
|
skipped: int = 0
|
||||||
|
duplicates: int = 0
|
||||||
|
errors: int = 0
|
||||||
|
bytes_sent: int = 0
|
||||||
|
_lock: Lock = field(default_factory=Lock, repr=False)
|
||||||
|
|
||||||
|
def inc_uploaded(self, size: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.uploaded += 1
|
||||||
|
self.bytes_sent += size
|
||||||
|
|
||||||
|
def inc_skipped(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.skipped += 1
|
||||||
|
|
||||||
|
def inc_duplicate(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.duplicates += 1
|
||||||
|
|
||||||
|
def inc_error(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.errors += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Менеджер прогресса (resume)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ProgressManager:
|
||||||
|
"""Управление файлом прогресса для механизма resume."""
|
||||||
|
|
||||||
|
def __init__(self, progress_file: str) -> None:
|
||||||
|
self.progress_file: Path = Path(progress_file)
|
||||||
|
self._lock: Lock = Lock()
|
||||||
|
self.data: dict = self._load()
|
||||||
|
# Множество для быстрого поиска уже загруженных файлов (по хешу пути)
|
||||||
|
self._uploaded_hashes: set[str] = set(self.data.get("uploaded_hashes", []))
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
"""Загружает прогресс из файла или создаёт пустой."""
|
||||||
|
if self.progress_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.progress_file, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"last_updated": "",
|
||||||
|
"uploaded_hashes": [],
|
||||||
|
"stats": {
|
||||||
|
"uploaded": 0,
|
||||||
|
"skipped": 0,
|
||||||
|
"duplicates": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_sent": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Сохраняет текущее состояние прогресса в файл."""
|
||||||
|
with self._lock:
|
||||||
|
self.data["last_updated"] = datetime.now().isoformat()
|
||||||
|
self.data["uploaded_hashes"] = list(self._uploaded_hashes)
|
||||||
|
tmp_path = self.progress_file.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.data, f, ensure_ascii=False)
|
||||||
|
tmp_path.replace(self.progress_file)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _file_hash(filepath: Path) -> str:
|
||||||
|
"""Генерирует хеш на основе пути и размера файла (быстро, без чтения)."""
|
||||||
|
stat = filepath.stat()
|
||||||
|
key = f"{filepath.resolve()}|{stat.st_size}|{stat.st_mtime}"
|
||||||
|
return hashlib.md5(key.encode()).hexdigest()
|
||||||
|
|
||||||
|
def is_uploaded(self, filepath: Path) -> bool:
|
||||||
|
"""Проверяет, был ли файл уже загружен."""
|
||||||
|
return self._file_hash(filepath) in self._uploaded_hashes
|
||||||
|
|
||||||
|
def mark_uploaded(self, filepath: Path) -> None:
|
||||||
|
"""Помечает файл как загруженный."""
|
||||||
|
with self._lock:
|
||||||
|
self._uploaded_hashes.add(self._file_hash(filepath))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def uploaded_count(self) -> int:
|
||||||
|
return len(self._uploaded_hashes)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Сканер файлов
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scan_media_files(folders: list[str]) -> list[Path]:
|
||||||
|
"""Рекурсивно сканирует папки, возвращает отсортированный список медиафайлов."""
|
||||||
|
files: list[Path] = []
|
||||||
|
for folder in folders:
|
||||||
|
root = Path(folder)
|
||||||
|
if not root.exists():
|
||||||
|
tqdm.write(f" Папка не найдена: {folder}")
|
||||||
|
continue
|
||||||
|
for filepath in root.rglob("*"):
|
||||||
|
if not filepath.is_file():
|
||||||
|
continue
|
||||||
|
if filepath.suffix.lower() in MEDIA_EXTENSIONS:
|
||||||
|
files.append(filepath)
|
||||||
|
# Сортировка по имени для предсказуемого порядка
|
||||||
|
files.sort(key=lambda p: p.name)
|
||||||
|
return files
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Загрузчик
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ImmichUploader:
|
||||||
|
"""Загрузка файлов в Immich через API с многопоточностью."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._stop_requested: bool = False
|
||||||
|
self.progress: ProgressManager = ProgressManager(PROGRESS_FILE)
|
||||||
|
self.stats: UploadStats = UploadStats()
|
||||||
|
self._optimizer_fallbacks: int = 0 # Счётчик fallback-загрузок
|
||||||
|
self._optimizer_log_lock: Lock = Lock()
|
||||||
|
|
||||||
|
# HTTP-сессия с пулом соединений (keep-alive)
|
||||||
|
self._session: requests.Session = requests.Session()
|
||||||
|
self._session.headers.update({
|
||||||
|
"Accept": "application/json",
|
||||||
|
"x-api-key": API_KEY,
|
||||||
|
})
|
||||||
|
# Увеличиваем пул соединений для параллельных запросов
|
||||||
|
adapter = requests.adapters.HTTPAdapter(
|
||||||
|
pool_connections=MAX_WORKERS,
|
||||||
|
pool_maxsize=MAX_WORKERS * 2,
|
||||||
|
)
|
||||||
|
self._session.mount("http://", adapter)
|
||||||
|
self._session.mount("https://", adapter)
|
||||||
|
|
||||||
|
# Обработчики сигналов для graceful shutdown
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
|
def _signal_handler(self, _signum: int, _frame: object) -> None:
|
||||||
|
"""Graceful shutdown по Ctrl+C."""
|
||||||
|
if self._stop_requested:
|
||||||
|
# Повторный Ctrl+C — тихо выходим без traceback
|
||||||
|
os._exit(1)
|
||||||
|
self._stop_requested = True
|
||||||
|
tqdm.write(
|
||||||
|
"\nПолучен сигнал остановки. "
|
||||||
|
"Завершаю текущие загрузки и сохраняю прогресс..."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _check_connection(self) -> bool:
|
||||||
|
"""Проверяет подключение к Immich."""
|
||||||
|
try:
|
||||||
|
resp = self._session.get(
|
||||||
|
f"{IMMICH_URL}/api/server/version", timeout=10
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
version = f"v{data['major']}.{data['minor']}.{data['patch']}"
|
||||||
|
tqdm.write(f"Immich сервер: {version} ({IMMICH_URL})")
|
||||||
|
return True
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f"ОШИБКА: Не удалось подключиться к Immich: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _check_auth(self) -> bool:
|
||||||
|
"""Проверяет API-ключ."""
|
||||||
|
try:
|
||||||
|
resp = self._session.get(
|
||||||
|
f"{IMMICH_URL}/api/users/me", timeout=10
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
user = resp.json()
|
||||||
|
tqdm.write(f"Авторизован как: {user.get('name', user.get('email', '?'))}")
|
||||||
|
return True
|
||||||
|
tqdm.write(f"ОШИБКА авторизации: HTTP {resp.status_code}")
|
||||||
|
return False
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f"ОШИБКА авторизации: {exc}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Паттерны даты в именах файлов
|
||||||
|
_FILENAME_DATE_PATTERNS: list[tuple[str, str]] = [
|
||||||
|
# "2019-12-01 15-30-00" (Яндекс Диск)
|
||||||
|
(r"(\d{4}-\d{2}-\d{2})\s+(\d{2}-\d{2}-\d{2})", "%Y-%m-%d %H-%M-%S"),
|
||||||
|
# "20191201_153000" (стандарт камер)
|
||||||
|
(r"(\d{8}_\d{6})", "%Y%m%d_%H%M%S"),
|
||||||
|
# "2019-12-01_15-30-00"
|
||||||
|
(r"(\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})", "%Y-%m-%d_%H-%M-%S"),
|
||||||
|
# "IMG_20191201_153000"
|
||||||
|
(r"IMG_(\d{8}_\d{6})", "%Y%m%d_%H%M%S"),
|
||||||
|
# Только дата "2019-12-01"
|
||||||
|
(r"(\d{4}-\d{2}-\d{2})", "%Y-%m-%d"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _parse_date_from_filename(stem: str) -> Optional[datetime]:
|
||||||
|
"""Извлекает дату из имени файла (без расширения).
|
||||||
|
|
||||||
|
Поддерживает форматы Яндекс Диска, камер и прочие.
|
||||||
|
"""
|
||||||
|
for pattern, fmt in ImmichUploader._FILENAME_DATE_PATTERNS:
|
||||||
|
match = re.search(pattern, stem)
|
||||||
|
if match:
|
||||||
|
date_str = " ".join(match.groups()) if match.lastindex and match.lastindex > 1 else match.group(1)
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, fmt)
|
||||||
|
except ValueError:
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _read_google_sidecar(filepath: Path) -> Optional[dict]:
|
||||||
|
"""Читает JSON-сайдкар Google Фото (supplemental-metadata.json).
|
||||||
|
|
||||||
|
Возвращает dict с метаданными или None, если сайдкар не найден.
|
||||||
|
"""
|
||||||
|
# Google Фото кладёт метаданные в файл вида: photo.jpg.supplemental-metadata.json
|
||||||
|
sidecar = filepath.with_name(filepath.name + ".supplemental-metadata.json")
|
||||||
|
if not sidecar.exists():
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
with open(sidecar, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _log_optimizer_failed(self, filepath: Path) -> None:
|
||||||
|
"""Записывает файл в лог неудачных оптимизаций."""
|
||||||
|
with self._optimizer_log_lock:
|
||||||
|
self._optimizer_fallbacks += 1
|
||||||
|
with open(OPTIMIZER_FAILED_LOG, "a", encoding="utf-8") as f:
|
||||||
|
f.write(f"{filepath}\n")
|
||||||
|
|
||||||
|
def _do_upload(self, filepath: Path, data: dict, upload_url: str) -> requests.Response:
|
||||||
|
"""Выполняет HTTP-загрузку файла на указанный URL."""
|
||||||
|
with open(filepath, "rb") as f:
|
||||||
|
files = {"assetData": (filepath.name, f, "application/octet-stream")}
|
||||||
|
return self._session.post(
|
||||||
|
f"{upload_url}/api/assets",
|
||||||
|
data=data,
|
||||||
|
files=files,
|
||||||
|
timeout=UPLOAD_TIMEOUT,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _handle_response(self, resp: requests.Response, file_size: int) -> Optional[str]:
|
||||||
|
"""Обрабатывает ответ Immich. Возвращает статус или None если нужен retry."""
|
||||||
|
if resp.status_code == 201:
|
||||||
|
result = resp.json()
|
||||||
|
status = result.get("status", "created")
|
||||||
|
if status == "duplicate" or result.get("duplicate"):
|
||||||
|
self.stats.inc_duplicate()
|
||||||
|
return "duplicate"
|
||||||
|
self.stats.inc_uploaded(file_size)
|
||||||
|
return "created"
|
||||||
|
elif resp.status_code == 200:
|
||||||
|
# Дубликат (некоторые версии Immich)
|
||||||
|
self.stats.inc_duplicate()
|
||||||
|
return "duplicate"
|
||||||
|
return None # Нужен retry или fallback
|
||||||
|
|
||||||
|
def _upload_one(self, filepath: Path) -> Optional[str]:
|
||||||
|
"""Загружает один файл в Immich. Возвращает статус или None при ошибке.
|
||||||
|
|
||||||
|
Логика: сначала через Upload Optimizer (порт 2283).
|
||||||
|
Если оптимизатор вернул 400 — fallback на прямую загрузку (порт 2284).
|
||||||
|
|
||||||
|
Статусы: 'created', 'duplicate', 'error'
|
||||||
|
"""
|
||||||
|
stat = filepath.stat()
|
||||||
|
file_size = stat.st_size
|
||||||
|
|
||||||
|
# Базовые метаданные из файловой системы
|
||||||
|
created_at = datetime.fromtimestamp(
|
||||||
|
stat.st_birthtime if hasattr(stat, "st_birthtime") else stat.st_mtime
|
||||||
|
)
|
||||||
|
modified_at = datetime.fromtimestamp(stat.st_mtime)
|
||||||
|
|
||||||
|
# 1) Пробуем парсить дату из имени файла (Яндекс Диск: "2019-12-01 15-30-00.JPG")
|
||||||
|
parsed_date = self._parse_date_from_filename(filepath.stem)
|
||||||
|
if parsed_date:
|
||||||
|
created_at = parsed_date
|
||||||
|
|
||||||
|
# 2) Google JSON-сайдкар перезаписывает — он точнее всего
|
||||||
|
sidecar = self._read_google_sidecar(filepath)
|
||||||
|
if sidecar:
|
||||||
|
photo_taken = sidecar.get("photoTakenTime", {}).get("timestamp")
|
||||||
|
if photo_taken:
|
||||||
|
try:
|
||||||
|
created_at = datetime.fromtimestamp(int(photo_taken))
|
||||||
|
except (ValueError, OSError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"deviceAssetId": f"{filepath.name}-{stat.st_size}-{stat.st_mtime}",
|
||||||
|
"deviceId": DEVICE_ID,
|
||||||
|
"fileCreatedAt": created_at.isoformat(),
|
||||||
|
"fileModifiedAt": modified_at.isoformat(),
|
||||||
|
"isFavorite": "false",
|
||||||
|
}
|
||||||
|
|
||||||
|
# GPS из Google-сайдкара (если есть и не нулевые)
|
||||||
|
if sidecar:
|
||||||
|
geo = sidecar.get("geoData", {})
|
||||||
|
lat = geo.get("latitude", 0.0)
|
||||||
|
lon = geo.get("longitude", 0.0)
|
||||||
|
if lat != 0.0 or lon != 0.0:
|
||||||
|
data["latitude"] = str(lat)
|
||||||
|
data["longitude"] = str(lon)
|
||||||
|
|
||||||
|
# Видео грузим напрямую — оптимизатор не умеет их обрабатывать
|
||||||
|
is_video = filepath.suffix.lower() in VIDEO_EXTENSIONS
|
||||||
|
if is_video:
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = self._do_upload(filepath, data, IMMICH_DIRECT_URL)
|
||||||
|
result = self._handle_response(resp, file_size)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
else:
|
||||||
|
tqdm.write(
|
||||||
|
f" Ошибка загрузки {filepath.name}: "
|
||||||
|
f"HTTP {resp.status_code} — {resp.text[:200]}"
|
||||||
|
)
|
||||||
|
self.stats.inc_error()
|
||||||
|
return "error"
|
||||||
|
except (requests.RequestException, OSError) as exc:
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
else:
|
||||||
|
tqdm.write(f" Ошибка загрузки {filepath.name}: {exc}")
|
||||||
|
self.stats.inc_error()
|
||||||
|
return "error"
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
# --- Прямая загрузка в Immich (порт 2284), минуя оптимизатор ---
|
||||||
|
for attempt in range(MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = self._do_upload(filepath, data, IMMICH_DIRECT_URL)
|
||||||
|
result = self._handle_response(resp, file_size)
|
||||||
|
if result is not None:
|
||||||
|
return result
|
||||||
|
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
else:
|
||||||
|
tqdm.write(
|
||||||
|
f" Ошибка загрузки {filepath.name}: "
|
||||||
|
f"HTTP {resp.status_code} — {resp.text[:200]}"
|
||||||
|
)
|
||||||
|
self.stats.inc_error()
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
except (requests.RequestException, OSError) as exc:
|
||||||
|
if attempt < MAX_RETRIES - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
else:
|
||||||
|
tqdm.write(f" Ошибка загрузки {filepath.name}: {exc}")
|
||||||
|
self.stats.inc_error()
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
return "error"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_size(size_bytes: int) -> str:
|
||||||
|
"""Форматирует размер в человекочитаемый вид."""
|
||||||
|
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
||||||
|
if size_bytes < 1024:
|
||||||
|
return f"{size_bytes:.1f} {unit}"
|
||||||
|
size_bytes /= 1024
|
||||||
|
return f"{size_bytes:.1f} ТБ"
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Запускает процесс загрузки."""
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(" Массовая загрузка в Immich")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
|
||||||
|
# Проверяем подключение и авторизацию
|
||||||
|
if not self._check_connection() or not self._check_auth():
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Сканируем файлы
|
||||||
|
tqdm.write("\nСканирование папок...")
|
||||||
|
all_files = scan_media_files(UPLOAD_FOLDERS)
|
||||||
|
total_size = sum(f.stat().st_size for f in all_files)
|
||||||
|
tqdm.write(
|
||||||
|
f"Найдено медиафайлов: {len(all_files)} "
|
||||||
|
f"({self._format_size(total_size)})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Фильтрация по режиму (photos / videos / all)
|
||||||
|
if UPLOAD_MODE == "photos":
|
||||||
|
skipped_videos = [f for f in all_files if f.suffix.lower() in VIDEO_EXTENSIONS]
|
||||||
|
if skipped_videos:
|
||||||
|
with open(SKIPPED_VIDEOS_LOG, "w", encoding="utf-8") as vlog:
|
||||||
|
for v in skipped_videos:
|
||||||
|
vlog.write(f"{v}\n")
|
||||||
|
tqdm.write(
|
||||||
|
f"Режим: только фото. Пропущено видео: {len(skipped_videos)} "
|
||||||
|
f"(см. {SKIPPED_VIDEOS_LOG})"
|
||||||
|
)
|
||||||
|
all_files = [f for f in all_files if f.suffix.lower() in IMAGE_EXTENSIONS]
|
||||||
|
elif UPLOAD_MODE == "videos":
|
||||||
|
all_files = [f for f in all_files if f.suffix.lower() in VIDEO_EXTENSIONS]
|
||||||
|
tqdm.write(f"Режим: только видео ({len(all_files)} файлов)")
|
||||||
|
|
||||||
|
# Фильтруем уже загруженные
|
||||||
|
pending_files = [f for f in all_files if not self.progress.is_uploaded(f)]
|
||||||
|
pending_size = sum(f.stat().st_size for f in pending_files)
|
||||||
|
already_done = len(all_files) - len(pending_files)
|
||||||
|
|
||||||
|
tqdm.write(
|
||||||
|
f"Уже загружено: {already_done} файлов\n"
|
||||||
|
f"Осталось загрузить: {len(pending_files)} файлов "
|
||||||
|
f"({self._format_size(pending_size)})"
|
||||||
|
)
|
||||||
|
tqdm.write(f"Потоков: {MAX_WORKERS}")
|
||||||
|
tqdm.write("-" * 60)
|
||||||
|
|
||||||
|
if not pending_files:
|
||||||
|
tqdm.write("\nВсе файлы уже загружены!")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Прогресс-бар с размером и скоростью МБ/с
|
||||||
|
bar = tqdm(
|
||||||
|
total=len(all_files),
|
||||||
|
initial=already_done,
|
||||||
|
desc="Загрузка",
|
||||||
|
unit=" файл",
|
||||||
|
dynamic_ncols=True,
|
||||||
|
bar_format=(
|
||||||
|
"{l_bar}{bar}| {n_fmt}/{total_fmt} "
|
||||||
|
"[{elapsed}<{remaining}, {rate_fmt}] {postfix}"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Счётчик для периодического сохранения прогресса
|
||||||
|
save_counter = 0
|
||||||
|
save_lock = Lock()
|
||||||
|
start_time = time.time()
|
||||||
|
|
||||||
|
def _process_file(filepath: Path) -> Optional[str]:
|
||||||
|
"""Обёртка загрузки одного файла для ThreadPoolExecutor."""
|
||||||
|
if self._stop_requested:
|
||||||
|
return None
|
||||||
|
return self._upload_one(filepath)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
|
||||||
|
# Отправляем все файлы в пул
|
||||||
|
future_to_file = {
|
||||||
|
executor.submit(_process_file, f): f
|
||||||
|
for f in pending_files
|
||||||
|
}
|
||||||
|
|
||||||
|
for future in as_completed(future_to_file):
|
||||||
|
if self._stop_requested:
|
||||||
|
# Отменяем оставшиеся задачи
|
||||||
|
for f in future_to_file:
|
||||||
|
f.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
filepath = future_to_file[future]
|
||||||
|
try:
|
||||||
|
status = future.result()
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" Неожиданная ошибка ({filepath.name}): {exc}")
|
||||||
|
self.stats.inc_error()
|
||||||
|
status = "error"
|
||||||
|
|
||||||
|
if status and status != "error":
|
||||||
|
self.progress.mark_uploaded(filepath)
|
||||||
|
# Fallback-загрузки сохраняем в прогресс сразу,
|
||||||
|
# чтобы не потерять при остановке скрипта
|
||||||
|
if status.startswith("fallback_"):
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
bar.update(1)
|
||||||
|
|
||||||
|
# Обновляем описание с текущей скоростью
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
if elapsed > 0:
|
||||||
|
speed = self.stats.bytes_sent / elapsed if self.stats.bytes_sent > 0 else 0
|
||||||
|
speed_str = f"{self._format_size(speed)}/с" if speed > 0 else "..."
|
||||||
|
fallback_str = (
|
||||||
|
f" FB:{self._optimizer_fallbacks}"
|
||||||
|
if self._optimizer_fallbacks > 0
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
bar.set_postfix_str(
|
||||||
|
f"{speed_str} | "
|
||||||
|
f"OK:{self.stats.uploaded} "
|
||||||
|
f"DUP:{self.stats.duplicates} "
|
||||||
|
f"ERR:{self.stats.errors}"
|
||||||
|
f"{fallback_str}",
|
||||||
|
refresh=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Сохраняем прогресс каждые 20 файлов
|
||||||
|
with save_lock:
|
||||||
|
save_counter += 1
|
||||||
|
if save_counter % 20 == 0:
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
finally:
|
||||||
|
bar.close()
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
elapsed = time.time() - start_time
|
||||||
|
avg_speed = self.stats.bytes_sent / elapsed if elapsed > 0 else 0
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" Итого:")
|
||||||
|
print(f" Загружено: {self.stats.uploaded}")
|
||||||
|
print(f" Дубликатов: {self.stats.duplicates}")
|
||||||
|
print(f" Пропущено: {self.stats.skipped}")
|
||||||
|
print(f" Ошибок: {self.stats.errors}")
|
||||||
|
if self._optimizer_fallbacks > 0:
|
||||||
|
print(f" Fallback (без оптимизации): {self._optimizer_fallbacks}")
|
||||||
|
print(f" (см. {OPTIMIZER_FAILED_LOG})")
|
||||||
|
print(f" Передано: {self._format_size(self.stats.bytes_sent)}")
|
||||||
|
print(f" Средняя скорость: {self._format_size(avg_speed)}/с")
|
||||||
|
print(f" Время: {elapsed:.0f} сек")
|
||||||
|
if self._stop_requested:
|
||||||
|
print(
|
||||||
|
"\n Работа остановлена. "
|
||||||
|
"Запусти скрипт снова для продолжения."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
print("\n Все файлы загружены!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Точка входа скрипта."""
|
||||||
|
uploader = ImmichUploader()
|
||||||
|
uploader.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
957
main.py
Normal file
957
main.py
Normal file
@@ -0,0 +1,957 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для выгрузки всех фотографий из личных диалогов ВКонтакте.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
1. Заполни VK_TOKEN в config.py (инструкция в файле)
|
||||||
|
2. pip install -r requirements.txt
|
||||||
|
3. python main.py
|
||||||
|
4. Ctrl+C для остановки (прогресс сохраняется автоматически)
|
||||||
|
5. Повторный запуск продолжит с места остановки
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import piexif
|
||||||
|
import piexif.helper
|
||||||
|
import requests
|
||||||
|
import vk_api
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
# Приоритет размеров фото ВК (от лучшего к худшему)
|
||||||
|
PHOTO_SIZE_PRIORITY: list[str] = ["w", "z", "y", "x", "r", "q", "p", "o", "m", "s"]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Модели данных
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PhotoInfo:
|
||||||
|
"""Информация о фотографии для скачивания и записи EXIF."""
|
||||||
|
|
||||||
|
photo_id: int
|
||||||
|
owner_id: int
|
||||||
|
url: str
|
||||||
|
date: int # Unix timestamp сообщения / фото
|
||||||
|
sender_id: int
|
||||||
|
sender_name: str
|
||||||
|
message_text: str
|
||||||
|
photo_text: str
|
||||||
|
lat: Optional[float] = None
|
||||||
|
long: Optional[float] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Менеджер прогресса (resume)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ProgressManager:
|
||||||
|
"""Управление файлом прогресса для механизма resume (потокобезопасный)."""
|
||||||
|
|
||||||
|
def __init__(self, progress_file: str) -> None:
|
||||||
|
self.progress_file: Path = Path(progress_file)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.data: dict = self._load()
|
||||||
|
self._downloaded_ids: set[int] = set(self.data.get("downloaded_photo_ids", []))
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
"""Загружает прогресс из файла или создаёт пустой."""
|
||||||
|
if self.progress_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.progress_file, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return self._default_state()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_state() -> dict:
|
||||||
|
"""Возвращает пустое состояние прогресса."""
|
||||||
|
return {
|
||||||
|
"version": 1,
|
||||||
|
"last_updated": "",
|
||||||
|
"dialogs_total": 0,
|
||||||
|
"dialogs_completed": [],
|
||||||
|
"current_dialog": None,
|
||||||
|
"downloaded_photo_ids": [],
|
||||||
|
"stats": {
|
||||||
|
"photos_downloaded": 0,
|
||||||
|
"photos_skipped": 0,
|
||||||
|
"errors": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Сохраняет текущее состояние прогресса в файл."""
|
||||||
|
with self._lock:
|
||||||
|
self.data["last_updated"] = datetime.now().isoformat()
|
||||||
|
self.data["downloaded_photo_ids"] = list(self._downloaded_ids)
|
||||||
|
tmp_path = self.progress_file.with_suffix(".tmp")
|
||||||
|
with open(tmp_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.data, f, ensure_ascii=False, indent=2)
|
||||||
|
tmp_path.replace(self.progress_file)
|
||||||
|
|
||||||
|
def is_dialog_completed(self, peer_id: int) -> bool:
|
||||||
|
"""Проверяет, завершён ли диалог."""
|
||||||
|
return peer_id in self.data["dialogs_completed"]
|
||||||
|
|
||||||
|
def mark_dialog_completed(self, peer_id: int) -> None:
|
||||||
|
"""Помечает диалог как полностью обработанный."""
|
||||||
|
with self._lock:
|
||||||
|
if peer_id not in self.data["dialogs_completed"]:
|
||||||
|
self.data["dialogs_completed"].append(peer_id)
|
||||||
|
self.data["current_dialog"] = None
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def set_current_dialog(self, peer_id: int) -> None:
|
||||||
|
"""Устанавливает текущий обрабатываемый диалог."""
|
||||||
|
with self._lock:
|
||||||
|
self.data["current_dialog"] = {"peer_id": peer_id}
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get_current_dialog(self) -> Optional[dict]:
|
||||||
|
"""Возвращает текущий обрабатываемый диалог или None."""
|
||||||
|
return self.data.get("current_dialog")
|
||||||
|
|
||||||
|
def is_photo_downloaded(self, photo_id: int) -> bool:
|
||||||
|
"""Проверяет, было ли фото уже скачано."""
|
||||||
|
return photo_id in self._downloaded_ids
|
||||||
|
|
||||||
|
def mark_photo_downloaded(self, photo_id: int) -> None:
|
||||||
|
"""Отмечает фото как скачанное (потокобезопасно)."""
|
||||||
|
with self._lock:
|
||||||
|
self._downloaded_ids.add(photo_id)
|
||||||
|
self.data["stats"]["photos_downloaded"] += 1
|
||||||
|
|
||||||
|
def increment_skipped(self) -> None:
|
||||||
|
"""Увеличивает счётчик пропущенных фото (потокобезопасно)."""
|
||||||
|
with self._lock:
|
||||||
|
self.data["stats"]["photos_skipped"] += 1
|
||||||
|
|
||||||
|
def increment_errors(self) -> None:
|
||||||
|
"""Увеличивает счётчик ошибок (потокобезопасно)."""
|
||||||
|
with self._lock:
|
||||||
|
self.data["stats"]["errors"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Запись EXIF метаданных
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ExifWriter:
|
||||||
|
"""Запись EXIF метаданных в JPEG файлы."""
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _decimal_to_dms(decimal_degrees: float) -> tuple[tuple, bool]:
|
||||||
|
"""Конвертирует десятичные градусы в формат DMS для EXIF GPS."""
|
||||||
|
is_negative = decimal_degrees < 0
|
||||||
|
decimal_degrees = abs(decimal_degrees)
|
||||||
|
degrees = int(decimal_degrees)
|
||||||
|
minutes_float = (decimal_degrees - degrees) * 60
|
||||||
|
minutes = int(minutes_float)
|
||||||
|
seconds = round((minutes_float - minutes) * 60 * 10000)
|
||||||
|
dms = (
|
||||||
|
(degrees, 1),
|
||||||
|
(minutes, 1),
|
||||||
|
(seconds, 10000),
|
||||||
|
)
|
||||||
|
return dms, is_negative
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def write_exif(filepath: Path, photo_info: PhotoInfo) -> None:
|
||||||
|
"""Записывает EXIF метаданные в файл изображения."""
|
||||||
|
if filepath.suffix.lower() not in (".jpg", ".jpeg"):
|
||||||
|
ExifWriter._write_json_meta(filepath, photo_info)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
exif_dict = piexif.load(str(filepath))
|
||||||
|
except Exception:
|
||||||
|
exif_dict = {
|
||||||
|
"0th": {}, "Exif": {}, "GPS": {}, "1st": {}, "Interop": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Дата отправки сообщения
|
||||||
|
if photo_info.date:
|
||||||
|
dt = datetime.fromtimestamp(photo_info.date)
|
||||||
|
date_bytes = dt.strftime("%Y:%m:%d %H:%M:%S").encode("ascii")
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.DateTimeOriginal] = date_bytes
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.DateTimeDigitized] = date_bytes
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.DateTime] = date_bytes
|
||||||
|
|
||||||
|
# Автор
|
||||||
|
if photo_info.sender_name:
|
||||||
|
artist = photo_info.sender_name.encode("utf-8")
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.Artist] = artist
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.Copyright] = artist
|
||||||
|
|
||||||
|
# Описание фото из ВК
|
||||||
|
if photo_info.photo_text:
|
||||||
|
exif_dict["0th"][piexif.ImageIFD.ImageDescription] = (
|
||||||
|
photo_info.photo_text.encode("utf-8")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Текст сообщения → UserComment
|
||||||
|
if photo_info.message_text:
|
||||||
|
user_comment = piexif.helper.UserComment.dump(
|
||||||
|
photo_info.message_text, encoding="unicode"
|
||||||
|
)
|
||||||
|
exif_dict["Exif"][piexif.ExifIFD.UserComment] = user_comment
|
||||||
|
|
||||||
|
# GPS
|
||||||
|
if photo_info.lat is not None and photo_info.long is not None:
|
||||||
|
lat_dms, lat_neg = ExifWriter._decimal_to_dms(photo_info.lat)
|
||||||
|
lon_dms, lon_neg = ExifWriter._decimal_to_dms(photo_info.long)
|
||||||
|
exif_dict["GPS"] = {
|
||||||
|
piexif.GPSIFD.GPSVersionID: (2, 3, 0, 0),
|
||||||
|
piexif.GPSIFD.GPSLatitude: lat_dms,
|
||||||
|
piexif.GPSIFD.GPSLatitudeRef: b"S" if lat_neg else b"N",
|
||||||
|
piexif.GPSIFD.GPSLongitude: lon_dms,
|
||||||
|
piexif.GPSIFD.GPSLongitudeRef: b"W" if lon_neg else b"E",
|
||||||
|
}
|
||||||
|
|
||||||
|
exif_bytes = piexif.dump(exif_dict)
|
||||||
|
piexif.insert(exif_bytes, str(filepath))
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" EXIF ошибка ({filepath.name}): {exc}. Сохраняю в JSON.")
|
||||||
|
ExifWriter._write_json_meta(filepath, photo_info)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _write_json_meta(filepath: Path, photo_info: PhotoInfo) -> None:
|
||||||
|
"""Сохраняет метаданные в JSON файл рядом с изображением."""
|
||||||
|
meta_path = filepath.with_suffix(filepath.suffix + ".meta.json")
|
||||||
|
meta: dict = {
|
||||||
|
"photo_id": photo_info.photo_id,
|
||||||
|
"date": datetime.fromtimestamp(photo_info.date).isoformat() if photo_info.date else None,
|
||||||
|
"sender_id": photo_info.sender_id,
|
||||||
|
"sender": photo_info.sender_name,
|
||||||
|
"message_text": photo_info.message_text,
|
||||||
|
"photo_text": photo_info.photo_text,
|
||||||
|
}
|
||||||
|
if photo_info.lat is not None:
|
||||||
|
meta["gps"] = {"lat": photo_info.lat, "long": photo_info.long}
|
||||||
|
|
||||||
|
with open(meta_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(meta, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Основной загрузчик
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class VKPhotoDownloader:
|
||||||
|
"""Скачивание фото из всех личных диалогов ВКонтакте."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._stop_requested: bool = False
|
||||||
|
self.progress: ProgressManager = ProgressManager(config.PROGRESS_FILE)
|
||||||
|
self.download_dir: Path = Path(config.DOWNLOAD_DIR)
|
||||||
|
self.download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._user_cache: dict[int, str] = {}
|
||||||
|
self._http_session: requests.Session = requests.Session()
|
||||||
|
|
||||||
|
# Обработчики сигналов для graceful shutdown
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
|
# Авторизация VK API
|
||||||
|
self._vk_session = vk_api.VkApi(token=config.VK_TOKEN, api_version=config.API_VERSION)
|
||||||
|
self.api = self._vk_session.get_api()
|
||||||
|
|
||||||
|
# -- сигналы --
|
||||||
|
|
||||||
|
def _signal_handler(self, _signum: int, _frame: object) -> None:
|
||||||
|
"""Graceful shutdown по Ctrl+C / SIGTERM."""
|
||||||
|
if self._stop_requested:
|
||||||
|
tqdm.write("\nПринудительная остановка!")
|
||||||
|
sys.exit(1)
|
||||||
|
self._stop_requested = True
|
||||||
|
tqdm.write(
|
||||||
|
"\nПолучен сигнал остановки. "
|
||||||
|
"Завершаю текущие загрузки и сохраняю прогресс..."
|
||||||
|
)
|
||||||
|
|
||||||
|
# -- утилиты --
|
||||||
|
|
||||||
|
def _check_free_space(self) -> bool:
|
||||||
|
"""Проверяет, достаточно ли свободного места на диске."""
|
||||||
|
usage = shutil.disk_usage(str(self.download_dir))
|
||||||
|
free_mb = usage.free / (1024 * 1024)
|
||||||
|
if free_mb < config.MIN_FREE_SPACE_MB:
|
||||||
|
tqdm.write(
|
||||||
|
f"Недостаточно места на диске! "
|
||||||
|
f"Свободно: {free_mb:.0f} МБ, минимум: {config.MIN_FREE_SPACE_MB} МБ"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_name(name: str) -> str:
|
||||||
|
"""Делает строку безопасной для имени папки/файла."""
|
||||||
|
return "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in name)
|
||||||
|
|
||||||
|
# -- VK API execute (до 25 вызовов за 1 запрос) --
|
||||||
|
|
||||||
|
def _execute(self, code: str) -> dict:
|
||||||
|
"""Выполняет VKScript через метод execute."""
|
||||||
|
return self._vk_session.method("execute", {"code": code})
|
||||||
|
|
||||||
|
# -- батч-загрузка имён пользователей --
|
||||||
|
|
||||||
|
def _prefetch_user_names(self, user_ids: list[int]) -> None:
|
||||||
|
"""Загружает имена пользователей пакетами (до 1000 за запрос)."""
|
||||||
|
# Разделяем на пользователей и сообщества
|
||||||
|
need_users: list[int] = []
|
||||||
|
need_groups: list[int] = []
|
||||||
|
|
||||||
|
for uid in user_ids:
|
||||||
|
if uid in self._user_cache or uid == 0:
|
||||||
|
continue
|
||||||
|
if uid > 2_000_000_000:
|
||||||
|
self._user_cache[uid] = f"Беседа_{uid - 2_000_000_000}"
|
||||||
|
elif uid > 0:
|
||||||
|
need_users.append(uid)
|
||||||
|
else:
|
||||||
|
need_groups.append(abs(uid))
|
||||||
|
|
||||||
|
# Пакетная загрузка пользователей (до 1000 за запрос)
|
||||||
|
for i in range(0, len(need_users), 1000):
|
||||||
|
batch = need_users[i:i + 1000]
|
||||||
|
try:
|
||||||
|
ids_str = ",".join(str(x) for x in batch)
|
||||||
|
users = self.api.users.get(user_ids=ids_str)
|
||||||
|
for u in users:
|
||||||
|
self._user_cache[u["id"]] = f"{u['first_name']} {u['last_name']}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
# Пакетная загрузка сообществ (до 500 за запрос)
|
||||||
|
for i in range(0, len(need_groups), 500):
|
||||||
|
batch = need_groups[i:i + 500]
|
||||||
|
try:
|
||||||
|
ids_str = ",".join(str(x) for x in batch)
|
||||||
|
resp = self.api.groups.getById(group_ids=ids_str)
|
||||||
|
groups = resp if isinstance(resp, list) else resp.get("groups", [])
|
||||||
|
for g in groups:
|
||||||
|
gid = -g["id"]
|
||||||
|
self._user_cache[gid] = g.get("name", f"group_{g['id']}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
def _get_user_name(self, user_id: int) -> str:
|
||||||
|
"""Получает имя из кэша. Если нет — дозагружает."""
|
||||||
|
if user_id in self._user_cache:
|
||||||
|
return self._user_cache[user_id]
|
||||||
|
# Фоллбэк на единичный запрос
|
||||||
|
self._prefetch_user_names([user_id])
|
||||||
|
return self._user_cache.get(user_id, f"id{user_id}")
|
||||||
|
|
||||||
|
# -- получение списка диалогов через execute --
|
||||||
|
|
||||||
|
def _get_all_conversations(self) -> list[dict]:
|
||||||
|
"""Получает полный список диалогов (через execute — 25 страниц за запрос)."""
|
||||||
|
conversations: list[dict] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
tqdm.write("Получаю список диалогов...")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
# VKScript: за один execute получаем до 25 страниц по 200 диалогов
|
||||||
|
code = f"""
|
||||||
|
var results = [];
|
||||||
|
var offset = {offset};
|
||||||
|
var i = 0;
|
||||||
|
while (i < 25) {{
|
||||||
|
var resp = API.messages.getConversations({{
|
||||||
|
"offset": offset, "count": 200, "extended": 0
|
||||||
|
}});
|
||||||
|
results.push(resp);
|
||||||
|
offset = offset + 200;
|
||||||
|
if (offset >= resp.count || resp.items.length == 0) {{
|
||||||
|
return {{"results": results, "done": true}};
|
||||||
|
}}
|
||||||
|
i = i + 1;
|
||||||
|
}}
|
||||||
|
return {{"results": results, "done": false, "next_offset": offset}};
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._execute(code)
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" execute ошибка (conversations): {exc}, пробую обычный метод")
|
||||||
|
return self._get_all_conversations_fallback()
|
||||||
|
|
||||||
|
for page in data.get("results", []):
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
for item in page.get("items", []):
|
||||||
|
peer = item["conversation"]["peer"]
|
||||||
|
if peer["type"] == "chat":
|
||||||
|
continue
|
||||||
|
conversations.append(
|
||||||
|
{"peer_id": peer["id"], "type": peer["type"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get("done", True):
|
||||||
|
break
|
||||||
|
offset = data.get("next_offset", offset + 5000)
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
tqdm.write(f"Найдено личных диалогов: {len(conversations)} (беседы пропущены)")
|
||||||
|
return conversations
|
||||||
|
|
||||||
|
def _get_all_conversations_fallback(self) -> list[dict]:
|
||||||
|
"""Фоллбэк: получение диалогов обычным методом (без execute)."""
|
||||||
|
conversations: list[dict] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
resp = self.api.messages.getConversations(
|
||||||
|
offset=offset, count=200, extended=0,
|
||||||
|
)
|
||||||
|
items = resp.get("items", [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for item in items:
|
||||||
|
peer = item["conversation"]["peer"]
|
||||||
|
if peer["type"] == "chat":
|
||||||
|
continue
|
||||||
|
conversations.append(
|
||||||
|
{"peer_id": peer["id"], "type": peer["type"]}
|
||||||
|
)
|
||||||
|
offset += 200
|
||||||
|
if offset >= resp.get("count", 0):
|
||||||
|
break
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
return conversations
|
||||||
|
|
||||||
|
# -- выбор лучшего размера фото --
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _best_photo_url(photo: dict) -> Optional[str]:
|
||||||
|
"""Выбирает URL фото максимального доступного размера."""
|
||||||
|
orig = photo.get("orig_photo")
|
||||||
|
if orig and orig.get("url"):
|
||||||
|
return orig["url"]
|
||||||
|
|
||||||
|
sizes = photo.get("sizes")
|
||||||
|
if sizes:
|
||||||
|
size_map = {s["type"]: s["url"] for s in sizes}
|
||||||
|
for prio in PHOTO_SIZE_PRIORITY:
|
||||||
|
if prio in size_map:
|
||||||
|
return size_map[prio]
|
||||||
|
best = max(sizes, key=lambda s: s.get("width", 0) * s.get("height", 0))
|
||||||
|
return best.get("url")
|
||||||
|
|
||||||
|
for key in ("photo_2560", "photo_1280", "photo_807", "photo_604", "photo_130", "photo_75"):
|
||||||
|
if key in photo:
|
||||||
|
return photo[key]
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- извлечение фото из сообщений --
|
||||||
|
|
||||||
|
def _extract_photos_recursive(self, message: dict) -> list[dict]:
|
||||||
|
"""Рекурсивно извлекает все фото из сообщения (вложения + пересланные)."""
|
||||||
|
result: list[dict] = []
|
||||||
|
|
||||||
|
for att in message.get("attachments", []):
|
||||||
|
if att.get("type") == "photo":
|
||||||
|
photo = att["photo"]
|
||||||
|
url = self._best_photo_url(photo)
|
||||||
|
if url:
|
||||||
|
result.append({
|
||||||
|
"photo": photo,
|
||||||
|
"url": url,
|
||||||
|
"from_id": message.get("from_id", 0),
|
||||||
|
"date": message.get("date", photo.get("date", 0)),
|
||||||
|
"message_text": message.get("text", ""),
|
||||||
|
})
|
||||||
|
|
||||||
|
for fwd in message.get("fwd_messages", []):
|
||||||
|
result.extend(self._extract_photos_recursive(fwd))
|
||||||
|
|
||||||
|
reply = message.get("reply_message")
|
||||||
|
if reply:
|
||||||
|
result.extend(self._extract_photos_recursive(reply))
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- сбор фото через execute (getHistoryAttachments, до 25 страниц за запрос) --
|
||||||
|
|
||||||
|
def _collect_all_attachment_photos(self, peer_id: int) -> list[dict]:
|
||||||
|
"""Собирает все фото-вложения через execute (25 страниц за 1 API вызов)."""
|
||||||
|
all_photos: list[dict] = []
|
||||||
|
cursor = ""
|
||||||
|
|
||||||
|
while not self._stop_requested:
|
||||||
|
# VKScript: до 25 вызовов getHistoryAttachments за один execute
|
||||||
|
start_from_clause = (
|
||||||
|
f'"start_from": "{cursor}",' if cursor else ""
|
||||||
|
)
|
||||||
|
code = f"""
|
||||||
|
var results = [];
|
||||||
|
var cursor = "{cursor}";
|
||||||
|
var i = 0;
|
||||||
|
while (i < 25) {{
|
||||||
|
var params = {{
|
||||||
|
"peer_id": {peer_id},
|
||||||
|
"media_type": "photo",
|
||||||
|
"count": 200,
|
||||||
|
"preserve_order": 1
|
||||||
|
}};
|
||||||
|
if (cursor != "") {{
|
||||||
|
params.start_from = cursor;
|
||||||
|
}}
|
||||||
|
var resp = API.messages.getHistoryAttachments(params);
|
||||||
|
results.push(resp);
|
||||||
|
if (!resp.next_from || resp.items.length == 0) {{
|
||||||
|
return {{"results": results, "cursor": ""}};
|
||||||
|
}}
|
||||||
|
cursor = resp.next_from;
|
||||||
|
i = i + 1;
|
||||||
|
}}
|
||||||
|
return {{"results": results, "cursor": cursor}};
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._execute(code)
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" execute ошибка (attachments): {exc}, фоллбэк")
|
||||||
|
return self._collect_attachments_fallback(peer_id, all_photos, cursor)
|
||||||
|
|
||||||
|
for page in data.get("results", []):
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
for item in page.get("items", []):
|
||||||
|
att = item.get("attachment", {})
|
||||||
|
if att.get("type") != "photo":
|
||||||
|
continue
|
||||||
|
photo = att["photo"]
|
||||||
|
url = self._best_photo_url(photo)
|
||||||
|
if url:
|
||||||
|
all_photos.append({
|
||||||
|
"photo": photo,
|
||||||
|
"url": url,
|
||||||
|
"from_id": item.get("from_id", 0),
|
||||||
|
"date": item.get("date", photo.get("date", 0)),
|
||||||
|
"message_text": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor = data.get("cursor", "")
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
return all_photos
|
||||||
|
|
||||||
|
def _collect_attachments_fallback(
|
||||||
|
self, peer_id: int, existing: list[dict], start_from: str
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Фоллбэк: обычная пагинация getHistoryAttachments."""
|
||||||
|
cursor: Optional[str] = start_from or None
|
||||||
|
while not self._stop_requested:
|
||||||
|
params: dict = {
|
||||||
|
"peer_id": peer_id, "media_type": "photo",
|
||||||
|
"count": 200, "preserve_order": 1,
|
||||||
|
}
|
||||||
|
if cursor:
|
||||||
|
params["start_from"] = cursor
|
||||||
|
resp = self.api.messages.getHistoryAttachments(**params)
|
||||||
|
items = resp.get("items", [])
|
||||||
|
cursor = resp.get("next_from")
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for item in items:
|
||||||
|
att = item.get("attachment", {})
|
||||||
|
if att.get("type") != "photo":
|
||||||
|
continue
|
||||||
|
photo = att["photo"]
|
||||||
|
url = self._best_photo_url(photo)
|
||||||
|
if url:
|
||||||
|
existing.append({
|
||||||
|
"photo": photo, "url": url,
|
||||||
|
"from_id": item.get("from_id", 0),
|
||||||
|
"date": item.get("date", photo.get("date", 0)),
|
||||||
|
"message_text": "",
|
||||||
|
})
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
time.sleep(0.34)
|
||||||
|
return existing
|
||||||
|
|
||||||
|
# -- сбор фото из пересланных через execute (getHistory) --
|
||||||
|
|
||||||
|
def _collect_forwarded_photos(
|
||||||
|
self, peer_id: int, known_ids: set[int],
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Сканирует историю сообщений через execute (25 страниц за запрос)."""
|
||||||
|
found: list[dict] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
while not self._stop_requested:
|
||||||
|
code = f"""
|
||||||
|
var results = [];
|
||||||
|
var offset = {offset};
|
||||||
|
var i = 0;
|
||||||
|
while (i < 25) {{
|
||||||
|
var resp = API.messages.getHistory({{
|
||||||
|
"peer_id": {peer_id}, "offset": offset, "count": 200
|
||||||
|
}});
|
||||||
|
results.push(resp);
|
||||||
|
offset = offset + 200;
|
||||||
|
if (offset >= resp.count || resp.items.length == 0) {{
|
||||||
|
return {{"results": results, "done": true}};
|
||||||
|
}}
|
||||||
|
i = i + 1;
|
||||||
|
}}
|
||||||
|
return {{"results": results, "done": false, "next_offset": offset}};
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._execute(code)
|
||||||
|
except vk_api.exceptions.ApiError as exc:
|
||||||
|
tqdm.write(f" API ошибка при getHistory: {exc}")
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" execute ошибка (history): {exc}, прерываю сканирование")
|
||||||
|
break
|
||||||
|
|
||||||
|
for page in data.get("results", []):
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
for msg in page.get("items", []):
|
||||||
|
sources: list[dict] = []
|
||||||
|
for fwd in msg.get("fwd_messages", []):
|
||||||
|
sources.extend(self._extract_photos_recursive(fwd))
|
||||||
|
reply = msg.get("reply_message")
|
||||||
|
if reply:
|
||||||
|
sources.extend(self._extract_photos_recursive(reply))
|
||||||
|
|
||||||
|
for item in sources:
|
||||||
|
pid = item["photo"].get("id", 0)
|
||||||
|
if pid and pid not in known_ids:
|
||||||
|
known_ids.add(pid)
|
||||||
|
if not item.get("message_text"):
|
||||||
|
item["message_text"] = msg.get("text", "")
|
||||||
|
found.append(item)
|
||||||
|
|
||||||
|
if data.get("done", True):
|
||||||
|
break
|
||||||
|
offset = data.get("next_offset", offset + 5000)
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
return found
|
||||||
|
|
||||||
|
# -- скачивание одного фото (для потока) --
|
||||||
|
|
||||||
|
def _download_single(
|
||||||
|
self, photo_data: dict, dialog_dir: Path
|
||||||
|
) -> Optional[PhotoInfo]:
|
||||||
|
"""Скачивает одно фото, записывает EXIF и utime. Возвращает PhotoInfo или None."""
|
||||||
|
photo = photo_data["photo"]
|
||||||
|
photo_id = photo.get("id", 0)
|
||||||
|
url = photo_data["url"]
|
||||||
|
date_ts: int = photo_data.get("date", photo.get("date", 0))
|
||||||
|
|
||||||
|
# Формируем путь: dialog_dir / YYYY / photo_{id}_{date}.jpg
|
||||||
|
dt = datetime.fromtimestamp(date_ts) if date_ts else datetime.now()
|
||||||
|
subfolder = dt.strftime("%Y")
|
||||||
|
filename = f"photo_{photo_id}_{dt.strftime('%Y%m%d_%H%M%S')}.jpg"
|
||||||
|
filepath = dialog_dir / subfolder / filename
|
||||||
|
|
||||||
|
# Скачиваем с retry
|
||||||
|
for attempt in range(config.MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = self._http_session.get(
|
||||||
|
url, timeout=config.DOWNLOAD_TIMEOUT, stream=True
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
filepath.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
break
|
||||||
|
except (requests.RequestException, OSError) as exc:
|
||||||
|
if attempt < config.MAX_RETRIES - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
else:
|
||||||
|
tqdm.write(f" Ошибка скачивания ({filename}): {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
sender_id = photo_data.get("from_id", 0)
|
||||||
|
sender_name = self._user_cache.get(sender_id, f"id{sender_id}") if sender_id else ""
|
||||||
|
|
||||||
|
info = PhotoInfo(
|
||||||
|
photo_id=photo_id,
|
||||||
|
owner_id=photo.get("owner_id", 0),
|
||||||
|
url=url,
|
||||||
|
date=date_ts,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
message_text=photo_data.get("message_text", ""),
|
||||||
|
photo_text=photo.get("text", ""),
|
||||||
|
lat=photo.get("lat"),
|
||||||
|
long=photo.get("long"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# EXIF
|
||||||
|
ExifWriter.write_exif(filepath, info)
|
||||||
|
|
||||||
|
# Дата файла = дата сообщения
|
||||||
|
if date_ts:
|
||||||
|
os.utime(filepath, (date_ts, date_ts))
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
# -- обработка одного диалога --
|
||||||
|
|
||||||
|
def _process_dialog(
|
||||||
|
self, peer_id: int, dialog_name: str, photos_bar: tqdm
|
||||||
|
) -> None:
|
||||||
|
"""Обрабатывает один диалог: собирает и скачивает все фото."""
|
||||||
|
|
||||||
|
saved = self.progress.get_current_dialog()
|
||||||
|
resuming = saved is not None and saved.get("peer_id") == peer_id
|
||||||
|
|
||||||
|
if not resuming:
|
||||||
|
self.progress.set_current_dialog(peer_id)
|
||||||
|
|
||||||
|
known_ids: set[int] = set()
|
||||||
|
all_photos: list[dict] = []
|
||||||
|
|
||||||
|
# Фаза 1: вложения через execute + getHistoryAttachments
|
||||||
|
tqdm.write(f" [{dialog_name}] Сбор фото-вложений...")
|
||||||
|
att_photos = self._collect_all_attachment_photos(peer_id)
|
||||||
|
for p in att_photos:
|
||||||
|
pid = p["photo"].get("id", 0)
|
||||||
|
if pid and pid not in known_ids:
|
||||||
|
known_ids.add(pid)
|
||||||
|
all_photos.append(p)
|
||||||
|
|
||||||
|
# Фаза 2: пересланные сообщения через execute + getHistory
|
||||||
|
if not self._stop_requested:
|
||||||
|
tqdm.write(f" [{dialog_name}] Поиск фото в пересланных сообщениях...")
|
||||||
|
fwd_photos = self._collect_forwarded_photos(peer_id, known_ids)
|
||||||
|
all_photos.extend(fwd_photos)
|
||||||
|
|
||||||
|
if not all_photos:
|
||||||
|
tqdm.write(f" [{dialog_name}] Нет фото")
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.progress.mark_dialog_completed(peer_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Фильтруем уже скачанные
|
||||||
|
tasks = [
|
||||||
|
p for p in all_photos
|
||||||
|
if not self.progress.is_photo_downloaded(p["photo"].get("id", 0))
|
||||||
|
]
|
||||||
|
skipped = len(all_photos) - len(tasks)
|
||||||
|
|
||||||
|
tqdm.write(
|
||||||
|
f" [{dialog_name}] Всего: {len(all_photos)}, "
|
||||||
|
f"скачать: {len(tasks)}, пропустить: {skipped}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.progress.mark_dialog_completed(peer_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Предзагрузка имён отправителей пакетом
|
||||||
|
sender_ids = list({t.get("from_id", 0) for t in tasks if t.get("from_id", 0)})
|
||||||
|
if sender_ids:
|
||||||
|
self._prefetch_user_names(sender_ids)
|
||||||
|
|
||||||
|
# Настройка прогресс-бара
|
||||||
|
photos_bar.reset(total=len(all_photos))
|
||||||
|
photos_bar.n = skipped
|
||||||
|
photos_bar.refresh()
|
||||||
|
photos_bar.set_description(f"Фото ({dialog_name[:25]})")
|
||||||
|
|
||||||
|
safe_dialog = self._safe_name(dialog_name)
|
||||||
|
dialog_dir = self.download_dir / f"{safe_dialog}_id{peer_id}"
|
||||||
|
|
||||||
|
# -- Параллельное скачивание через ThreadPoolExecutor --
|
||||||
|
completed_count = 0
|
||||||
|
|
||||||
|
with ThreadPoolExecutor(max_workers=config.DOWNLOAD_WORKERS) as executor:
|
||||||
|
futures: dict = {}
|
||||||
|
|
||||||
|
for task in tasks:
|
||||||
|
if self._stop_requested:
|
||||||
|
break
|
||||||
|
if not self._check_free_space():
|
||||||
|
tqdm.write("Остановка из-за нехватки места на диске.")
|
||||||
|
self._stop_requested = True
|
||||||
|
break
|
||||||
|
future = executor.submit(self._download_single, task, dialog_dir)
|
||||||
|
futures[future] = task
|
||||||
|
|
||||||
|
for future in as_completed(futures):
|
||||||
|
task = futures[future]
|
||||||
|
photo_id = task["photo"].get("id", 0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = future.result()
|
||||||
|
if result is not None:
|
||||||
|
self.progress.mark_photo_downloaded(photo_id)
|
||||||
|
else:
|
||||||
|
self.progress.increment_errors()
|
||||||
|
except Exception:
|
||||||
|
self.progress.increment_errors()
|
||||||
|
|
||||||
|
photos_bar.update(1)
|
||||||
|
completed_count += 1
|
||||||
|
|
||||||
|
# Сохраняем прогресс каждые 50 фото
|
||||||
|
if completed_count % 50 == 0:
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
if self._stop_requested:
|
||||||
|
# Отменяем ещё не запущенные задачи
|
||||||
|
for f in futures:
|
||||||
|
f.cancel()
|
||||||
|
break
|
||||||
|
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.progress.mark_dialog_completed(peer_id)
|
||||||
|
|
||||||
|
# -- главный цикл --
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Запускает процесс скачивания фотографий."""
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(" Выгрузка фото из диалогов ВКонтакте")
|
||||||
|
tqdm.write(f" Потоков скачивания: {config.DOWNLOAD_WORKERS}")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
|
||||||
|
if not config.VK_TOKEN:
|
||||||
|
tqdm.write(
|
||||||
|
"ОШИБКА: Заполни VK_TOKEN в config.py!\n"
|
||||||
|
"Инструкция по получению токена — в комментарии в config.py"
|
||||||
|
)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
me = self.api.users.get()[0]
|
||||||
|
my_name = f"{me['first_name']} {me['last_name']}"
|
||||||
|
tqdm.write(f"Авторизован как: {my_name}")
|
||||||
|
self._user_cache[me["id"]] = my_name
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f"ОШИБКА авторизации: {exc}")
|
||||||
|
tqdm.write("Проверь VK_TOKEN в config.py")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conversations = self._get_all_conversations()
|
||||||
|
self.progress.data["dialogs_total"] = len(conversations)
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
# Предзагрузка имён для всех собеседников
|
||||||
|
peer_ids = [c["peer_id"] for c in conversations]
|
||||||
|
tqdm.write("Загружаю имена собеседников...")
|
||||||
|
self._prefetch_user_names(peer_ids)
|
||||||
|
|
||||||
|
completed_ids: set[int] = set(self.progress.data["dialogs_completed"])
|
||||||
|
remaining = [c for c in conversations if c["peer_id"] not in completed_ids]
|
||||||
|
|
||||||
|
current = self.progress.get_current_dialog()
|
||||||
|
if current:
|
||||||
|
cur_pid = current["peer_id"]
|
||||||
|
remaining = [c for c in remaining if c["peer_id"] != cur_pid]
|
||||||
|
for c in conversations:
|
||||||
|
if c["peer_id"] == cur_pid:
|
||||||
|
remaining.insert(0, c)
|
||||||
|
break
|
||||||
|
|
||||||
|
stats = self.progress.data["stats"]
|
||||||
|
tqdm.write(
|
||||||
|
f"\nПрогресс: {len(completed_ids)}/{len(conversations)} диалогов, "
|
||||||
|
f"{stats['photos_downloaded']} фото уже скачано"
|
||||||
|
)
|
||||||
|
tqdm.write(f"Осталось обработать: {len(remaining)} диалогов")
|
||||||
|
tqdm.write("-" * 60)
|
||||||
|
|
||||||
|
dialogs_bar = tqdm(
|
||||||
|
total=len(conversations),
|
||||||
|
initial=len(completed_ids),
|
||||||
|
desc="Диалоги",
|
||||||
|
unit=" диал",
|
||||||
|
position=0,
|
||||||
|
dynamic_ncols=True,
|
||||||
|
)
|
||||||
|
photos_bar = tqdm(
|
||||||
|
total=0,
|
||||||
|
desc="Фото",
|
||||||
|
unit=" фото",
|
||||||
|
position=1,
|
||||||
|
leave=False,
|
||||||
|
dynamic_ncols=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for conv in remaining:
|
||||||
|
if self._stop_requested:
|
||||||
|
break
|
||||||
|
|
||||||
|
peer_id = conv["peer_id"]
|
||||||
|
dialog_name = self._get_user_name(peer_id)
|
||||||
|
|
||||||
|
self._process_dialog(peer_id, dialog_name, photos_bar)
|
||||||
|
|
||||||
|
if not self._stop_requested:
|
||||||
|
dialogs_bar.update(1)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
photos_bar.close()
|
||||||
|
dialogs_bar.close()
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
stats = self.progress.data["stats"]
|
||||||
|
completed_count = len(self.progress.data["dialogs_completed"])
|
||||||
|
total_count = self.progress.data["dialogs_total"]
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" Итого:")
|
||||||
|
print(f" Фото скачано: {stats['photos_downloaded']}")
|
||||||
|
print(f" Фото пропущено: {stats['photos_skipped']}")
|
||||||
|
print(f" Ошибок: {stats['errors']}")
|
||||||
|
print(f" Диалогов обработано: {completed_count}/{total_count}")
|
||||||
|
if self._stop_requested:
|
||||||
|
print("\n Работа остановлена. Запусти скрипт снова для продолжения.")
|
||||||
|
else:
|
||||||
|
print("\n Все диалоги обработаны!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Точка входа скрипта."""
|
||||||
|
downloader = VKPhotoDownloader()
|
||||||
|
downloader.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
823
main_video.py
Normal file
823
main_video.py
Normal file
@@ -0,0 +1,823 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Скрипт для выгрузки СВОИХ видео из личных диалогов ВКонтакте.
|
||||||
|
|
||||||
|
Скачивает только видео, где owner_id == ваш ID (загруженные вами).
|
||||||
|
Чужие видео, видео сообществ — пропускаются.
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
1. Заполни VK_TOKEN в config.py
|
||||||
|
2. pip install -r requirements.txt
|
||||||
|
3. python main_video.py
|
||||||
|
4. Ctrl+C для остановки (прогресс сохраняется)
|
||||||
|
5. Повторный запуск продолжит с места остановки
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import vk_api
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
# Приоритет качества видео (от лучшего к худшему)
|
||||||
|
VIDEO_QUALITY_PRIORITY: list[str] = [
|
||||||
|
"mp4_2160", "mp4_1440", "mp4_1080", "mp4_720", "mp4_480", "mp4_360", "mp4_240", "mp4_144",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Модели данных
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VideoMeta:
|
||||||
|
"""Метаданные видео для JSON-сайдкара."""
|
||||||
|
|
||||||
|
video_id: int
|
||||||
|
owner_id: int
|
||||||
|
title: str
|
||||||
|
duration: int
|
||||||
|
date: int
|
||||||
|
sender_id: int
|
||||||
|
sender_name: str
|
||||||
|
message_text: str
|
||||||
|
quality: str
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Менеджер прогресса
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ProgressManager:
|
||||||
|
"""Прогресс с resume-механизмом (потокобезопасный)."""
|
||||||
|
|
||||||
|
def __init__(self, progress_file: str) -> None:
|
||||||
|
self.progress_file: Path = Path(progress_file)
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self.data: dict = self._load()
|
||||||
|
self._downloaded_ids: set[int] = set(
|
||||||
|
self.data.get("downloaded_video_ids", [])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
if self.progress_file.exists():
|
||||||
|
try:
|
||||||
|
with open(self.progress_file, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return self._default_state()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _default_state() -> dict:
|
||||||
|
return {
|
||||||
|
"version": 3,
|
||||||
|
"last_updated": "",
|
||||||
|
"dialogs_total": 0,
|
||||||
|
"dialogs_completed": [],
|
||||||
|
"current_dialog": None,
|
||||||
|
"downloaded_video_ids": [],
|
||||||
|
"stats": {
|
||||||
|
"videos_downloaded": 0,
|
||||||
|
"foreign_skipped": 0,
|
||||||
|
"external_saved": 0,
|
||||||
|
"no_files": 0,
|
||||||
|
"errors": 0,
|
||||||
|
"bytes_downloaded": 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.data["last_updated"] = datetime.now().isoformat()
|
||||||
|
self.data["downloaded_video_ids"] = list(self._downloaded_ids)
|
||||||
|
tmp = self.progress_file.with_suffix(".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.data, f, ensure_ascii=False, indent=2)
|
||||||
|
tmp.replace(self.progress_file)
|
||||||
|
|
||||||
|
def is_dialog_completed(self, peer_id: int) -> bool:
|
||||||
|
return peer_id in self.data["dialogs_completed"]
|
||||||
|
|
||||||
|
def mark_dialog_completed(self, peer_id: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
if peer_id not in self.data["dialogs_completed"]:
|
||||||
|
self.data["dialogs_completed"].append(peer_id)
|
||||||
|
self.data["current_dialog"] = None
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def set_current_dialog(self, peer_id: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.data["current_dialog"] = {"peer_id": peer_id}
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
def get_current_dialog(self) -> Optional[dict]:
|
||||||
|
return self.data.get("current_dialog")
|
||||||
|
|
||||||
|
def is_video_downloaded(self, video_id: int) -> bool:
|
||||||
|
return video_id in self._downloaded_ids
|
||||||
|
|
||||||
|
def mark_video_downloaded(self, video_id: int, size: int) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self._downloaded_ids.add(video_id)
|
||||||
|
self.data["stats"]["videos_downloaded"] += 1
|
||||||
|
self.data["stats"]["bytes_downloaded"] += size
|
||||||
|
|
||||||
|
def increment_foreign(self, count: int = 1) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.data["stats"]["foreign_skipped"] += count
|
||||||
|
|
||||||
|
def increment_external(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.data["stats"]["external_saved"] += 1
|
||||||
|
|
||||||
|
def increment_no_files(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.data["stats"]["no_files"] += 1
|
||||||
|
|
||||||
|
def increment_errors(self) -> None:
|
||||||
|
with self._lock:
|
||||||
|
self.data["stats"]["errors"] += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Основной загрузчик
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class VKVideoDownloader:
|
||||||
|
"""Скачивание своих видео из диалогов ВК."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._stop_requested: bool = False
|
||||||
|
self.progress = ProgressManager(config.VIDEO_PROGRESS_FILE)
|
||||||
|
self.download_dir = Path(config.VIDEO_DOWNLOAD_DIR)
|
||||||
|
self.download_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._user_cache: dict[int, str] = {}
|
||||||
|
self._my_id: int = 0
|
||||||
|
|
||||||
|
# HTTP-сессия для скачивания файлов
|
||||||
|
self._http = requests.Session()
|
||||||
|
|
||||||
|
# Отдельная сессия для video.get БЕЗ User-Agent браузера
|
||||||
|
# (VK не отдаёт поле files если видит браузерный UA)
|
||||||
|
self._video_api_session = requests.Session()
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
|
# vk_api для всего кроме video.get
|
||||||
|
self._vk_session = vk_api.VkApi(
|
||||||
|
token=config.VK_TOKEN, api_version=config.API_VERSION,
|
||||||
|
)
|
||||||
|
self.api = self._vk_session.get_api()
|
||||||
|
|
||||||
|
def _signal_handler(self, _signum: int, _frame: object) -> None:
|
||||||
|
if self._stop_requested:
|
||||||
|
tqdm.write("\nПринудительная остановка!")
|
||||||
|
sys.exit(1)
|
||||||
|
self._stop_requested = True
|
||||||
|
tqdm.write("\nОстановка... сохраняю прогресс.")
|
||||||
|
|
||||||
|
# -- утилиты --
|
||||||
|
|
||||||
|
def _check_free_space(self) -> bool:
|
||||||
|
usage = shutil.disk_usage(str(self.download_dir))
|
||||||
|
free_mb = usage.free / (1024 * 1024)
|
||||||
|
if free_mb < config.VIDEO_MIN_FREE_SPACE_MB:
|
||||||
|
tqdm.write(
|
||||||
|
f"Мало места! Свободно: {free_mb:.0f} МБ, "
|
||||||
|
f"минимум: {config.VIDEO_MIN_FREE_SPACE_MB} МБ"
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _safe_name(name: str) -> str:
|
||||||
|
return "".join(c if c.isalnum() or c in ("_", "-") else "_" for c in name)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_size(size_bytes: int) -> str:
|
||||||
|
sz = float(size_bytes)
|
||||||
|
for unit in ("Б", "КБ", "МБ", "ГБ"):
|
||||||
|
if sz < 1024:
|
||||||
|
return f"{sz:.1f} {unit}"
|
||||||
|
sz /= 1024
|
||||||
|
return f"{sz:.1f} ТБ"
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _format_duration(seconds: int) -> str:
|
||||||
|
m, s = divmod(seconds, 60)
|
||||||
|
h, m = divmod(m, 60)
|
||||||
|
if h:
|
||||||
|
return f"{h}:{m:02d}:{s:02d}"
|
||||||
|
return f"{m}:{s:02d}"
|
||||||
|
|
||||||
|
# -- VK API --
|
||||||
|
|
||||||
|
def _execute(self, code: str) -> dict:
|
||||||
|
return self._vk_session.method("execute", {"code": code})
|
||||||
|
|
||||||
|
def _video_get_raw(self, video_keys: list[str]) -> list[dict]:
|
||||||
|
"""Вызов video.get через requests БЕЗ браузерного User-Agent.
|
||||||
|
|
||||||
|
VK не отдаёт поле files если видит User-Agent браузера.
|
||||||
|
"""
|
||||||
|
result: list[dict] = []
|
||||||
|
for i in range(0, len(video_keys), 200):
|
||||||
|
if self._stop_requested:
|
||||||
|
break
|
||||||
|
batch = video_keys[i:i + 200]
|
||||||
|
try:
|
||||||
|
resp = self._video_api_session.post(
|
||||||
|
"https://api.vk.com/method/video.get",
|
||||||
|
data={
|
||||||
|
"videos": ",".join(batch),
|
||||||
|
"access_token": config.VK_TOKEN,
|
||||||
|
"v": config.API_VERSION,
|
||||||
|
},
|
||||||
|
timeout=30,
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
items = data.get("response", {}).get("items", [])
|
||||||
|
result.extend(items)
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" video.get ошибка: {exc}")
|
||||||
|
time.sleep(0.34)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _prefetch_user_names(self, user_ids: list[int]) -> None:
|
||||||
|
need_users: list[int] = []
|
||||||
|
need_groups: list[int] = []
|
||||||
|
|
||||||
|
for uid in user_ids:
|
||||||
|
if uid in self._user_cache or uid == 0:
|
||||||
|
continue
|
||||||
|
if uid > 2_000_000_000:
|
||||||
|
self._user_cache[uid] = f"Беседа_{uid - 2_000_000_000}"
|
||||||
|
elif uid > 0:
|
||||||
|
need_users.append(uid)
|
||||||
|
else:
|
||||||
|
need_groups.append(abs(uid))
|
||||||
|
|
||||||
|
for i in range(0, len(need_users), 1000):
|
||||||
|
batch = need_users[i:i + 1000]
|
||||||
|
try:
|
||||||
|
ids_str = ",".join(str(x) for x in batch)
|
||||||
|
users = self.api.users.get(user_ids=ids_str)
|
||||||
|
for u in users:
|
||||||
|
self._user_cache[u["id"]] = f"{u['first_name']} {u['last_name']}"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
for i in range(0, len(need_groups), 500):
|
||||||
|
batch = need_groups[i:i + 500]
|
||||||
|
try:
|
||||||
|
ids_str = ",".join(str(x) for x in batch)
|
||||||
|
resp = self.api.groups.getById(group_ids=ids_str)
|
||||||
|
groups = resp if isinstance(resp, list) else resp.get("groups", [])
|
||||||
|
for g in groups:
|
||||||
|
self._user_cache[-g["id"]] = g.get("name", f"group_{g['id']}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
def _get_user_name(self, user_id: int) -> str:
|
||||||
|
if user_id in self._user_cache:
|
||||||
|
return self._user_cache[user_id]
|
||||||
|
self._prefetch_user_names([user_id])
|
||||||
|
return self._user_cache.get(user_id, f"id{user_id}")
|
||||||
|
|
||||||
|
# -- получение диалогов --
|
||||||
|
|
||||||
|
def _get_all_conversations(self) -> list[dict]:
|
||||||
|
conversations: list[dict] = []
|
||||||
|
offset = 0
|
||||||
|
|
||||||
|
tqdm.write("Получаю список диалогов...")
|
||||||
|
while True:
|
||||||
|
code = f"""
|
||||||
|
var results = [];
|
||||||
|
var offset = {offset};
|
||||||
|
var i = 0;
|
||||||
|
while (i < 25) {{
|
||||||
|
var resp = API.messages.getConversations({{
|
||||||
|
"offset": offset, "count": 200, "extended": 0
|
||||||
|
}});
|
||||||
|
results.push(resp);
|
||||||
|
offset = offset + 200;
|
||||||
|
if (offset >= resp.count || resp.items.length == 0) {{
|
||||||
|
return {{"results": results, "done": true}};
|
||||||
|
}}
|
||||||
|
i = i + 1;
|
||||||
|
}}
|
||||||
|
return {{"results": results, "done": false, "next_offset": offset}};
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._execute(code)
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" execute ошибка: {exc}, фоллбэк")
|
||||||
|
return self._get_conversations_fallback()
|
||||||
|
|
||||||
|
for page in data.get("results", []):
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
for item in page.get("items", []):
|
||||||
|
peer = item["conversation"]["peer"]
|
||||||
|
if peer["type"] == "chat":
|
||||||
|
continue
|
||||||
|
conversations.append(
|
||||||
|
{"peer_id": peer["id"], "type": peer["type"]}
|
||||||
|
)
|
||||||
|
|
||||||
|
if data.get("done", True):
|
||||||
|
break
|
||||||
|
offset = data.get("next_offset", offset + 5000)
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
tqdm.write(f"Найдено личных диалогов: {len(conversations)}")
|
||||||
|
return conversations
|
||||||
|
|
||||||
|
def _get_conversations_fallback(self) -> list[dict]:
|
||||||
|
conversations: list[dict] = []
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
resp = self.api.messages.getConversations(
|
||||||
|
offset=offset, count=200, extended=0,
|
||||||
|
)
|
||||||
|
items = resp.get("items", [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for item in items:
|
||||||
|
peer = item["conversation"]["peer"]
|
||||||
|
if peer["type"] == "chat":
|
||||||
|
continue
|
||||||
|
conversations.append(
|
||||||
|
{"peer_id": peer["id"], "type": peer["type"]}
|
||||||
|
)
|
||||||
|
offset += 200
|
||||||
|
if offset >= resp.get("count", 0):
|
||||||
|
break
|
||||||
|
time.sleep(0.34)
|
||||||
|
return conversations
|
||||||
|
|
||||||
|
# -- сбор видео-вложений --
|
||||||
|
|
||||||
|
def _collect_my_videos(self, peer_id: int) -> list[dict]:
|
||||||
|
"""Собирает видео-вложения, фильтруя только свои (owner_id == my_id)."""
|
||||||
|
all_videos: list[dict] = []
|
||||||
|
cursor = ""
|
||||||
|
foreign_count = 0
|
||||||
|
|
||||||
|
while not self._stop_requested:
|
||||||
|
code = f"""
|
||||||
|
var results = [];
|
||||||
|
var cursor = "{cursor}";
|
||||||
|
var i = 0;
|
||||||
|
while (i < 25) {{
|
||||||
|
var params = {{
|
||||||
|
"peer_id": {peer_id},
|
||||||
|
"media_type": "video",
|
||||||
|
"count": 200,
|
||||||
|
"preserve_order": 1
|
||||||
|
}};
|
||||||
|
if (cursor != "") {{
|
||||||
|
params.start_from = cursor;
|
||||||
|
}}
|
||||||
|
var resp = API.messages.getHistoryAttachments(params);
|
||||||
|
results.push(resp);
|
||||||
|
if (!resp.next_from || resp.items.length == 0) {{
|
||||||
|
return {{"results": results, "cursor": ""}};
|
||||||
|
}}
|
||||||
|
cursor = resp.next_from;
|
||||||
|
i = i + 1;
|
||||||
|
}}
|
||||||
|
return {{"results": results, "cursor": cursor}};
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
data = self._execute(code)
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f" execute ошибка: {exc}, фоллбэк")
|
||||||
|
fb_result, fb_foreign = self._collect_videos_fallback(
|
||||||
|
peer_id, all_videos, cursor,
|
||||||
|
)
|
||||||
|
foreign_count += fb_foreign
|
||||||
|
break
|
||||||
|
|
||||||
|
for page in data.get("results", []):
|
||||||
|
if not page:
|
||||||
|
continue
|
||||||
|
for item in page.get("items", []):
|
||||||
|
att = item.get("attachment", {})
|
||||||
|
if att.get("type") != "video":
|
||||||
|
continue
|
||||||
|
video = att["video"]
|
||||||
|
if video.get("owner_id") != self._my_id:
|
||||||
|
foreign_count += 1
|
||||||
|
continue
|
||||||
|
all_videos.append({
|
||||||
|
"video": video,
|
||||||
|
"from_id": item.get("from_id", 0),
|
||||||
|
"date": item.get("date", video.get("date", 0)),
|
||||||
|
"message_text": "",
|
||||||
|
})
|
||||||
|
|
||||||
|
cursor = data.get("cursor", "")
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
time.sleep(0.34)
|
||||||
|
|
||||||
|
if foreign_count:
|
||||||
|
self.progress.increment_foreign(foreign_count)
|
||||||
|
|
||||||
|
return all_videos
|
||||||
|
|
||||||
|
def _collect_videos_fallback(
|
||||||
|
self, peer_id: int, existing: list[dict], start_from: str,
|
||||||
|
) -> tuple[list[dict], int]:
|
||||||
|
cursor: Optional[str] = start_from or None
|
||||||
|
foreign_count = 0
|
||||||
|
while not self._stop_requested:
|
||||||
|
params: dict = {
|
||||||
|
"peer_id": peer_id, "media_type": "video",
|
||||||
|
"count": 200, "preserve_order": 1,
|
||||||
|
}
|
||||||
|
if cursor:
|
||||||
|
params["start_from"] = cursor
|
||||||
|
resp = self.api.messages.getHistoryAttachments(**params)
|
||||||
|
items = resp.get("items", [])
|
||||||
|
cursor = resp.get("next_from")
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for item in items:
|
||||||
|
att = item.get("attachment", {})
|
||||||
|
if att.get("type") != "video":
|
||||||
|
continue
|
||||||
|
video = att["video"]
|
||||||
|
if video.get("owner_id") != self._my_id:
|
||||||
|
foreign_count += 1
|
||||||
|
continue
|
||||||
|
existing.append({
|
||||||
|
"video": video,
|
||||||
|
"from_id": item.get("from_id", 0),
|
||||||
|
"date": item.get("date", video.get("date", 0)),
|
||||||
|
"message_text": "",
|
||||||
|
})
|
||||||
|
if not cursor:
|
||||||
|
break
|
||||||
|
time.sleep(0.34)
|
||||||
|
return existing, foreign_count
|
||||||
|
|
||||||
|
# -- выбор лучшего качества --
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _best_video_url(files: dict) -> Optional[tuple[str, str]]:
|
||||||
|
"""Выбирает URL видео максимального качества.
|
||||||
|
|
||||||
|
Возвращает (url, quality) или None.
|
||||||
|
"""
|
||||||
|
for quality in VIDEO_QUALITY_PRIORITY:
|
||||||
|
url = files.get(quality)
|
||||||
|
if url:
|
||||||
|
return (url, quality)
|
||||||
|
return None
|
||||||
|
|
||||||
|
# -- скачивание одного видео --
|
||||||
|
|
||||||
|
def _download_single(
|
||||||
|
self, video_data: dict, files: dict,
|
||||||
|
) -> Optional[VideoMeta]:
|
||||||
|
"""Скачивает одно видео через прямой URL. Возвращает VideoMeta или None."""
|
||||||
|
video = video_data["video"]
|
||||||
|
video_id = video.get("id", 0)
|
||||||
|
date_ts: int = video_data.get("date", video.get("date", 0))
|
||||||
|
title = video.get("title", "")
|
||||||
|
duration = video.get("duration", 0)
|
||||||
|
|
||||||
|
best = self._best_video_url(files)
|
||||||
|
if not best:
|
||||||
|
self.progress.increment_no_files()
|
||||||
|
return None
|
||||||
|
|
||||||
|
url, quality = best
|
||||||
|
|
||||||
|
# Путь: downloads_video/video_{id}_{date}_{title}.mp4
|
||||||
|
dt = datetime.fromtimestamp(date_ts) if date_ts else datetime.now()
|
||||||
|
safe_title = self._safe_name(title)[:50] if title else ""
|
||||||
|
suffix = f"_{safe_title}" if safe_title else ""
|
||||||
|
filename = f"video_{video_id}_{dt.strftime('%Y%m%d_%H%M%S')}{suffix}.mp4"
|
||||||
|
filepath = self.download_dir / filename
|
||||||
|
|
||||||
|
if filepath.exists():
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Скачиваем с retry и стримингом (128 КБ чанки)
|
||||||
|
file_size = 0
|
||||||
|
for attempt in range(config.MAX_RETRIES):
|
||||||
|
try:
|
||||||
|
resp = self._http.get(
|
||||||
|
url, timeout=config.VIDEO_DOWNLOAD_TIMEOUT, stream=True,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
with open(filepath, "wb") as f:
|
||||||
|
for chunk in resp.iter_content(chunk_size=131072):
|
||||||
|
if self._stop_requested:
|
||||||
|
f.close()
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
file_size = filepath.stat().st_size
|
||||||
|
break
|
||||||
|
except (requests.RequestException, OSError) as exc:
|
||||||
|
filepath.unlink(missing_ok=True)
|
||||||
|
if attempt < config.MAX_RETRIES - 1:
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
else:
|
||||||
|
tqdm.write(f" Ошибка скачивания ({filename}): {exc}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Метаданные
|
||||||
|
sender_id = video_data.get("from_id", 0)
|
||||||
|
sender_name = (
|
||||||
|
self._user_cache.get(sender_id, f"id{sender_id}")
|
||||||
|
if sender_id else ""
|
||||||
|
)
|
||||||
|
|
||||||
|
info = VideoMeta(
|
||||||
|
video_id=video_id,
|
||||||
|
owner_id=video.get("owner_id", 0),
|
||||||
|
title=title,
|
||||||
|
duration=duration,
|
||||||
|
date=date_ts,
|
||||||
|
sender_id=sender_id,
|
||||||
|
sender_name=sender_name,
|
||||||
|
message_text=video_data.get("message_text", ""),
|
||||||
|
quality=quality,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Дата файла = дата сообщения
|
||||||
|
if date_ts:
|
||||||
|
os.utime(filepath, (date_ts, date_ts))
|
||||||
|
|
||||||
|
return info
|
||||||
|
|
||||||
|
# -- обработка одного диалога --
|
||||||
|
|
||||||
|
def _process_dialog(
|
||||||
|
self, peer_id: int, dialog_name: str, bar: tqdm,
|
||||||
|
) -> None:
|
||||||
|
saved = self.progress.get_current_dialog()
|
||||||
|
resuming = saved is not None and saved.get("peer_id") == peer_id
|
||||||
|
|
||||||
|
if not resuming:
|
||||||
|
self.progress.set_current_dialog(peer_id)
|
||||||
|
|
||||||
|
# Сбор только моих видео
|
||||||
|
raw_videos = self._collect_my_videos(peer_id)
|
||||||
|
if not raw_videos:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.progress.mark_dialog_completed(peer_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Дедупликация по video_id
|
||||||
|
seen: set[int] = set()
|
||||||
|
unique: list[dict] = []
|
||||||
|
for v in raw_videos:
|
||||||
|
vid = v["video"].get("id", 0)
|
||||||
|
if vid and vid not in seen:
|
||||||
|
seen.add(vid)
|
||||||
|
unique.append(v)
|
||||||
|
|
||||||
|
# Фильтруем уже скачанные
|
||||||
|
tasks = [
|
||||||
|
v for v in unique
|
||||||
|
if not self.progress.is_video_downloaded(v["video"].get("id", 0))
|
||||||
|
]
|
||||||
|
skipped = len(unique) - len(tasks)
|
||||||
|
|
||||||
|
tqdm.write(
|
||||||
|
f" [{dialog_name}] Моих видео: {len(unique)}, "
|
||||||
|
f"скачать: {len(tasks)}, пропустить: {skipped}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if not tasks:
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.progress.mark_dialog_completed(peer_id)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Получаем прямые URL через video.get (без браузерного UA)
|
||||||
|
tqdm.write(f" [{dialog_name}] Получаю URL видеофайлов...")
|
||||||
|
video_keys: list[str] = []
|
||||||
|
for t in tasks:
|
||||||
|
v = t["video"]
|
||||||
|
key = f"{v.get('owner_id', 0)}_{v.get('id', 0)}"
|
||||||
|
ak = v.get("access_key", "")
|
||||||
|
if ak:
|
||||||
|
key += f"_{ak}"
|
||||||
|
video_keys.append(key)
|
||||||
|
|
||||||
|
details = self._video_get_raw(video_keys)
|
||||||
|
# Индекс: video_id → files
|
||||||
|
files_map: dict[int, dict] = {}
|
||||||
|
for d in details:
|
||||||
|
files_map[d["id"]] = d.get("files", {})
|
||||||
|
|
||||||
|
# Предзагрузка имён
|
||||||
|
sender_ids = list({t.get("from_id", 0) for t in tasks if t.get("from_id", 0)})
|
||||||
|
if sender_ids:
|
||||||
|
self._prefetch_user_names(sender_ids)
|
||||||
|
|
||||||
|
# Прогресс-бар
|
||||||
|
bar.reset(total=len(unique))
|
||||||
|
bar.n = skipped
|
||||||
|
bar.refresh()
|
||||||
|
bar.set_description(f"Видео ({dialog_name[:25]})")
|
||||||
|
|
||||||
|
# Последовательное скачивание
|
||||||
|
for task in tasks:
|
||||||
|
if self._stop_requested:
|
||||||
|
break
|
||||||
|
if not self._check_free_space():
|
||||||
|
tqdm.write("Остановка: мало места на диске.")
|
||||||
|
self._stop_requested = True
|
||||||
|
break
|
||||||
|
|
||||||
|
video_id = task["video"].get("id", 0)
|
||||||
|
files = files_map.get(video_id, {})
|
||||||
|
|
||||||
|
# Проверяем: внешнее видео (YouTube и т.д.)?
|
||||||
|
if "external" in files and not any(
|
||||||
|
k.startswith("mp4_") for k in files
|
||||||
|
):
|
||||||
|
self.progress.increment_external()
|
||||||
|
bar.update(1)
|
||||||
|
self.progress.save()
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Убираем служебные поля
|
||||||
|
files.pop("failover_host", None)
|
||||||
|
files.pop("hls_ondemand", None)
|
||||||
|
files.pop("dash_ondemand", None)
|
||||||
|
files.pop("external", None)
|
||||||
|
|
||||||
|
result = self._download_single(task, files)
|
||||||
|
|
||||||
|
if result is not None:
|
||||||
|
fsize = 0
|
||||||
|
dt = datetime.fromtimestamp(task.get("date", 0) or time.time())
|
||||||
|
safe_title = self._safe_name(task["video"].get("title", ""))[:50]
|
||||||
|
sfx = f"_{safe_title}" if safe_title else ""
|
||||||
|
fname = f"video_{video_id}_{dt.strftime('%Y%m%d_%H%M%S')}{sfx}.mp4"
|
||||||
|
fpath = self.download_dir / fname
|
||||||
|
if fpath.exists():
|
||||||
|
fsize = fpath.stat().st_size
|
||||||
|
|
||||||
|
self.progress.mark_video_downloaded(video_id, fsize)
|
||||||
|
dur_str = self._format_duration(task["video"].get("duration", 0))
|
||||||
|
tqdm.write(
|
||||||
|
f" ✓ {task['video'].get('title', '')[:40]} "
|
||||||
|
f"({dur_str}, {result.quality}, {self._format_size(fsize)})"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
if not files or not self._best_video_url(files):
|
||||||
|
pass # increment_no_files уже вызван в _download_single
|
||||||
|
else:
|
||||||
|
self.progress.increment_errors()
|
||||||
|
|
||||||
|
bar.update(1)
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
if not self._stop_requested:
|
||||||
|
self.progress.mark_dialog_completed(peer_id)
|
||||||
|
|
||||||
|
# -- главный цикл --
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(" Выгрузка СВОИХ видео из диалогов ВКонтакте")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
|
||||||
|
if not config.VK_TOKEN:
|
||||||
|
tqdm.write("ОШИБКА: Заполни VK_TOKEN в config.py!")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
me = self.api.users.get()[0]
|
||||||
|
self._my_id = me["id"]
|
||||||
|
my_name = f"{me['first_name']} {me['last_name']}"
|
||||||
|
tqdm.write(f"Авторизован как: {my_name} (id{self._my_id})")
|
||||||
|
tqdm.write(f"Скачиваю только видео с owner_id={self._my_id}")
|
||||||
|
self._user_cache[me["id"]] = my_name
|
||||||
|
except Exception as exc:
|
||||||
|
tqdm.write(f"ОШИБКА авторизации: {exc}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
conversations = self._get_all_conversations()
|
||||||
|
self.progress.data["dialogs_total"] = len(conversations)
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
peer_ids = [c["peer_id"] for c in conversations]
|
||||||
|
tqdm.write("Загружаю имена собеседников...")
|
||||||
|
self._prefetch_user_names(peer_ids)
|
||||||
|
|
||||||
|
completed_ids = set(self.progress.data["dialogs_completed"])
|
||||||
|
remaining = [c for c in conversations if c["peer_id"] not in completed_ids]
|
||||||
|
|
||||||
|
current = self.progress.get_current_dialog()
|
||||||
|
if current:
|
||||||
|
cur_pid = current["peer_id"]
|
||||||
|
remaining = [c for c in remaining if c["peer_id"] != cur_pid]
|
||||||
|
for c in conversations:
|
||||||
|
if c["peer_id"] == cur_pid:
|
||||||
|
remaining.insert(0, c)
|
||||||
|
break
|
||||||
|
|
||||||
|
stats = self.progress.data["stats"]
|
||||||
|
tqdm.write(
|
||||||
|
f"\nПрогресс: {len(completed_ids)}/{len(conversations)} диалогов, "
|
||||||
|
f"{stats['videos_downloaded']} видео скачано, "
|
||||||
|
f"{stats['foreign_skipped']} чужих пропущено"
|
||||||
|
)
|
||||||
|
tqdm.write(f"Осталось: {len(remaining)} диалогов")
|
||||||
|
tqdm.write("-" * 60)
|
||||||
|
|
||||||
|
dialogs_bar = tqdm(
|
||||||
|
total=len(conversations),
|
||||||
|
initial=len(completed_ids),
|
||||||
|
desc="Диалоги",
|
||||||
|
unit=" диал",
|
||||||
|
position=0,
|
||||||
|
dynamic_ncols=True,
|
||||||
|
)
|
||||||
|
videos_bar = tqdm(
|
||||||
|
total=0,
|
||||||
|
desc="Видео",
|
||||||
|
unit=" видео",
|
||||||
|
position=1,
|
||||||
|
leave=False,
|
||||||
|
dynamic_ncols=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
for conv in remaining:
|
||||||
|
if self._stop_requested:
|
||||||
|
break
|
||||||
|
peer_id = conv["peer_id"]
|
||||||
|
dialog_name = self._get_user_name(peer_id)
|
||||||
|
self._process_dialog(peer_id, dialog_name, videos_bar)
|
||||||
|
if not self._stop_requested:
|
||||||
|
dialogs_bar.update(1)
|
||||||
|
finally:
|
||||||
|
videos_bar.close()
|
||||||
|
dialogs_bar.close()
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
stats = self.progress.data["stats"]
|
||||||
|
completed_count = len(self.progress.data["dialogs_completed"])
|
||||||
|
total_count = self.progress.data["dialogs_total"]
|
||||||
|
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print(" Итого:")
|
||||||
|
print(f" Моих видео скачано: {stats['videos_downloaded']}")
|
||||||
|
print(f" Чужих видео пропущено: {stats['foreign_skipped']}")
|
||||||
|
ext = stats.get("external_saved", 0)
|
||||||
|
if ext:
|
||||||
|
print(f" Внешних (YouTube и т.п.):{ext}")
|
||||||
|
nf = stats.get("no_files", 0)
|
||||||
|
if nf:
|
||||||
|
print(f" Без файлов (удалены?): {nf}")
|
||||||
|
print(f" Ошибок: {stats['errors']}")
|
||||||
|
print(f" Скачано: {self._format_size(stats['bytes_downloaded'])}")
|
||||||
|
print(f" Диалогов обработано: {completed_count}/{total_count}")
|
||||||
|
if self._stop_requested:
|
||||||
|
print("\n Остановлено. Запусти снова для продолжения.")
|
||||||
|
else:
|
||||||
|
print("\n Все диалоги обработаны!")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
downloader = VKVideoDownloader()
|
||||||
|
downloader.run()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
797
process_photos.py
Normal file
797
process_photos.py
Normal file
@@ -0,0 +1,797 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Обработка скачанных фото: сквозная дедупликация и классификация.
|
||||||
|
|
||||||
|
Все фото собираются из downloads/ (из всех диалогов), сортируются
|
||||||
|
хронологически (самые старые первыми) и раскладываются в output/:
|
||||||
|
output/personal/ — личные фото людей
|
||||||
|
output/travel/ — путешествия, места
|
||||||
|
output/food/ — еда
|
||||||
|
output/screenshots/ — скриншоты переписок
|
||||||
|
output/_duplicates/ — дубликаты (оригинал = самое раннее фото)
|
||||||
|
output/_junk/ — мемы, стикеры
|
||||||
|
output/_review/ — неуверенная классификация, art, document
|
||||||
|
|
||||||
|
Использование:
|
||||||
|
python process_photos.py run # Полная обработка
|
||||||
|
python process_photos.py run --limit 200 # Тест на 200 фото
|
||||||
|
python process_photos.py run --dry-run # Без перемещения
|
||||||
|
python process_photos.py rollback # Откатить всё назад
|
||||||
|
python process_photos.py stats # Статистика
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
from collections import defaultdict
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import imagehash
|
||||||
|
import numpy as np
|
||||||
|
from PIL import Image
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
import config
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Константы
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# Папки-категории в output/
|
||||||
|
DEDUP_DIR: str = "_duplicates"
|
||||||
|
JUNK_DIR: str = "_junk"
|
||||||
|
REVIEW_DIR: str = "_review"
|
||||||
|
|
||||||
|
# Описания категорий для CLIP
|
||||||
|
CATEGORIES: dict[str, list[str]] = {
|
||||||
|
"personal": [
|
||||||
|
"a personal photograph of real people",
|
||||||
|
"a selfie photo of a person",
|
||||||
|
"a group photo of friends or family",
|
||||||
|
"a portrait photograph of a person",
|
||||||
|
"a candid photo of people at an event or party",
|
||||||
|
],
|
||||||
|
"travel": [
|
||||||
|
"a landscape photograph of nature or scenery",
|
||||||
|
"a travel photograph of a famous place or landmark",
|
||||||
|
"a photograph of architecture or buildings",
|
||||||
|
"a cityscape or street photograph",
|
||||||
|
"a photograph from a vacation or trip",
|
||||||
|
],
|
||||||
|
"food": [
|
||||||
|
"a photograph of food or a dish on a plate",
|
||||||
|
"a restaurant or cafe photograph",
|
||||||
|
"a cooking or baking photograph",
|
||||||
|
],
|
||||||
|
"screenshots": [
|
||||||
|
"a screenshot of a mobile phone chat or text messages",
|
||||||
|
"a screenshot of a computer screen with interface",
|
||||||
|
"a screenshot of a social media post or webpage",
|
||||||
|
],
|
||||||
|
"meme": [
|
||||||
|
"a meme image with text overlay and funny picture",
|
||||||
|
"a demotivational poster image with black border",
|
||||||
|
"a comic strip or cartoon panel with speech bubbles",
|
||||||
|
"an internet joke image with caption text",
|
||||||
|
],
|
||||||
|
"sticker": [
|
||||||
|
"a small cartoon sticker on plain background",
|
||||||
|
"a simple emoji or emoticon image",
|
||||||
|
"a cartoon character sticker with transparent background",
|
||||||
|
],
|
||||||
|
"art": [
|
||||||
|
"a digital art or illustration drawing",
|
||||||
|
"a hand-drawn painting or artwork",
|
||||||
|
"abstract colorful art image",
|
||||||
|
],
|
||||||
|
"document": [
|
||||||
|
"a scanned printed document or text page",
|
||||||
|
"a photograph of a paper document",
|
||||||
|
"a handwritten note or letter photograph",
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Куда перемещать каждую категорию
|
||||||
|
KEEP_CATEGORIES: set[str] = {"personal", "travel", "food", "screenshots"}
|
||||||
|
JUNK_CATEGORIES: set[str] = {"meme", "sticker"}
|
||||||
|
REVIEW_CATEGORIES: set[str] = {"art", "document"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Утилиты
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def popcount64(arr: np.ndarray) -> np.ndarray:
|
||||||
|
"""Vectorized подсчёт единичных бит в массиве uint64."""
|
||||||
|
x = arr.astype(np.uint64)
|
||||||
|
x = x - ((x >> np.uint64(1)) & np.uint64(0x5555555555555555))
|
||||||
|
x = (x & np.uint64(0x3333333333333333)) + (
|
||||||
|
(x >> np.uint64(2)) & np.uint64(0x3333333333333333)
|
||||||
|
)
|
||||||
|
x = (x + (x >> np.uint64(4))) & np.uint64(0x0F0F0F0F0F0F0F0F)
|
||||||
|
return ((x * np.uint64(0x0101010101010101)) >> np.uint64(56)).astype(
|
||||||
|
np.int32
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def unique_dest(dest: Path) -> Path:
|
||||||
|
"""Если файл с таким именем уже существует, добавляет суффикс _2, _3 и т.д."""
|
||||||
|
if not dest.exists():
|
||||||
|
return dest
|
||||||
|
stem = dest.stem
|
||||||
|
suffix = dest.suffix
|
||||||
|
parent = dest.parent
|
||||||
|
counter = 2
|
||||||
|
while True:
|
||||||
|
candidate = parent / f"{stem}_{counter}{suffix}"
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
counter += 1
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Трекер прогресса
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ProgressTracker:
|
||||||
|
"""Прогресс обработки с поддержкой возобновления."""
|
||||||
|
|
||||||
|
def __init__(self, filepath: str = config.PROCESS_PROGRESS_FILE) -> None:
|
||||||
|
self.filepath: Path = Path(filepath)
|
||||||
|
self.data: dict = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> dict:
|
||||||
|
"""Загружает прогресс из файла."""
|
||||||
|
if self.filepath.exists():
|
||||||
|
try:
|
||||||
|
with open(self.filepath, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return {
|
||||||
|
"version": 2,
|
||||||
|
"hashed_files": {},
|
||||||
|
"classified_files": {},
|
||||||
|
"processed_files": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
def save(self) -> None:
|
||||||
|
"""Сохраняет прогресс (атомарная запись)."""
|
||||||
|
self.data["last_updated"] = datetime.now().isoformat()
|
||||||
|
tmp = self.filepath.with_suffix(".tmp")
|
||||||
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.data, f, ensure_ascii=False, indent=2)
|
||||||
|
tmp.replace(self.filepath)
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Полный сброс прогресса."""
|
||||||
|
self.data = {
|
||||||
|
"version": 2,
|
||||||
|
"hashed_files": {},
|
||||||
|
"classified_files": {},
|
||||||
|
"processed_files": [],
|
||||||
|
}
|
||||||
|
self.save()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Лог отката
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class RollbackLog:
|
||||||
|
"""Журнал перемещений для отката."""
|
||||||
|
|
||||||
|
def __init__(self, filepath: str = config.ROLLBACK_LOG_FILE) -> None:
|
||||||
|
self.filepath: Path = Path(filepath)
|
||||||
|
self.moves: list[dict] = self._load()
|
||||||
|
|
||||||
|
def _load(self) -> list[dict]:
|
||||||
|
if self.filepath.exists():
|
||||||
|
try:
|
||||||
|
with open(self.filepath, "r", encoding="utf-8") as f:
|
||||||
|
return json.load(f)
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
pass
|
||||||
|
return []
|
||||||
|
|
||||||
|
def log_move(self, src: str, dst: str, reason: str) -> None:
|
||||||
|
"""Записывает перемещение."""
|
||||||
|
self.moves.append({"src": src, "dst": dst, "reason": reason})
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
with open(self.filepath, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(self.moves, f, ensure_ascii=False, indent=2)
|
||||||
|
|
||||||
|
def rollback(self) -> int:
|
||||||
|
"""Откатить все перемещения (LIFO). Возвращает кол-во восстановленных."""
|
||||||
|
restored = 0
|
||||||
|
for move in tqdm(
|
||||||
|
reversed(self.moves), total=len(self.moves),
|
||||||
|
desc="Откат", unit=" файлов",
|
||||||
|
):
|
||||||
|
current = Path(move["dst"])
|
||||||
|
original = Path(move["src"])
|
||||||
|
if current.exists():
|
||||||
|
try:
|
||||||
|
original.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(current), str(original))
|
||||||
|
restored += 1
|
||||||
|
except OSError as exc:
|
||||||
|
tqdm.write(f" Ошибка: {current.name}: {exc}")
|
||||||
|
self.moves.clear()
|
||||||
|
self._save()
|
||||||
|
return restored
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count(self) -> int:
|
||||||
|
return len(self.moves)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Основной обработчик
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class PhotoProcessor:
|
||||||
|
"""Сквозная обработка: сбор → сортировка → хеширование → деdup → CLIP → организация."""
|
||||||
|
|
||||||
|
def __init__(self, args: argparse.Namespace) -> None:
|
||||||
|
self.source_dir: Path = Path(args.source)
|
||||||
|
self.output_dir: Path = Path(args.output)
|
||||||
|
self.limit: Optional[int] = args.limit
|
||||||
|
self.threshold: int = args.threshold
|
||||||
|
self.dry_run: bool = args.dry_run
|
||||||
|
self._stop: bool = False
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, self._signal_handler)
|
||||||
|
signal.signal(signal.SIGTERM, self._signal_handler)
|
||||||
|
|
||||||
|
self.progress = ProgressTracker()
|
||||||
|
self.rollback = RollbackLog()
|
||||||
|
|
||||||
|
def _signal_handler(self, _sig: int, _frame: object) -> None:
|
||||||
|
if self._stop:
|
||||||
|
tqdm.write("\nПринудительная остановка!")
|
||||||
|
sys.exit(1)
|
||||||
|
self._stop = True
|
||||||
|
tqdm.write("\nОстановка... сохраняю прогресс.")
|
||||||
|
|
||||||
|
# -- 1. Сбор и сортировка --
|
||||||
|
|
||||||
|
def _scan_photos(self) -> list[Path]:
|
||||||
|
"""Собирает все фото из downloads/, сортирует по дате файла (oldest first)."""
|
||||||
|
photos: list[Path] = []
|
||||||
|
for root, _dirs, files in os.walk(self.source_dir):
|
||||||
|
for f in files:
|
||||||
|
if f.lower().endswith((".jpg", ".jpeg", ".png")):
|
||||||
|
photos.append(Path(root) / f)
|
||||||
|
|
||||||
|
# Сортировка по mtime (= дата сообщения ВК, установлена через os.utime)
|
||||||
|
photos.sort(key=lambda p: p.stat().st_mtime)
|
||||||
|
|
||||||
|
if self.limit:
|
||||||
|
photos = photos[: self.limit]
|
||||||
|
|
||||||
|
return photos
|
||||||
|
|
||||||
|
# -- 2. Хеширование --
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _compute_one_hash(photo_path: Path) -> Optional[tuple[str, str]]:
|
||||||
|
"""Вычисляет pHash одного фото."""
|
||||||
|
try:
|
||||||
|
with Image.open(photo_path) as img:
|
||||||
|
h = imagehash.phash(img, hash_size=config.HASH_SIZE)
|
||||||
|
return (str(photo_path), str(h))
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _compute_hashes(self, photos: list[Path]) -> dict[str, str]:
|
||||||
|
"""Вычисляет хеши для всех фото (с кешем и многопоточностью)."""
|
||||||
|
cached: dict[str, str] = self.progress.data.get("hashed_files", {})
|
||||||
|
result: dict[str, str] = {}
|
||||||
|
to_hash: list[Path] = []
|
||||||
|
|
||||||
|
for p in photos:
|
||||||
|
key = str(p)
|
||||||
|
if key in cached:
|
||||||
|
result[key] = cached[key]
|
||||||
|
else:
|
||||||
|
to_hash.append(p)
|
||||||
|
|
||||||
|
if not to_hash:
|
||||||
|
tqdm.write(f" Все {len(result)} хешей из кеша.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
tqdm.write(f" Хеширование: {len(to_hash)} новых + {len(result)} из кеша")
|
||||||
|
|
||||||
|
bar = tqdm(total=len(to_hash), desc="Хеширование", unit=" фото")
|
||||||
|
with ThreadPoolExecutor(max_workers=config.HASH_WORKERS) as executor:
|
||||||
|
futures = {
|
||||||
|
executor.submit(self._compute_one_hash, p): p
|
||||||
|
for p in to_hash
|
||||||
|
}
|
||||||
|
done_count = 0
|
||||||
|
for future in as_completed(futures):
|
||||||
|
if self._stop:
|
||||||
|
for f in futures:
|
||||||
|
f.cancel()
|
||||||
|
break
|
||||||
|
res = future.result()
|
||||||
|
if res:
|
||||||
|
path_str, hash_hex = res
|
||||||
|
result[path_str] = hash_hex
|
||||||
|
cached[path_str] = hash_hex
|
||||||
|
bar.update(1)
|
||||||
|
done_count += 1
|
||||||
|
if done_count % 500 == 0:
|
||||||
|
self.progress.data["hashed_files"] = cached
|
||||||
|
self.progress.save()
|
||||||
|
bar.close()
|
||||||
|
|
||||||
|
self.progress.data["hashed_files"] = cached
|
||||||
|
self.progress.save()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- 3. Хронологическая дедупликация --
|
||||||
|
|
||||||
|
def _dedup_chronological(
|
||||||
|
self,
|
||||||
|
sorted_photos: list[Path],
|
||||||
|
hashes: dict[str, str],
|
||||||
|
) -> tuple[list[Path], list[Path]]:
|
||||||
|
"""Проходит фото от старых к новым. Первое вхождение — оригинал, остальные — дубликаты.
|
||||||
|
|
||||||
|
Возвращает (originals, duplicates).
|
||||||
|
"""
|
||||||
|
# Массив уникальных хешей (int) для vectorized сравнения
|
||||||
|
unique_hash_ints: list[int] = []
|
||||||
|
unique_hash_np: Optional[np.ndarray] = None
|
||||||
|
|
||||||
|
originals: list[Path] = []
|
||||||
|
duplicates: list[Path] = []
|
||||||
|
|
||||||
|
tqdm.write(f" Сквозная дедупликация (порог: {self.threshold})...")
|
||||||
|
|
||||||
|
for photo in tqdm(
|
||||||
|
sorted_photos, desc="Дедупликация", unit=" фото", leave=False,
|
||||||
|
):
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
key = str(photo)
|
||||||
|
hash_hex = hashes.get(key)
|
||||||
|
if hash_hex is None:
|
||||||
|
# Не удалось вычислить хеш — пропускаем
|
||||||
|
continue
|
||||||
|
|
||||||
|
hash_int = int(hash_hex, 16)
|
||||||
|
|
||||||
|
is_duplicate = False
|
||||||
|
|
||||||
|
if unique_hash_ints:
|
||||||
|
# Vectorized сравнение с уже встреченными уникальными хешами
|
||||||
|
if unique_hash_np is None or len(unique_hash_np) != len(unique_hash_ints):
|
||||||
|
unique_hash_np = np.array(unique_hash_ints, dtype=np.uint64)
|
||||||
|
|
||||||
|
xor = np.bitwise_xor(np.uint64(hash_int), unique_hash_np)
|
||||||
|
distances = popcount64(xor)
|
||||||
|
if np.any(distances <= self.threshold):
|
||||||
|
is_duplicate = True
|
||||||
|
|
||||||
|
if is_duplicate:
|
||||||
|
duplicates.append(photo)
|
||||||
|
else:
|
||||||
|
originals.append(photo)
|
||||||
|
unique_hash_ints.append(hash_int)
|
||||||
|
unique_hash_np = None # Инвалидируем кеш numpy-массива
|
||||||
|
|
||||||
|
tqdm.write(
|
||||||
|
f" Оригиналов: {len(originals)}, дубликатов: {len(duplicates)}"
|
||||||
|
)
|
||||||
|
return originals, duplicates
|
||||||
|
|
||||||
|
# -- 4. CLIP классификация --
|
||||||
|
|
||||||
|
def _classify_photos(
|
||||||
|
self, photos: list[Path],
|
||||||
|
) -> dict[str, tuple[str, float]]:
|
||||||
|
"""Классифицирует фото через CLIP. Возвращает {path_str: (category, score)}."""
|
||||||
|
import torch
|
||||||
|
import open_clip
|
||||||
|
|
||||||
|
cached: dict[str, list] = self.progress.data.get("classified_files", {})
|
||||||
|
result: dict[str, tuple[str, float]] = {}
|
||||||
|
to_classify: list[Path] = []
|
||||||
|
|
||||||
|
for p in photos:
|
||||||
|
key = str(p)
|
||||||
|
if key in cached:
|
||||||
|
result[key] = tuple(cached[key])
|
||||||
|
elif p.exists():
|
||||||
|
to_classify.append(p)
|
||||||
|
|
||||||
|
if not to_classify:
|
||||||
|
tqdm.write(f" Все {len(result)} классификаций из кеша.")
|
||||||
|
return result
|
||||||
|
|
||||||
|
tqdm.write(f" Классификация: {len(to_classify)} новых + {len(result)} из кеша")
|
||||||
|
|
||||||
|
# Устройство
|
||||||
|
if torch.backends.mps.is_available():
|
||||||
|
device = torch.device("mps")
|
||||||
|
tqdm.write(" Устройство: Apple Silicon MPS")
|
||||||
|
elif torch.cuda.is_available():
|
||||||
|
device = torch.device("cuda")
|
||||||
|
else:
|
||||||
|
device = torch.device("cpu")
|
||||||
|
tqdm.write(" Устройство: CPU")
|
||||||
|
|
||||||
|
# Загрузка модели
|
||||||
|
tqdm.write(" Загрузка CLIP ViT-B-32...")
|
||||||
|
model, _, preprocess = open_clip.create_model_and_transforms(
|
||||||
|
"ViT-B-32", pretrained="laion2b_s34b_b79k",
|
||||||
|
)
|
||||||
|
tokenizer = open_clip.get_tokenizer("ViT-B-32")
|
||||||
|
model = model.to(device)
|
||||||
|
model.eval()
|
||||||
|
|
||||||
|
# Подготовка text-эмбеддингов
|
||||||
|
all_prompts: list[str] = []
|
||||||
|
category_mapping: list[str] = []
|
||||||
|
for cat_name, prompts in CATEGORIES.items():
|
||||||
|
for prompt in prompts:
|
||||||
|
all_prompts.append(prompt)
|
||||||
|
category_mapping.append(cat_name)
|
||||||
|
|
||||||
|
tokens = tokenizer(all_prompts).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
text_features = model.encode_text(tokens)
|
||||||
|
text_features /= text_features.norm(dim=-1, keepdim=True)
|
||||||
|
|
||||||
|
tqdm.write(" Модель загружена.")
|
||||||
|
|
||||||
|
# Классификация батчами
|
||||||
|
batch_size = config.CLIP_BATCH_SIZE
|
||||||
|
bar = tqdm(total=len(to_classify), desc="Классификация", unit=" фото")
|
||||||
|
|
||||||
|
for i in range(0, len(to_classify), batch_size):
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
batch_paths = to_classify[i: i + batch_size]
|
||||||
|
tensors: list = []
|
||||||
|
valid_paths: list[Path] = []
|
||||||
|
|
||||||
|
for p in batch_paths:
|
||||||
|
try:
|
||||||
|
img = Image.open(p).convert("RGB")
|
||||||
|
tensors.append(preprocess(img))
|
||||||
|
valid_paths.append(p)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if tensors:
|
||||||
|
image_batch = torch.stack(tensors).to(device)
|
||||||
|
with torch.no_grad():
|
||||||
|
image_features = model.encode_image(image_batch)
|
||||||
|
image_features /= image_features.norm(dim=-1, keepdim=True)
|
||||||
|
similarities = image_features @ text_features.T
|
||||||
|
|
||||||
|
for j, path in enumerate(valid_paths):
|
||||||
|
sims = similarities[j]
|
||||||
|
cat_scores: dict[str, list[float]] = defaultdict(list)
|
||||||
|
for idx, cat_name in enumerate(category_mapping):
|
||||||
|
cat_scores[cat_name].append(sims[idx].item())
|
||||||
|
|
||||||
|
cat_avg = {
|
||||||
|
cat: sum(scores) / len(scores)
|
||||||
|
for cat, scores in cat_scores.items()
|
||||||
|
}
|
||||||
|
best_cat = max(cat_avg, key=lambda k: cat_avg[k])
|
||||||
|
best_score = cat_avg[best_cat]
|
||||||
|
|
||||||
|
key = str(path)
|
||||||
|
result[key] = (best_cat, best_score)
|
||||||
|
cached[key] = [best_cat, best_score]
|
||||||
|
|
||||||
|
bar.update(len(batch_paths))
|
||||||
|
|
||||||
|
if (i // batch_size) % 10 == 0:
|
||||||
|
self.progress.data["classified_files"] = cached
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
bar.close()
|
||||||
|
self.progress.data["classified_files"] = cached
|
||||||
|
self.progress.save()
|
||||||
|
return result
|
||||||
|
|
||||||
|
# -- 5. Организация (перемещение) --
|
||||||
|
|
||||||
|
def _move_photo(
|
||||||
|
self, photo: Path, dest_dir: Path, reason: str,
|
||||||
|
) -> Optional[Path]:
|
||||||
|
"""Перемещает фото в dest_dir (плоско, без подпапок диалогов).
|
||||||
|
|
||||||
|
Возвращает путь назначения или None при dry-run.
|
||||||
|
"""
|
||||||
|
dest = unique_dest(dest_dir / photo.name)
|
||||||
|
|
||||||
|
if self.dry_run:
|
||||||
|
tqdm.write(f" [DRY-RUN] {photo.name} → {dest_dir.name}/")
|
||||||
|
return None
|
||||||
|
|
||||||
|
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
shutil.move(str(photo), str(dest))
|
||||||
|
self.rollback.log_move(str(photo), str(dest), reason)
|
||||||
|
return dest
|
||||||
|
|
||||||
|
def _organize_all(
|
||||||
|
self,
|
||||||
|
originals: list[Path],
|
||||||
|
duplicates: list[Path],
|
||||||
|
classifications: dict[str, tuple[str, float]],
|
||||||
|
) -> dict[str, int]:
|
||||||
|
"""Перемещает все фото в output/ по категориям (плоская структура)."""
|
||||||
|
stats: dict[str, int] = defaultdict(int)
|
||||||
|
confidence_min = config.CLIP_CONFIDENCE_MIN
|
||||||
|
|
||||||
|
# Дубликаты → output/_duplicates/
|
||||||
|
tqdm.write("Перемещение дубликатов...")
|
||||||
|
for photo in tqdm(duplicates, desc="Дубликаты", unit=" фото", leave=False):
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
self._move_photo(photo, self.output_dir / DEDUP_DIR, "duplicate")
|
||||||
|
stats["duplicates"] += 1
|
||||||
|
|
||||||
|
# Оригиналы → по категориям
|
||||||
|
tqdm.write("Перемещение оригиналов по категориям...")
|
||||||
|
for photo in tqdm(originals, desc="Организация", unit=" фото", leave=False):
|
||||||
|
if self._stop:
|
||||||
|
break
|
||||||
|
|
||||||
|
key = str(photo)
|
||||||
|
cat_info = classifications.get(key)
|
||||||
|
|
||||||
|
if cat_info is None:
|
||||||
|
# Нет классификации — в review
|
||||||
|
self._move_photo(
|
||||||
|
photo,
|
||||||
|
self.output_dir / REVIEW_DIR / "unclassified",
|
||||||
|
"unclassified",
|
||||||
|
)
|
||||||
|
stats["review_unclassified"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
category, score = cat_info
|
||||||
|
|
||||||
|
if score < confidence_min:
|
||||||
|
dest = self.output_dir / REVIEW_DIR / "low_confidence"
|
||||||
|
stats["review_low_conf"] += 1
|
||||||
|
reason = f"low_conf:{category}"
|
||||||
|
elif category in JUNK_CATEGORIES:
|
||||||
|
dest = self.output_dir / JUNK_DIR
|
||||||
|
stats[f"junk_{category}"] += 1
|
||||||
|
reason = f"junk:{category}"
|
||||||
|
elif category in REVIEW_CATEGORIES:
|
||||||
|
dest = self.output_dir / REVIEW_DIR / category
|
||||||
|
stats[f"review_{category}"] += 1
|
||||||
|
reason = f"review:{category}"
|
||||||
|
elif category in KEEP_CATEGORIES:
|
||||||
|
dest = self.output_dir / category
|
||||||
|
stats[f"keep_{category}"] += 1
|
||||||
|
reason = f"keep:{category}"
|
||||||
|
else:
|
||||||
|
dest = self.output_dir / REVIEW_DIR / "other"
|
||||||
|
stats["review_other"] += 1
|
||||||
|
reason = f"other:{category}"
|
||||||
|
|
||||||
|
self._move_photo(photo, dest, reason)
|
||||||
|
|
||||||
|
return dict(stats)
|
||||||
|
|
||||||
|
# -- Главная команда: run --
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
"""Полная обработка: сбор → деdup → classify → организация."""
|
||||||
|
self.output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(" Обработка фото: дедупликация + классификация")
|
||||||
|
tqdm.write(f" Источник: {self.source_dir}/")
|
||||||
|
tqdm.write(f" Выход: {self.output_dir}/")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
|
||||||
|
# 1. Сбор фото, отсортированных по дате (oldest first)
|
||||||
|
tqdm.write("\n[1/5] Сбор и сортировка фото по дате...")
|
||||||
|
photos = self._scan_photos()
|
||||||
|
if not photos:
|
||||||
|
tqdm.write("Фото не найдено.")
|
||||||
|
return
|
||||||
|
|
||||||
|
oldest = datetime.fromtimestamp(photos[0].stat().st_mtime)
|
||||||
|
newest = datetime.fromtimestamp(photos[-1].stat().st_mtime)
|
||||||
|
tqdm.write(
|
||||||
|
f" Найдено: {len(photos)} фото "
|
||||||
|
f"({oldest.strftime('%Y-%m-%d')} → {newest.strftime('%Y-%m-%d')})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if self._stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 2. Хеширование
|
||||||
|
tqdm.write("\n[2/5] Вычисление перцептивных хешей...")
|
||||||
|
hashes = self._compute_hashes(photos)
|
||||||
|
if self._stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 3. Хронологическая дедупликация (от старых к новым)
|
||||||
|
tqdm.write("\n[3/5] Сквозная дедупликация (oldest = оригинал)...")
|
||||||
|
originals, duplicates = self._dedup_chronological(photos, hashes)
|
||||||
|
if self._stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 4. Классификация оригиналов через CLIP
|
||||||
|
tqdm.write(f"\n[4/5] Классификация {len(originals)} оригиналов...")
|
||||||
|
classifications = self._classify_photos(originals)
|
||||||
|
if self._stop:
|
||||||
|
return
|
||||||
|
|
||||||
|
# 5. Перемещение в output/
|
||||||
|
tqdm.write(f"\n[5/5] Организация файлов в {self.output_dir}/...")
|
||||||
|
stats = self._organize_all(originals, duplicates, classifications)
|
||||||
|
self.progress.save()
|
||||||
|
|
||||||
|
# Итого
|
||||||
|
tqdm.write("\n" + "=" * 60)
|
||||||
|
tqdm.write(" Итого:")
|
||||||
|
total_keep = sum(v for k, v in stats.items() if k.startswith("keep_"))
|
||||||
|
total_junk = sum(v for k, v in stats.items() if k.startswith("junk_"))
|
||||||
|
total_review = sum(v for k, v in stats.items() if k.startswith("review_"))
|
||||||
|
total_dup = stats.get("duplicates", 0)
|
||||||
|
|
||||||
|
tqdm.write(f" Оригиналов (keep): {total_keep}")
|
||||||
|
for k, v in sorted(stats.items()):
|
||||||
|
if k.startswith("keep_"):
|
||||||
|
tqdm.write(f" {k.replace('keep_', '')}: {v}")
|
||||||
|
tqdm.write(f" Дубликатов: {total_dup}")
|
||||||
|
tqdm.write(f" Мусор (junk): {total_junk}")
|
||||||
|
tqdm.write(f" На проверку (review): {total_review}")
|
||||||
|
tqdm.write(f" Всего обработано: {sum(stats.values())}")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
|
||||||
|
# -- Откат --
|
||||||
|
|
||||||
|
def run_rollback(self) -> None:
|
||||||
|
"""Откатывает все перемещения обратно в downloads/."""
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(" Откат всех перемещений")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
|
||||||
|
if self.rollback.count == 0:
|
||||||
|
tqdm.write("Нет перемещений для отката.")
|
||||||
|
return
|
||||||
|
|
||||||
|
tqdm.write(f"Записей в журнале: {self.rollback.count}")
|
||||||
|
restored = self.rollback.rollback()
|
||||||
|
tqdm.write(f"Восстановлено файлов: {restored}")
|
||||||
|
|
||||||
|
# Сбрасываем прогресс
|
||||||
|
self.progress.reset()
|
||||||
|
|
||||||
|
# Удаляем пустые папки в output/
|
||||||
|
if self.output_dir.exists():
|
||||||
|
self._remove_empty_dirs(self.output_dir)
|
||||||
|
if self.output_dir.exists() and not any(self.output_dir.iterdir()):
|
||||||
|
self.output_dir.rmdir()
|
||||||
|
tqdm.write(f" Удалена пустая папка: {self.output_dir.name}/")
|
||||||
|
|
||||||
|
# -- Статистика --
|
||||||
|
|
||||||
|
def run_stats(self) -> None:
|
||||||
|
"""Показывает статистику из отчёта."""
|
||||||
|
report_path = self.output_dir / "classification_report.json"
|
||||||
|
if not report_path.exists():
|
||||||
|
tqdm.write("Отчёт не найден. Сначала запусти run.")
|
||||||
|
return
|
||||||
|
|
||||||
|
with open(report_path, "r", encoding="utf-8") as f:
|
||||||
|
report = json.load(f)
|
||||||
|
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(" Статистика обработки")
|
||||||
|
tqdm.write("=" * 60)
|
||||||
|
tqdm.write(f"Всего фото: {report.get('total_photos', '?')}")
|
||||||
|
tqdm.write(f"Дубликатов: {report.get('duplicates', '?')}")
|
||||||
|
tqdm.write(f"\nКатегории оригиналов:")
|
||||||
|
for cat, count in report.get("categories", {}).items():
|
||||||
|
marker = ""
|
||||||
|
if cat in KEEP_CATEGORIES:
|
||||||
|
marker = " [ОСТАВИТЬ]"
|
||||||
|
elif cat in JUNK_CATEGORIES:
|
||||||
|
marker = " [МУСОР]"
|
||||||
|
elif cat in REVIEW_CATEGORIES:
|
||||||
|
marker = " [ПРОВЕРИТЬ]"
|
||||||
|
total = report.get("originals", report.get("total_photos", 1))
|
||||||
|
pct = count * 100 / total if total else 0
|
||||||
|
tqdm.write(f" {cat}: {count} ({pct:.1f}%){marker}")
|
||||||
|
|
||||||
|
# -- Утилиты --
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _remove_empty_dirs(path: Path) -> None:
|
||||||
|
"""Рекурсивно удаляет пустые подпапки."""
|
||||||
|
for child in sorted(path.rglob("*"), reverse=True):
|
||||||
|
if child.is_dir() and not any(child.iterdir()):
|
||||||
|
child.rmdir()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Точка входа
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Сквозная дедупликация и классификация фото из ВК",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Примеры:
|
||||||
|
python process_photos.py run # Полная обработка
|
||||||
|
python process_photos.py run --limit 200 # Тест на 200 фото
|
||||||
|
python process_photos.py run --dry-run # Без перемещения
|
||||||
|
python process_photos.py rollback # Откат
|
||||||
|
python process_photos.py stats # Статистика
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"command",
|
||||||
|
choices=["run", "rollback", "stats"],
|
||||||
|
help="Команда: run | rollback | stats",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--source",
|
||||||
|
default=config.DOWNLOAD_DIR,
|
||||||
|
help=f"Папка с фото (по умолчанию: {config.DOWNLOAD_DIR})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--output",
|
||||||
|
default=config.OUTPUT_DIR,
|
||||||
|
help=f"Папка для результатов (по умолчанию: {config.OUTPUT_DIR})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Обработать только первые N фото (для тестирования)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--threshold",
|
||||||
|
type=int,
|
||||||
|
default=config.DEDUP_THRESHOLD,
|
||||||
|
help=f"Порог Хэмминга (по умолчанию: {config.DEDUP_THRESHOLD})",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dry-run",
|
||||||
|
action="store_true",
|
||||||
|
help="Показать без перемещения файлов",
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
processor = PhotoProcessor(args)
|
||||||
|
|
||||||
|
if args.command == "run":
|
||||||
|
processor.run()
|
||||||
|
elif args.command == "rollback":
|
||||||
|
processor.run_rollback()
|
||||||
|
elif args.command == "stats":
|
||||||
|
processor.run_stats()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user