From 5b76a0dcbdfd1207c75a20aeb0a1dbff3910a45e Mon Sep 17 00:00:00 2001 From: MrAkells Date: Thu, 29 May 2025 13:53:25 +0300 Subject: [PATCH] add translation cache and queue --- .gitignore | 3 ++ categories.json | 74 ++++++++++++++++++++++++++++++++++++- check_cache_size.py | 16 ++++++++ translation_cache.py | 40 ++++++++++++++++++++ translation_queue.py | 71 ++++++++++++++++++++++++++++++++++++ translator.py | 28 ++++++++++++-- utils.py | 12 ++++++ web_interface.py | 87 ++++++++++++++++++++++++++++++++++---------- 8 files changed, 307 insertions(+), 24 deletions(-) create mode 100644 check_cache_size.py create mode 100644 translation_cache.py create mode 100644 translation_queue.py create mode 100644 utils.py diff --git a/.gitignore b/.gitignore index 45bc509..b70d6e5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,6 @@ venv/ images/ output/ __pycache__/ + +product_translation_cache.json +translation_queue.json diff --git a/categories.json b/categories.json index b28ef0b..4906ed9 100644 --- a/categories.json +++ b/categories.json @@ -328,5 +328,77 @@ "name": "Корпуси для комп'ютерів", "portal_id": "720", "url": "https://www.euro.com.pl/obudowy-pc.bhtml" + }, + { + "id": "62", + "name": "Материнські плати", + "portal_id": "70702", + "url": "https://www.euro.com.pl/plyty-glowne.bhtml" + }, + { + "id": "63", + "name": "Клавіатури", + "portal_id": "71113", + "url": "https://www.euro.com.pl/klawiatury.bhtml" + }, + { + "id": "64", + "name": "Каструлі", + "portal_id": "15230307", + "url": "https://www.euro.com.pl/garnki.bhtml" + }, + { + "id": "65", + "name": "Столові прибори", + "portal_id": "15230409", + "url": "https://www.euro.com.pl/sztucce.bhtml" + }, + { + "id": "66", + "name": "Кухонне приладдя", + "portal_id": "15230409", + "url": "https://www.euro.com.pl/przybory-kuchenne.bhtml" + }, + { + "id": "67", + "name": "Сковороди", + "portal_id": "15230304", + "url": "https://www.euro.com.pl/patelnie.bhtml" + }, + { + "id": "68", + "name": "Портативні колонки", + "portal_id": "6371101", + "url": "https://www.euro.com.pl/glosniki-przenosne.bhtml" + }, + { + "id": "69", + "name": "Маршрутизатори", + "portal_id": "71902", + "url": "https://www.euro.com.pl/routery.bhtml" + }, + { + "id": "70", + "name": "Побутові сушильні машини для білизни", + "portal_id": "62702", + "url": "https://www.euro.com.pl/suszarki.bhtml" + }, + { + "id": "71", + "name": "Вбудовані посудомийні машини", + "portal_id": "615", + "url": "https://www.euro.com.pl/zmywarki-do-zabudowy.bhtml" + }, + { + "id": "72", + "name": "Форми для випікання", + "portal_id": "15230414", + "url": "https://www.euro.com.pl/formy-i-akcesoria-do-pieczenia.bhtml" + }, + { + "id": "73", + "name": "Сабвуфери", + "portal_id": "518", + "url": "https://www.euro.com.pl/subwoofery.bhtml" } -] +] \ No newline at end of file diff --git a/check_cache_size.py b/check_cache_size.py new file mode 100644 index 0000000..9891d70 --- /dev/null +++ b/check_cache_size.py @@ -0,0 +1,16 @@ +import json + +CACHE_FILE = "product_translation_cache.json" + +def check_cache_size(): + try: + with open(CACHE_FILE, "r", encoding="utf-8") as f: + cache = json.load(f) + print(f"🔎 В кеші збережено {len(cache)} перекладів.") + except FileNotFoundError: + print("❌ Кеш-файл не знайдено.") + except Exception as e: + print(f"⚠️ Помилка читання кешу: {e}") + +if __name__ == "__main__": + check_cache_size() diff --git a/translation_cache.py b/translation_cache.py new file mode 100644 index 0000000..e4646b0 --- /dev/null +++ b/translation_cache.py @@ -0,0 +1,40 @@ +import json +import os +import hashlib + +CACHE_FILE = "product_translation_cache.json" +MAX_CACHE_ENTRIES = 200_000 # максимум записів у кеші + +class TranslationCache: + def __init__(self): + self.cache = {} + self.load_cache() + + def get_hash(self, text: str) -> str: + return hashlib.md5(text.strip().encode("utf-8")).hexdigest() + + def load_cache(self): + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, "r", encoding="utf-8") as f: + self.cache = json.load(f) + except Exception as e: + print(f"[CACHE] Помилка завантаження кешу: {e}") + + def save_cache(self): + # якщо занадто великий кеш — залишаємо тільки останні MAX_CACHE_ENTRIES + if len(self.cache) > MAX_CACHE_ENTRIES: + # зберігаємо найновіші — останні додані (у Python 3.7+ dict зберігає порядок) + trimmed = dict(list(self.cache.items())[-MAX_CACHE_ENTRIES:]) + self.cache = trimmed + + with open(CACHE_FILE, "w", encoding="utf-8") as f: + json.dump(self.cache, f, ensure_ascii=False, indent=2) + + def get(self, text: str) -> str | None: + key = self.get_hash(text) + return self.cache.get(key) + + def add(self, text: str, translated: str): + key = self.get_hash(text) + self.cache[key] = translated diff --git a/translation_queue.py b/translation_queue.py new file mode 100644 index 0000000..0c5f97e --- /dev/null +++ b/translation_queue.py @@ -0,0 +1,71 @@ +import json +import os +from typing import List + +QUEUE_FILE = "translation_queue.json" + + +def load_queue() -> List[str]: + if not os.path.exists(QUEUE_FILE): + return [] + try: + with open(QUEUE_FILE, "r", encoding="utf-8") as f: + data = json.load(f) + return data.get("queue", []) + except Exception: + return [] + + +def save_queue(queue: List[str]): + with open(QUEUE_FILE, "w", encoding="utf-8") as f: + json.dump({"queue": queue}, f, ensure_ascii=False, indent=2) + + +def add_to_queue(filename: str): + queue = load_queue() + if filename not in queue: + queue.append(filename) + save_queue(queue) + + +def remove_from_queue(filename: str): + queue = load_queue() + if filename in queue: + queue.remove(filename) + save_queue(queue) + + +def translate_from_queue(start_translation_func): + from utils import notify_telegram + import threading + import time + + queue = load_queue() + if not queue: + print("[QUEUE] Черга пуста") + return + + filename = queue[0] + try: + print(f"[QUEUE] Спроба перекладу з черги: {filename}") + start_translation_func(filename) + remove_from_queue(filename) + notify_telegram(f"[✅] Файл з черги перекладено: {filename}") + + if load_queue(): + threading.Timer(3, translate_from_queue, args=(start_translation_func,)).start() + + except Exception as e: + error_text = str(e).lower() + if ( + "too many requests" in error_text + or "you are allowed to make" in error_text + or "5 requests per second" in error_text + ): + notify_telegram( + f"[🛑] Знову ліміт при перекладі з черги. Повтор через 30 хвилин.\nФайл: {filename}" + ) + threading.Timer(1800, translate_from_queue, args=(start_translation_func,)).start() + else: + notify_telegram(f"[⚠️] Помилка при перекладі з черги: {filename}\n{str(e)}") + remove_from_queue(filename) diff --git a/translator.py b/translator.py index 9b549b0..39de8a6 100644 --- a/translator.py +++ b/translator.py @@ -1,23 +1,45 @@ from deep_translator import GoogleTranslator from typing import Dict, Any, List import time +from translation_cache import TranslationCache class ProductTranslator: def __init__(self): self.translator = GoogleTranslator(source="pl", target="uk") + self.cache = TranslationCache() # 🧠 кеш ініціалізація def translate_text(self, text: str) -> str: - """Переводит текст с обработкой ошибок и задержкой""" + """Переводит текст с кешем, обработкой ошибок и задержкой""" if not text or not isinstance(text, str): return text + # 🧠 Перевірка кешу + cached = self.cache.get(text) + if cached: + return cached + try: translated = self.translator.translate(text) - time.sleep(0.5) # Задержка чтобы избежать блокировки + time.sleep(0.5) # Затримка щоб уникнути блокування + + if translated and translated != text: + self.cache.add(text, translated) + self.cache.save_cache() # 💾 збереження кешу + return translated + except Exception as e: - print(f"Ошибка перевода: {e}") + error_text = str(e).lower() + print(f"[ERROR] Ошибка перевода: {e}") + + if ( + "too many requests" in error_text + or "you are allowed to make" in error_text + or "5 requests per second" in error_text + ): + raise e + return text def translate_list(self, items: List[str]) -> List[str]: diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..e49a09d --- /dev/null +++ b/utils.py @@ -0,0 +1,12 @@ +import requests + +TELEGRAM_TOKEN = "6452801861:AAHPzu1uvWzAznCVD905z7So-abg3R4wUKU" +TELEGRAM_CHAT_ID = "802473090" # можна отримати через @userinfobot + +def notify_telegram(message: str): + try: + url = f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage" + data = {"chat_id": TELEGRAM_CHAT_ID, "text": message} + requests.post(url, data=data) + except Exception as e: + print(f"[ERROR] Не вдалося надіслати повідомлення в Telegram: {e}") diff --git a/web_interface.py b/web_interface.py index 54801f1..f98bec5 100644 --- a/web_interface.py +++ b/web_interface.py @@ -30,6 +30,9 @@ from urllib.parse import urljoin from apscheduler.schedulers.background import BackgroundScheduler import time from config import BASE_URL +from utils import notify_telegram +import threading +from translation_queue import add_to_queue, translate_from_queue # Добавляем в начало файла login_manager = LoginManager() @@ -457,6 +460,8 @@ def start_translation(filename: str): translation_status["processed_items"] = 0 translation_status["error"] = None + notify_telegram(f"[🚀] Початок перекладу: {filename}") + try: os.makedirs("output/translated", exist_ok=True) @@ -499,16 +504,23 @@ def start_translation(filename: str): ) as f: json.dump(translated_products, f, ensure_ascii=False, indent=2) print(f"[OK] Збережено переклад: {output_filename}") + notify_telegram( + f"[✅] Переклад завершено: {filename} ({len(translated_products)} товарів)" + ) else: print(f"[SKIP] Жодного перекладеного товару: {filename}. Файл не створено.") + notify_telegram(f"[⚠️] Жодного товару не перекладено: {filename}") except Exception as e: translation_status["error"] = str(e) - print(f"Ошибка перевода: {e}") + notify_telegram(f"[❌] Помилка перекладу: {filename}\n{e}") + add_to_queue(filename) + + raise e + finally: translation_status["is_running"] = False active_translations.discard(filename) - print(f"[DONE] Переклад завершено: {filename}") def refresh_all_categories_daily(): @@ -555,31 +567,66 @@ def refresh_all_categories_daily(): def translate_all_parsed_once(): - """Одноразово запускає переклад для всіх ще не перекладених файлів""" - print("[START] Одноразовий переклад всіх категорій...") + """Розумне оновлення перекладу: неперекладені → найстаріші перекладені""" + print("[START] Розумне оновлення перекладу...") parsed_folder = "output" translated_folder = "output/translated" - for filename in os.listdir(parsed_folder): - if filename.endswith("_products.json") and not filename.endswith( - "_translated_products.json" - ): - parsed_path = os.path.join(parsed_folder, filename) - translated_name = filename.replace( - "_products.json", "_translated_products.json" - ) - translated_path = os.path.join(translated_folder, translated_name) + parsed_files = { + f: os.path.getmtime(os.path.join(parsed_folder, f)) + for f in os.listdir(parsed_folder) + if f.endswith("_products.json") and not f.endswith("_translated_products.json") + } - if os.path.exists(translated_path): - print(f"[SKIP] Вже перекладено: {filename}") - continue + translated_files = { + f.replace("_translated_products.json", "_products.json"): os.path.getmtime( + os.path.join(translated_folder, f) + ) + for f in os.listdir(translated_folder) + if f.endswith("_translated_products.json") + } - print(f"[TRANSLATE] Запускаємо переклад: {filename}") - threading.Thread(target=start_translation, args=(filename,)).start() - time.sleep(2) # трохи затримки між потоками, щоб не навантажувати + # Спочатку неперекладені + untranslated = [f for f in parsed_files if f not in translated_files] + # Потім найстаріші з перекладених + outdated_translations = sorted( + (f for f in parsed_files if f in translated_files), + key=lambda f: translated_files[f], + ) - print("[DONE] Запуск перекладу завершено.") + to_translate = untranslated + outdated_translations + + for filename in to_translate: + try: + print(f"[TRANSLATE] {filename}") + start_translation(filename) # ⬅️ БЕЗ ПОТОКУ + time.sleep(1) # щоб трохи розвантажити + except Exception as e: + error_text = str(e).lower() + if ( + "too many requests" in error_text + or "you are allowed to make" in error_text + or "5 requests per second" in error_text + ): + notify_telegram( + f"[🛑] Переклад зупинено після помилки ліміту на файлі: {filename}.\n" + f"Повтор заплановано через 30 хвилин." + ) + add_to_queue(filename) + threading.Timer(1800, translate_from_queue).start() + + break + else: + notify_telegram(f"[⚠️] Помилка перекладу {filename}:\n{str(e)}") + + print("[DONE] translate_all_parsed_once завершено.") + + +@app.route("/translate-from-queue", methods=["POST"]) +def run_translate_from_queue(): + threading.Thread(target=translate_from_queue, args=(start_translation,)).start() + return jsonify({"status": "Запущено переклад з черги"}) @app.route("/manual-translate-all", methods=["POST"])