add translation cache and queue

This commit is contained in:
2025-05-29 13:53:25 +03:00
parent bcee61a817
commit 5b76a0dcbd
8 changed files with 307 additions and 24 deletions

3
.gitignore vendored
View File

@@ -2,3 +2,6 @@ venv/
images/ images/
output/ output/
__pycache__/ __pycache__/
product_translation_cache.json
translation_queue.json

View File

@@ -328,5 +328,77 @@
"name": "Корпуси для комп'ютерів", "name": "Корпуси для комп'ютерів",
"portal_id": "720", "portal_id": "720",
"url": "https://www.euro.com.pl/obudowy-pc.bhtml" "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"
} }
] ]

16
check_cache_size.py Normal file
View File

@@ -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()

40
translation_cache.py Normal file
View File

@@ -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

71
translation_queue.py Normal file
View File

@@ -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)

View File

@@ -1,23 +1,45 @@
from deep_translator import GoogleTranslator from deep_translator import GoogleTranslator
from typing import Dict, Any, List from typing import Dict, Any, List
import time import time
from translation_cache import TranslationCache
class ProductTranslator: class ProductTranslator:
def __init__(self): def __init__(self):
self.translator = GoogleTranslator(source="pl", target="uk") self.translator = GoogleTranslator(source="pl", target="uk")
self.cache = TranslationCache() # 🧠 кеш ініціалізація
def translate_text(self, text: str) -> str: def translate_text(self, text: str) -> str:
"""Переводит текст с обработкой ошибок и задержкой""" """Переводит текст с кешем, обработкой ошибок и задержкой"""
if not text or not isinstance(text, str): if not text or not isinstance(text, str):
return text return text
# 🧠 Перевірка кешу
cached = self.cache.get(text)
if cached:
return cached
try: try:
translated = self.translator.translate(text) 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 return translated
except Exception as e: 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 return text
def translate_list(self, items: List[str]) -> List[str]: def translate_list(self, items: List[str]) -> List[str]:

12
utils.py Normal file
View File

@@ -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}")

View File

@@ -30,6 +30,9 @@ from urllib.parse import urljoin
from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.schedulers.background import BackgroundScheduler
import time import time
from config import BASE_URL 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() login_manager = LoginManager()
@@ -457,6 +460,8 @@ def start_translation(filename: str):
translation_status["processed_items"] = 0 translation_status["processed_items"] = 0
translation_status["error"] = None translation_status["error"] = None
notify_telegram(f"[🚀] Початок перекладу: {filename}")
try: try:
os.makedirs("output/translated", exist_ok=True) os.makedirs("output/translated", exist_ok=True)
@@ -499,16 +504,23 @@ def start_translation(filename: str):
) as f: ) as f:
json.dump(translated_products, f, ensure_ascii=False, indent=2) json.dump(translated_products, f, ensure_ascii=False, indent=2)
print(f"[OK] Збережено переклад: {output_filename}") print(f"[OK] Збережено переклад: {output_filename}")
notify_telegram(
f"[✅] Переклад завершено: {filename} ({len(translated_products)} товарів)"
)
else: else:
print(f"[SKIP] Жодного перекладеного товару: {filename}. Файл не створено.") print(f"[SKIP] Жодного перекладеного товару: {filename}. Файл не створено.")
notify_telegram(f"[⚠️] Жодного товару не перекладено: {filename}")
except Exception as e: except Exception as e:
translation_status["error"] = str(e) translation_status["error"] = str(e)
print(f"Ошибка перевода: {e}") notify_telegram(f"[❌] Помилка перекладу: {filename}\n{e}")
add_to_queue(filename)
raise e
finally: finally:
translation_status["is_running"] = False translation_status["is_running"] = False
active_translations.discard(filename) active_translations.discard(filename)
print(f"[DONE] Переклад завершено: {filename}")
def refresh_all_categories_daily(): def refresh_all_categories_daily():
@@ -555,31 +567,66 @@ def refresh_all_categories_daily():
def translate_all_parsed_once(): def translate_all_parsed_once():
"""Одноразово запускає переклад для всіх ще не перекладених файлів""" """Розумне оновлення перекладу: неперекладені → найстаріші перекладені"""
print("[START] Одноразовий переклад всіх категорій...") print("[START] Розумне оновлення перекладу...")
parsed_folder = "output" parsed_folder = "output"
translated_folder = "output/translated" translated_folder = "output/translated"
for filename in os.listdir(parsed_folder): parsed_files = {
if filename.endswith("_products.json") and not filename.endswith( f: os.path.getmtime(os.path.join(parsed_folder, f))
"_translated_products.json" for f in os.listdir(parsed_folder)
): if f.endswith("_products.json") and not f.endswith("_translated_products.json")
parsed_path = os.path.join(parsed_folder, filename) }
translated_name = filename.replace(
"_products.json", "_translated_products.json" translated_files = {
f.replace("_translated_products.json", "_products.json"): os.path.getmtime(
os.path.join(translated_folder, f)
) )
translated_path = os.path.join(translated_folder, translated_name) for f in os.listdir(translated_folder)
if f.endswith("_translated_products.json")
}
if os.path.exists(translated_path): # Спочатку неперекладені
print(f"[SKIP] Вже перекладено: {filename}") untranslated = [f for f in parsed_files if f not in translated_files]
continue # Потім найстаріші з перекладених
outdated_translations = sorted(
(f for f in parsed_files if f in translated_files),
key=lambda f: translated_files[f],
)
print(f"[TRANSLATE] Запускаємо переклад: {filename}") to_translate = untranslated + outdated_translations
threading.Thread(target=start_translation, args=(filename,)).start()
time.sleep(2) # трохи затримки між потоками, щоб не навантажувати
print("[DONE] Запуск перекладу завершено.") 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"]) @app.route("/manual-translate-all", methods=["POST"])