Files

861 lines
31 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import argparse
import os
import re
import sys
import time
# from datetime import datetime
from dataclasses import dataclass
# from pathlib import Path
from playwright.sync_api import (
Browser,
BrowserContext,
Page,
Playwright,
sync_playwright,
)
@dataclass
class BackupFile:
"""Одна строка таблицы — файл бэкапа"""
name: str
size: str
type: str
download_url: str = ""
delete_js: str = ""
timestamp: str = ""
@property
def category(self) -> str:
if self.name.startswith("ftp_"):
return "data"
elif self.type == "SQL dump ZIP":
return "sql_zip"
elif self.type == "raw SQL file":
return "sql_raw"
return "unknown"
@dataclass
class BackupGroup:
"""Группа файлов одного бэкапа (данные + SQL + ZIP)"""
timestamp: str
data: BackupFile | None = None
sql_raw: BackupFile | None = None
sql_zip: BackupFile | None = None
@property
def label(self) -> str:
parts = self.timestamp.split("_")
if len(parts) == 6:
return f"{parts[0]}-{parts[1]}-{parts[2]} {parts[3]}:{parts[4]}:{parts[5]}"
return self.timestamp
@property
def files(self) -> list[BackupFile]:
return [f for f in [self.data, self.sql_raw, self.sql_zip] if f]
class BackupManager:
pw: Playwright
browser: Browser
context: BrowserContext
page: Page
def __init__(self, username: str, password: str, account_id: str = "564622"):
self.username = username
self.password = password
self.account_id = account_id
# self.backup_folder = backup_folder / datetime.now().strftime("%Y-%m-%d")
self.base_url = "https://www.1gb.ru/adm-alv5/"
self.backup_url = f"{self.base_url}?u={account_id}#/c/b1"
self._stdout_original = None
self._log_file = None
def enable_output_log(self, log_path: str = "backup.log"):
"""Duplicate all stdout to a log file."""
if self._stdout_original is None:
self._stdout_original = sys.stdout
if self._log_file is None:
self._log_file = open(log_path, "a", encoding="utf-8", buffering=1)
class _Tee:
def __init__(self, *streams):
self._streams = streams
def write(self, data):
for s in self._streams:
s.write(data)
return len(data)
def flush(self):
for s in self._streams:
s.flush()
sys.stdout = _Tee(self._stdout_original, self._log_file)
def start(self):
self.pw = sync_playwright().start()
self.browser = self.pw.chromium.launch(headless=False)
self.context = self.browser.new_context()
self.page = self.context.new_page()
self._login()
def stop(self):
if self._stdout_original is not None:
sys.stdout = self._stdout_original
self._stdout_original = None
if self._log_file is not None:
self._log_file.close()
self._log_file = None
if self.browser:
self.browser.close()
if self.pw:
self.pw.stop()
def _login(self):
print("[*] Авторизация...")
self.page.goto(self.base_url, wait_until="networkidle")
self.page.fill("input[name='login_login']", self.username)
self.page.fill("input[name='login_pwd']", self.password)
with self.page.expect_navigation(wait_until="networkidle"):
self.page.click("button[type='submit']")
if "login_login" in self.page.content():
raise Exception("Авторизация не удалась!")
print("[OK] Авторизация успешна")
def _go_to_backups(self):
self.page.goto(self.backup_url, wait_until="networkidle")
self.page.wait_for_timeout(10000)
def _refresh_backup_list(self):
"""Обновить список бэкапов кликом по ссылке 'обновить список / статусы'"""
self.page.click("a:has-text('обновить список / статусы')")
self.page.wait_for_timeout(10000)
def _extract_timestamp(self, name: str) -> str:
match = re.search(r"(\d{4}_\d{2}_\d{2}_\d{2}_\d{2}_\d{2})", name)
return match.group(1) if match else ""
def _is_file_ready(self, backup_file: BackupFile) -> bool:
"""Проверяет что файл готов (не в процессе создания)"""
size = backup_file.size.lower()
# Готовый файл: "1033513 KB"
# В процессе: "файл формируется, 50048 KB"
if "формируется" in size or "создаётся" in size or "progress" in size:
return False
return True
def parse_backup_table(self) -> list[BackupFile]:
rows_data = self.page.evaluate("""() => {
const table = document.querySelector('table.gb-ttc-2');
if (!table) return [];
const results = [];
const rows = table.querySelectorAll('tr');
for (const row of rows) {
const cells = row.querySelectorAll('td');
if (cells.length < 4) continue;
const name = cells[0].innerText.trim();
const size = cells[1].innerText.trim();
const type = cells[2].innerText.trim();
const actionLinks = cells[3].querySelectorAll('a');
let downloadUrl = '';
let deleteJs = '';
for (const link of actionLinks) {
const text = link.innerText.trim().toLowerCase();
if (text === 'скачать') {
downloadUrl = link.getAttribute('href') || '';
} else if (text === 'удалить') {
deleteJs = link.getAttribute('href') || '';
}
}
results.push({
name: name,
size: size,
type: type,
download_url: downloadUrl,
delete_js: deleteJs
});
}
return results;
}""")
files = []
for row in rows_data:
f = BackupFile(
name=row["name"],
size=row["size"],
type=row["type"],
download_url=row["download_url"],
delete_js=row["delete_js"],
timestamp=self._extract_timestamp(row["name"]),
)
files.append(f)
return files
def get_grouped_backups(self) -> list[BackupGroup]:
self._go_to_backups()
files = self.parse_backup_table()
groups: dict[str, BackupGroup] = {}
for f in files:
ts = f.timestamp
if not ts:
continue
if ts not in groups:
groups[ts] = BackupGroup(timestamp=ts)
group = groups[ts]
if f.category == "data":
group.data = f
elif f.category == "sql_raw":
group.sql_raw = f
elif f.category == "sql_zip":
group.sql_zip = f
sorted_groups = sorted(groups.values(), key=lambda g: g.timestamp)
return sorted_groups
def print_backups(self):
groups = self.get_grouped_backups()
print(f"\n{'=' * 70}")
print(f" РЕЗЕРВНЫЕ КОПИИ: {len(groups)} шт.")
print(f"{'=' * 70}")
for i, group in enumerate(groups):
print(f"\n Бэкап #{i + 1} [{group.label}]")
print(f" {'' * 50}")
if group.data:
f = group.data
print(" 📁 Данные сайта (FTP)")
print(f" Файл: {f.name}")
print(f" Размер: {f.size}")
print(f" Тип: {f.type}")
print(f" Скачать: {f.download_url[:80]}...")
print(f" Удалить: {'есть' if f.delete_js else 'нет'}")
if group.sql_raw:
f = group.sql_raw
print(" 📄 База данных (SQL)")
print(f" Файл: {f.name}")
print(f" Размер: {f.size}")
print(f" Тип: {f.type}")
print(f" Скачать: {f.download_url[:80]}...")
print(f" Удалить: {'есть' if f.delete_js else 'нет'}")
if group.sql_zip:
f = group.sql_zip
print(" 📦 База данных (ZIP)")
print(f" Файл: {f.name}")
print(f" Размер: {f.size}")
print(f" Тип: {f.type}")
print(f" Скачать: {f.download_url[:80]}...")
print(f" Удалить: {'есть' if f.delete_js else 'нет'}")
return groups
# =====================================================
# СОЗДАНИЕ БЭКАПОВ
# =====================================================
def _expected_new_files(self, backup_data: bool, backup_mysql: bool) -> int:
"""Сколько новых файлов ожидаем"""
count = 0
if backup_data:
count += 1 # ftp_...
if backup_mysql:
count += 2 # sqldump_... (raw + zip)
return count
def create_backup(
self,
backup_data: bool = False,
backup_mysql: bool = True,
wait_for_completion: bool = True,
poll_interval: int = 60,
max_wait: int = 600,
):
"""
Создать резервную копию.
Args:
backup_data: True — бэкап данных FTP (большой, долгий)
backup_mysql: True — бэкап базы данных MySQL
wait_for_completion: ждать завершения создания
poll_interval: интервал проверки статуса (секунды)
max_wait: максимальное время ожидания (секунды)
"""
if not backup_data and not backup_mysql:
print("[!] Нужно выбрать хотя бы один тип бэкапа")
return False
self._go_to_backups()
# Запоминаем количество бэкапов до создания
ready_files_before = [
f for f in self.parse_backup_table() if self._is_file_ready(f)
]
count_before = len(ready_files_before)
print(f"[*] Готовых бэкапов до создания: {count_before}")
types = []
if backup_data:
types.append("FTP-данные")
if backup_mysql:
types.append("MySQL")
print(f"[*] Создание бэкапа: {', '.join(types)}")
# Ставим чекбоксы
if backup_data:
self.page.click("#t_iis_690148")
self.page.wait_for_timeout(300)
if backup_mysql:
self.page.click("#t_mysql_444096")
self.page.wait_for_timeout(300)
# Проверяем
checkbox_state = self.page.evaluate("""() => {
return {
ftp: document.querySelector('#t_iis_690148')?.checked || false,
mysql: document.querySelector('#t_mysql_444096')?.checked || false
}
}""")
print(f" Чекбокс FTP: {'' if checkbox_state['ftp'] else ''}")
print(f" Чекбокс MySQL: {'' if checkbox_state['mysql'] else ''}")
# Нажимаем "создать архивы"
print("[*] Отправка команды на создание...")
self.page.click("input[name='do_upd']")
self.page.wait_for_timeout(5000)
print("[OK] Команда на создание бэкапа отправлена")
self.page.screenshot(path="create_backup_sent.png")
if not wait_for_completion:
print("[*] Ожидание отключено. Проверьте статус позже.")
return True
# Ждём завершения
return self._wait_for_backup_completion(
count_before=count_before,
expected_new=self._expected_new_files(backup_data, backup_mysql),
poll_interval=poll_interval,
max_wait=max_wait,
)
def _wait_for_backup_completion(
self, count_before: int, expected_new: int, poll_interval: int, max_wait: int
) -> bool:
"""Ожидание завершения создания бэкапа"""
print(f"\n[*] Ожидание завершения (макс. {max_wait} сек)...")
print(f" Ожидаем {expected_new} новых готовых файлов")
start_time = time.time()
attempt = 0
while True:
elapsed = time.time() - start_time
if elapsed > max_wait:
print(f"\n[!] Таймаут {max_wait} сек. Бэкап возможно ещё создаётся.")
return False
attempt += 1
print(f"\n Проверка #{attempt} ({int(elapsed)} сек)...", end=" ")
# Обновляем список через ссылку на странице
self._refresh_backup_list()
files_now = self.parse_backup_table()
# Считаем только ГОТОВЫЕ файлы (размер вида "12345 KB" без "формируется")
ready_files = [f for f in files_now if self._is_file_ready(f)]
ready_count = len(ready_files)
new_ready = ready_count - count_before
# Считаем файлы в процессе создания
in_progress = [f for f in files_now if not self._is_file_ready(f)]
print(
f"готовых: {ready_count} (новых: {new_ready}), в процессе: {len(in_progress)}"
)
if in_progress:
for f in in_progress:
print(f"{f.name}{f.size}")
if new_ready >= expected_new:
print(f"\n[OK] Бэкап создан! Новых готовых файлов: {new_ready}")
return True
# Проверяем статус задания
# status = self._check_task_status()
# if status:
# for s in status:
# print(f" Задание: {s}")
print(f" Следующая проверка через {poll_interval} сек...")
time.sleep(poll_interval)
def create_backup_mysql_only(self, wait: bool = True):
"""Создать только бэкап базы данных"""
return self.create_backup(
backup_data=False, backup_mysql=True, wait_for_completion=wait
)
def create_backup_full(self, wait: bool = True):
"""Создать полный бэкап (данные + база)"""
return self.create_backup(
backup_data=True, backup_mysql=True, wait_for_completion=wait
)
def download_file(
self, backup_file: BackupFile, save_dir: str = "./backups"
) -> str | None:
"""Скачать один файл бэкапа"""
os.makedirs(save_dir, exist_ok=True)
if not backup_file.download_url:
print(f"[!] Нет ссылки для скачивания: {backup_file.name}")
return None
print(f"[*] Скачивание: {backup_file.name} ({backup_file.size})...")
with self.page.expect_download(timeout=600000) as download_info:
# Открываем ссылку в текущем контексте (ссылка имеет target="_blank")
self.page.evaluate(f"""() => {{
const a = document.createElement('a');
a.href = '{backup_file.download_url}';
a.download = '';
document.body.appendChild(a);
a.click();
a.remove();
}}""")
download = download_info.value
filename = download.suggested_filename or backup_file.name
save_path = os.path.join(save_dir, filename)
download.save_as(save_path)
file_size = os.path.getsize(save_path)
print(f"[OK] Сохранено: {save_path} ({file_size:,} байт)")
return save_path
def download_group(
self,
group: BackupGroup,
save_dir: str = "./backups",
include_data: bool = True,
include_sql_raw: bool = True,
include_sql_zip: bool = True,
) -> list[str]:
"""Скачать все файлы одной группы бэкапа"""
downloaded = []
print(f"\n[*] Скачивание бэкапа [{group.label}]")
if include_data and group.data:
path = self.download_file(group.data, save_dir)
if path:
downloaded.append(path)
if include_sql_raw and group.sql_raw:
path = self.download_file(group.sql_raw, save_dir)
if path:
downloaded.append(path)
if include_sql_zip and group.sql_zip:
path = self.download_file(group.sql_zip, save_dir)
if path:
downloaded.append(path)
print(f"[OK] Скачано файлов: {len(downloaded)}")
return downloaded
def download_latest(
self,
save_dir: str = "./backups",
include_data: bool = False,
include_sql_raw: bool = True,
include_sql_zip: bool = True,
) -> list[str]:
"""Скачать самый свежий бэкап"""
groups = self.get_grouped_backups()
if not groups:
print("[!] Нет бэкапов для скачивания")
return []
latest = groups[-1]
print(f"[*] Последний бэкап: [{latest.label}]")
return self.download_group(
latest,
save_dir,
include_data=include_data,
include_sql_raw=include_sql_raw,
include_sql_zip=include_sql_zip,
)
def create_and_download(
self,
save_dir: str = "./backups",
backup_data: bool = False,
backup_mysql: bool = True,
) -> list[str]:
"""Создать бэкап и сразу скачать"""
# Запоминаем текущие timestamp'ы
groups_before = self.get_grouped_backups()
timestamps_before = {g.timestamp for g in groups_before}
# Создаём бэкап
success = self.create_backup(
backup_data=backup_data, backup_mysql=backup_mysql, wait_for_completion=True
)
if not success:
print("[!] Бэкап не был создан, скачивание отменено")
return []
# Находим новые бэкапы
groups_after = self.get_grouped_backups()
new_groups = [g for g in groups_after if g.timestamp not in timestamps_before]
if not new_groups:
print("[!] Новые бэкапы не найдены")
return []
print(f"\n[*] Найдено новых бэкапов: {len(new_groups)}")
# Скачиваем все новые
all_downloaded = []
for group in new_groups:
downloaded = self.download_group(
group,
save_dir,
include_data=backup_data,
include_sql_raw=backup_mysql,
include_sql_zip=backup_mysql,
)
all_downloaded.extend(downloaded)
print(f"\n{'=' * 50}")
print(f"[OK] Итого скачано файлов: {len(all_downloaded)}")
for path in all_downloaded:
size = os.path.getsize(path)
print(f" {path} ({size:,} байт)")
print(f"{'=' * 50}")
return all_downloaded
def find_old_backups(self, delete_count: int = 1, year: int = 2026):
"""
Найти самые старые бэкапы за указанный год для удаления.
Args:
delete_count: сколько самых старых удалить
year: за какой год
Returns:
кортеж (на удаление, оставляем)
"""
groups = self.get_grouped_backups()
# Фильтруем только бэкапы за указанный год
year_groups = [g for g in groups if g.timestamp.startswith(str(year))]
if not year_groups:
print(f"[*] Бэкапов за {year} год не найдено")
return None
print(f"[*] Всего бэкапов за {year} год: {len(year_groups)}")
print(f"[*] Удаляем самых старых: {delete_count}")
# Сортировка по timestamp
year_groups.sort(key=lambda g: g.timestamp)
if delete_count >= len(year_groups):
print(f"[!] Нельзя удалить все бэкапы. Максимум: {len(year_groups) - 1}")
delete_count = len(year_groups) - 1
if delete_count <= 0:
return None
to_delete = year_groups[:delete_count]
to_keep = year_groups[delete_count:]
return to_delete, to_keep
def print_deletion_plan(self, delete_count: int = 1, year: int = 2026):
"""Показать план удаления без реального удаления"""
result = self.find_old_backups(delete_count=delete_count, year=year)
if not result:
return []
to_delete, to_keep = result
print(f"\n{'=' * 70}")
print(f" ПЛАН УДАЛЕНИЯ БЭКАПОВ ЗА {year} ГОД")
print(f"{'=' * 70}")
print(f"\n 🗑️ НА УДАЛЕНИЕ ({len(to_delete)} шт.):")
print(f" {'' * 50}")
for i, group in enumerate(to_delete):
file_count = len(group.files)
total_size = sum(
int(f.size.replace(" KB", "").replace(",", "").strip())
for f in group.files
if "KB" in f.size
)
print(
f" {i + 1}. [{group.label}] — {file_count} файлов, ~{total_size:,} KB"
)
for f in group.files:
print(
f" {'📁' if f.category == 'data' else '📄' if f.category == 'sql_raw' else '📦'} {f.name} ({f.size})"
)
print(f"\n ✅ ОСТАВЛЯЕМ ({len(to_keep)} шт.):")
print(f" {'' * 50}")
for i, group in enumerate(to_keep):
file_count = len(group.files)
print(f" {i + 1}. [{group.label}] — {file_count} файлов")
return to_delete
def delete_old_backups(
self, delete_count: int = 1, year: int = 2026, confirm: bool = True
):
"""Удалить старые бэкапы за указанный год"""
to_delete = self.print_deletion_plan(delete_count=delete_count, year=year)
if not to_delete:
return
if confirm:
answer = input(f"\nУдалить {len(to_delete)} бэкапов? (y/n): ")
if answer.lower() != "y":
print("Отменено.")
return
# Автоматически подтверждаем confirm() диалоги
self.page.on("dialog", lambda dialog: dialog.accept())
for i, group in enumerate(to_delete):
print(f"\n[*] Удаление бэкапа [{group.label}] ({i + 1}/{len(to_delete)})")
for f in group.files:
if not f.delete_js:
print(f" [!] Нет ссылки удаления: {f.name}")
continue
print(f" Удаление: {f.name}...", end="\n")
js_code = f.delete_js.replace("javascript:", "", 1)
self.page.evaluate(js_code)
self.page.wait_for_timeout(3000)
self._refresh_backup_list()
print("\n[OK] Готово")
def parse_args():
parser = argparse.ArgumentParser(
description="Менеджер резервных копий 1gb.ru",
formatter_class=argparse.RawTextHelpFormatter,
)
parser.add_argument(
"--action",
choices=["create", "list", "download", "delete", "create-download"],
default="list",
help="""Действие:
list — показать список бэкапов (по умолчанию)
create — создать бэкап
download — скачать последний бэкап
create-download — создать и скачать
delete — удалить старые бэкапы""",
)
parser.add_argument(
"--type",
choices=["all", "data", "db"],
default="all",
help="""Тип бэкапа (для create/create-download/download):
db — только база данных MySQL (по умолчанию)
data — только данные сайта FTP
all — данные + база""",
)
parser.add_argument(
"--keep",
type=int,
default=3,
help="Сколько бэкапов ОСТАВИТЬ при удалении (по умолчанию: 3)",
)
parser.add_argument(
"--year",
type=int,
default=2026,
help="За какой год удалять бэкапы (по умолчанию: 2026)",
)
parser.add_argument(
"--save-dir",
type=str,
default="F:/test_folder/MyReports",
help="Папка для скачивания (по умолчанию: F:/test_folder/MyReports)",
)
parser.add_argument(
"--no-wait", action="store_true", help="Не ждать завершения создания бэкапа"
)
parser.add_argument("--username", type=str, default="minzdravmo", help="Логин")
parser.add_argument(
"--password", type=str, default="KB3ZS7*sv6j4zYr@bPdeFWQN38rbtQv", help="Пароль"
)
parser.add_argument("--account", type=str, default="564622", help="ID аккаунта")
return parser.parse_args()
# === ИСПОЛЬЗОВАНИЕ ===
if __name__ == "__main__":
args = parse_args()
backup_data = args.type in ("all", "data")
backup_mysql = args.type in ("all", "db")
mgr = BackupManager(
username=args.username, password=args.password, account_id=args.account
)
mgr.enable_output_log("F:/test_folder/MyReports/backup.log")
try:
mgr.start()
if args.action == "list":
mgr.print_backups()
elif args.action == "create":
mgr.print_backups()
print(f"\n{'=' * 70}")
mgr.create_backup(
backup_data=backup_data,
backup_mysql=backup_mysql,
wait_for_completion=not args.no_wait,
)
mgr.print_backups()
elif args.action == "download":
mgr.download_latest(
save_dir=args.save_dir,
include_data=backup_data,
include_sql_raw=backup_mysql,
include_sql_zip=backup_mysql,
)
elif args.action == "create-download":
mgr.create_and_download(
save_dir=args.save_dir,
backup_data=backup_data,
backup_mysql=backup_mysql,
)
elif args.action == "delete":
groups = mgr.get_grouped_backups()
year_groups = [g for g in groups if g.timestamp.startswith(str(args.year))]
total = len(year_groups)
if total <= args.keep:
print(
f"[*] Бэкапов за {args.year}: {total}, оставляем: {args.keep}. Удалять нечего."
)
else:
delete_count = total - args.keep
mgr.delete_old_backups(delete_count=delete_count, year=args.year)
# 1. Показать текущие бэкапы
# groups = mgr.print_backups()
# print("\n" + "=" * 70)
# 2. Создать бэкап только базы данных
# mgr.create_backup_mysql_only(wait=True)
# 2. Создать бэкап и сразу скачать
# mgr.create_and_download(
# save_dir="F:/test_folder/MyReports",
# backup_data=True, # данные FTP (закомментировано)
# backup_mysql=True, # база данных
# )
# 3. Показать, что удаляем и удалить
# mgr.print_deletion_plan(delete_count=1, year=2026)
# mgr.delete_old_backups(delete_count=1, year=2026)
# Скачать только последний бэкап (без создания нового)
# mgr.download_latest(save_dir="./backups")
# Скачать конкретный бэкап по номеру
# mgr.download_group(groups[0], save_dir="./backups")
# Создать бэкап без скачивания
# mgr.create_backup_mysql_only(wait=True)
# 3. Показать бэкапы после создания
# groups = mgr.print_backups()
# 4. Создать полный бэкап (раскомментировать когда нужно)
# mgr.create_backup_full(wait=True)
input("\nEnter для выхода...")
finally:
mgr.stop()
# # Показать список бэкапов
# python main.py --action list
# # Создать бэкап только БД
# python main.py --action create --type db
# # Создать бэкап всего (данные + БД)
# python main.py --action create --type all
# # Создать и сразу скачать БД
# python main.py --action create-download --type db --save-dir ./backups
# # Скачать последний бэкап
# python main.py --action download --type db
# # Удалить старые, оставить 3 за 2026 год
# python main.py --action delete --keep 3 --year 2026
# # Удалить старые, оставить 1
# python main.py --action delete --keep 1 --year 2026
# # Полный цикл: создать БД + скачать + удалить старые (оставить 3)
# python main.py --action create-download --type db && python main.py --action delete --keep 3