Compare commits
8 Commits
e97d346682
...
v1.5.1
| Author | SHA1 | Date | |
|---|---|---|---|
| e590a6cde0 | |||
| aad6e8c5af | |||
| 4629037890 | |||
| ad24cb6fca | |||
| 9b263dd85f | |||
| d7eaec4ba4 | |||
|
|
30fc78e89b | ||
|
|
6225fb15d4 |
13
.gitignore
vendored
13
.gitignore
vendored
@@ -1,3 +1,12 @@
|
|||||||
/.venv/
|
/.venv/
|
||||||
/.venv1/
|
/setup.py
|
||||||
/.venv3/
|
/build_cx/
|
||||||
|
/build_linux/
|
||||||
|
/build_win32/
|
||||||
|
/build_darwin/
|
||||||
|
.idea/
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
tests/__pycache__/
|
||||||
|
build/
|
||||||
|
dist/
|
||||||
|
|||||||
18
LICENSE
Normal file
18
LICENSE
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2025 Aleksandr Denisov
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||||
|
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||||
|
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||||
|
following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||||
|
portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||||
|
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||||
|
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||||
|
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||||
|
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
104
README.md
Normal file
104
README.md
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
# 🚀 Anabasis VK Chat Manager
|
||||||
|
|
||||||
|
**Anabasis VK Chat Manager** — специализированное десктопное приложение для HR-менеджеров и администраторов сообществ, предназначенное для автоматизации управления участниками в чатах ВКонтакте. Избавьтесь от рутины и управляйте всеми беседами из одного удобного интерфейса.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Основные возможности
|
||||||
|
|
||||||
|
* **🔐 Безопасная авторизация:** Вход через официальный VK OAuth во встроенном защищенном браузере.
|
||||||
|
* **💾 Умное сохранение сессий:** Поддержка Persistent Cookies — не нужно вводить пароль при каждом запуске.
|
||||||
|
* **⏳ Таймер токена:** Наглядное отображение времени действия сессии прямо в интерфейсе.
|
||||||
|
* **📊 Массовые операции:**
|
||||||
|
* Моментальная загрузка всех доступных чатов пользователя.
|
||||||
|
* Групповой выбор чатов («Выбрать все» / «Снять выбор»).
|
||||||
|
* Быстрое обновление списка бесед.
|
||||||
|
* **👤 Интеллектуальный поиск ID:** Автоматическое распознавание ID пользователя из ссылок любого формата (например, `vk.com/id123`, `vk.com/durov` или просто `durov`).
|
||||||
|
* **🛠 Управление в один клик:** Кнопки для мгновенного исключения или приглашения пользователя во все выбранные чаты одновременно.
|
||||||
|
* **🛡 Стабильность:** Улучшенная обработка ошибок VK API и автоматическая реакция на смену IP-адреса.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 Установка и запуск
|
||||||
|
|
||||||
|
### Вариант 1: Готовый билд (Windows)
|
||||||
|
1. Перейдите в раздел **Releases** на GitHub.
|
||||||
|
2. Скачайте архив формата `AnabasisManager-1.x.zip`.
|
||||||
|
3. Распакуйте архив в удобную папку.
|
||||||
|
4. Запустите файл **AnabasisManager.exe**.
|
||||||
|
|
||||||
|
### Вариант 2: Запуск из исходного кода
|
||||||
|
Вам потребуется **Python 3.10** или выше.
|
||||||
|
|
||||||
|
1. **Клонируйте репозиторий:**
|
||||||
|
```bash
|
||||||
|
git clone [https://github.com/your-username/AnabasisVKChatManager.git](https://github.com/your-username/AnabasisVKChatManager.git)
|
||||||
|
cd AnabasisVKChatManager
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Настройте виртуальное окружение:**
|
||||||
|
```bash
|
||||||
|
python -m venv venv
|
||||||
|
# Для Windows:
|
||||||
|
.\venv\Scripts\activate
|
||||||
|
# Для macOS/Linux:
|
||||||
|
source venv/bin/activate
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Установите зависимости:**
|
||||||
|
```bash
|
||||||
|
pip install PySide6 vk_api
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Запустите приложение:**
|
||||||
|
```bash
|
||||||
|
python main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🕹 Инструкция по использованию
|
||||||
|
|
||||||
|
1. **Вход:** Нажмите кнопку «Авторизоваться через VK». Введите данные в открывшемся окне браузера.
|
||||||
|
2. **Выбор целей:** Отметьте галочками чаты, в которых нужно произвести изменения.
|
||||||
|
3. **Данные пользователя:** Вставьте ссылку на профиль VK человека, которого нужно добавить или удалить.
|
||||||
|
4. **Действие:** Нажмите кнопку нужной операции. Следите за прогрессом в окне системных сообщений.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📂 Техническая информация
|
||||||
|
|
||||||
|
### Сборка проекта (для разработчиков)
|
||||||
|
Проект использует кастомный скрипт автоматизации `build.py`, который оптимизирует зависимости `PySide6` и корректно упаковывает `QtWebEngineCore`.
|
||||||
|
|
||||||
|
**Команда для сборки:**
|
||||||
|
```bash
|
||||||
|
python build.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт автоматически:
|
||||||
|
|
||||||
|
Собирает .exe через PyInstaller с использованием --collect-all для модулей WebEngine.
|
||||||
|
|
||||||
|
Удаляет лишние библиотеки (PDF, Multimedia, Designer) и папки переводов, сокращая размер сборки на ~100 МБ.
|
||||||
|
|
||||||
|
Создает готовый ZIP-архив с актуальной версией в названии.
|
||||||
|
|
||||||
|
Хранение данных
|
||||||
|
|
||||||
|
Приложение использует системные папки AppData для изоляции пользовательских данных:
|
||||||
|
|
||||||
|
Windows: %APPDATA%/AnabasisVKChatManager
|
||||||
|
|
||||||
|
macOS: ~/Library/Application Support/AnabasisVKChatManager
|
||||||
|
|
||||||
|
В этих папках хранятся token.json (доступ к API) и web_engine_cache/ (сессия браузера).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📜 Лицензия
|
||||||
|
|
||||||
|
---
|
||||||
|
Проект распространяется под лицензией MIT.
|
||||||
|
|
||||||
|
Сэкономьте часы ручного труда с Anabasis VK Chat Manager.
|
||||||
78
auth_webview.py
Normal file
78
auth_webview.py
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from urllib.parse import urlparse, parse_qs, unquote
|
||||||
|
|
||||||
|
import webview
|
||||||
|
|
||||||
|
|
||||||
|
def extract_token(url_string):
|
||||||
|
token = None
|
||||||
|
expires_in = 3600
|
||||||
|
parsed = urlparse(url_string)
|
||||||
|
|
||||||
|
if parsed.fragment:
|
||||||
|
params = parse_qs(parsed.fragment)
|
||||||
|
else:
|
||||||
|
params = parse_qs(parsed.query)
|
||||||
|
|
||||||
|
if 'access_token' in params:
|
||||||
|
token = params['access_token'][0]
|
||||||
|
if 'expires_in' in params:
|
||||||
|
try:
|
||||||
|
expires_in = int(params['expires_in'][0])
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
start_marker = "access_token%253D"
|
||||||
|
end_marker = "%25"
|
||||||
|
|
||||||
|
start_index = url_string.find(start_marker)
|
||||||
|
if start_index != -1:
|
||||||
|
token_start_index = start_index + len(start_marker)
|
||||||
|
remaining_url = url_string[token_start_index:]
|
||||||
|
end_index = remaining_url.find(end_marker)
|
||||||
|
|
||||||
|
if end_index != -1:
|
||||||
|
raw_token = remaining_url[:end_index]
|
||||||
|
else:
|
||||||
|
amp_index = remaining_url.find('&')
|
||||||
|
if amp_index != -1:
|
||||||
|
raw_token = remaining_url[:amp_index]
|
||||||
|
else:
|
||||||
|
raw_token = remaining_url
|
||||||
|
token = unquote(raw_token)
|
||||||
|
|
||||||
|
return token, expires_in
|
||||||
|
|
||||||
|
|
||||||
|
def main_auth(auth_url, output_path):
|
||||||
|
def poll_url():
|
||||||
|
try:
|
||||||
|
url = window.get_current_url()
|
||||||
|
except Exception:
|
||||||
|
url = None
|
||||||
|
if url:
|
||||||
|
token, expires_in = extract_token(url)
|
||||||
|
if token:
|
||||||
|
data = {"token": token, "expires_in": expires_in}
|
||||||
|
with open(output_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
window.destroy()
|
||||||
|
return
|
||||||
|
threading.Timer(0.5, poll_url).start()
|
||||||
|
|
||||||
|
def on_loaded():
|
||||||
|
threading.Timer(0.5, poll_url).start()
|
||||||
|
|
||||||
|
window = webview.create_window("VK Авторизация", auth_url)
|
||||||
|
window.events.loaded += on_loaded
|
||||||
|
storage_path = os.path.join(os.path.dirname(output_path), "webview_profile")
|
||||||
|
webview.start(private_mode=False, storage_path=storage_path)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
107
build.py
Normal file
107
build.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# --- Конфигурация ---
|
||||||
|
APP_NAME = "AnabasisManager"
|
||||||
|
VERSION = "1.5.1" # Ваша версия
|
||||||
|
MAIN_SCRIPT = "main.py"
|
||||||
|
ICON_PATH = "icon.ico"
|
||||||
|
DIST_DIR = os.path.join("dist", APP_NAME)
|
||||||
|
ARCHIVE_NAME = f"{APP_NAME}-{VERSION}" # Формат Название-Версия
|
||||||
|
SAFE_CLEAN_ROOT_FILES = {"main.py", "requirements.txt", "build.py"}
|
||||||
|
REMOVE_LIST = [
|
||||||
|
"Qt6Pdf.dll", "Qt6PdfQuick.dll", "Qt6PdfWidgets.dll",
|
||||||
|
"Qt6VirtualKeyboard.dll", "Qt6Positioning.dll",
|
||||||
|
"Qt6PrintSupport.dll", "Qt6Svg.dll", "Qt6Sql.dll",
|
||||||
|
"Qt6Charts.dll", "Qt6Multimedia.dll", "Qt63DCore.dll",
|
||||||
|
"translations",
|
||||||
|
"Qt6QuickTemplates2.dll"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_project_root():
|
||||||
|
missing = [name for name in SAFE_CLEAN_ROOT_FILES if not os.path.exists(name)]
|
||||||
|
if missing:
|
||||||
|
print("[ERROR] Скрипт нужно запускать из корня проекта.")
|
||||||
|
print(f"[ERROR] Не найдены: {', '.join(missing)}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_build():
|
||||||
|
print(f"--- 1. Запуск PyInstaller для {APP_NAME} v{VERSION} ---")
|
||||||
|
|
||||||
|
command = [
|
||||||
|
"pyinstaller",
|
||||||
|
"--noconfirm",
|
||||||
|
"--onedir",
|
||||||
|
"--windowed",
|
||||||
|
"--exclude-module", "PySide6.QtWebEngineCore",
|
||||||
|
"--exclude-module", "PySide6.QtWebEngineWidgets",
|
||||||
|
"--exclude-module", "PySide6.QtWebEngineQuick",
|
||||||
|
f"--name={APP_NAME}",
|
||||||
|
f"--icon={ICON_PATH}" if os.path.exists(ICON_PATH) else "",
|
||||||
|
f"--add-data={ICON_PATH}{os.pathsep}." if os.path.exists(ICON_PATH) else "",
|
||||||
|
f"--add-data=auth_webview.py{os.pathsep}.",
|
||||||
|
MAIN_SCRIPT
|
||||||
|
]
|
||||||
|
|
||||||
|
command = [arg for arg in command if arg]
|
||||||
|
|
||||||
|
try:
|
||||||
|
subprocess.check_call(command)
|
||||||
|
print("\n[OK] Сборка PyInstaller завершена.")
|
||||||
|
except subprocess.CalledProcessError as e:
|
||||||
|
print(f"\n[ERROR] Ошибка при сборке: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def run_cleanup():
|
||||||
|
print(f"\n--- 2. Оптимизация папки {APP_NAME} ---")
|
||||||
|
|
||||||
|
# Пытаемся найти папку PySide6 внутри сборки
|
||||||
|
pyside_path = os.path.join(DIST_DIR, "PySide6")
|
||||||
|
if not os.path.exists(pyside_path):
|
||||||
|
pyside_path = DIST_DIR
|
||||||
|
|
||||||
|
for item in REMOVE_LIST:
|
||||||
|
path = os.path.join(pyside_path, item)
|
||||||
|
if os.path.exists(path):
|
||||||
|
try:
|
||||||
|
if os.path.isdir(path):
|
||||||
|
shutil.rmtree(path)
|
||||||
|
else:
|
||||||
|
os.remove(path)
|
||||||
|
print(f"Удалено: {item}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Пропуск {item}: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def create_archive():
|
||||||
|
print(f"\n--- 3. Создание архива {ARCHIVE_NAME}.zip ---")
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Создаем zip-архив из папки DIST_DIR
|
||||||
|
# base_name - имя файла без расширения, format - 'zip', root_dir - что упаковываем
|
||||||
|
shutil.make_archive(os.path.join("dist", ARCHIVE_NAME), 'zip', DIST_DIR)
|
||||||
|
print(f"[OK] Архив создан: dist/{ARCHIVE_NAME}.zip")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERROR] Не удалось создать архив: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
ensure_project_root()
|
||||||
|
# Предварительная очистка
|
||||||
|
for folder in ["build", "dist"]:
|
||||||
|
if os.path.exists(folder):
|
||||||
|
shutil.rmtree(folder)
|
||||||
|
|
||||||
|
run_build()
|
||||||
|
run_cleanup()
|
||||||
|
create_archive()
|
||||||
|
|
||||||
|
print("\n" + "=" * 30)
|
||||||
|
print("ПРОЦЕСС ЗАВЕРШЕН")
|
||||||
|
print(f"Файл для отправки: dist/{ARCHIVE_NAME}.zip")
|
||||||
|
print("=" * 30)
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
PySide6~=6.9.1
|
PySide6~=6.10.2
|
||||||
vk-api~=11.9.9
|
vk-api~=11.9.9
|
||||||
|
pywebview
|
||||||
|
|||||||
49
tests/test_auth_relogin_smoke.py
Normal file
49
tests/test_auth_relogin_smoke.py
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
class AuthReloginSmokeTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.source = Path("main.py").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
def test_auth_command_builder_handles_frozen_and_source(self):
|
||||||
|
self.assertIn("def _build_auth_command(self, auth_url, output_path):", self.source)
|
||||||
|
self.assertIn('return sys.executable, ["--auth", auth_url, output_path]', self.source)
|
||||||
|
self.assertIn('return sys.executable, [os.path.abspath(__file__), "--auth", auth_url, output_path]', self.source)
|
||||||
|
|
||||||
|
def test_auth_runs_via_qprocess(self):
|
||||||
|
self.assertIn("process = QProcess(self)", self.source)
|
||||||
|
self.assertIn("process.start(program, args)", self.source)
|
||||||
|
self.assertIn("def _on_auth_process_finished(self, exit_code, _exit_status):", self.source)
|
||||||
|
self.assertIn("if self.auth_process and self.auth_process.state() == QProcess.NotRunning:", self.source)
|
||||||
|
|
||||||
|
def test_force_relogin_has_backoff_and_event_log(self):
|
||||||
|
self.assertIn("AUTH_RELOGIN_BACKOFF_SECONDS = 5.0", self.source)
|
||||||
|
self.assertIn("if self._auth_relogin_in_progress:", self.source)
|
||||||
|
self.assertIn("force_relogin_backoff", self.source)
|
||||||
|
self.assertIn("force_relogin", self.source)
|
||||||
|
|
||||||
|
def test_auth_error_paths_trigger_force_relogin(self):
|
||||||
|
self.assertIn("def _handle_vk_api_error(self, context, exc, action_name=None, ui_message_prefix=None, disable_ui=False):", self.source)
|
||||||
|
self.assertIn("self._force_relogin(exc, action_name or context)", self.source)
|
||||||
|
self.assertIn('"load_chats",', self.source)
|
||||||
|
self.assertIn('"execute_user_action",', self.source)
|
||||||
|
self.assertIn('"set_user_admin",', self.source)
|
||||||
|
|
||||||
|
def test_tab_checkbox_lists_use_existing_attributes(self):
|
||||||
|
self.assertIn("self.warehouse_chat_checkboxes", self.source)
|
||||||
|
self.assertIn("self.coffee_chat_checkboxes", self.source)
|
||||||
|
self.assertNotIn("self.retail_warehouse_checkboxes", self.source)
|
||||||
|
self.assertNotIn("self.retail_coffee_checkboxes", self.source)
|
||||||
|
|
||||||
|
def test_update_check_actions_exist(self):
|
||||||
|
self.assertIn("APP_VERSION = ", self.source)
|
||||||
|
self.assertIn("UPDATE_REPOSITORY = ", self.source)
|
||||||
|
self.assertIn('QAction("Проверить обновления", self)', self.source)
|
||||||
|
self.assertIn("def check_for_updates(self, silent_no_updates=False):", self.source)
|
||||||
|
self.assertIn("class UpdateChecker(QObject):", self.source)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user