import json import re import html import urllib.parse from typing import Optional, Dict, Any, List import httpx from app import config from app.database import get_db USER_AGENT = "NotesApp/1.0 (https://notes.qpid.pro; admin@qpid.pro)" _http_client: Optional[httpx.AsyncClient] = None def get_http_client() -> httpx.AsyncClient: """ Returns a shared singleton httpx.AsyncClient instance with connection pooling and Keep-Alive. Reuses persistent TCP/TLS connections to dictionary providers (Wiktionary, MyMemory) eliminating 300-600ms of repeated connection handshakes per word lookup. """ global _http_client if _http_client is None or _http_client.is_closed: limits = httpx.Limits(max_keepalive_connections=10, max_connections=20, keepalive_expiry=60.0) timeout = httpx.Timeout(connect=3.0, read=4.0, write=3.0, pool=3.0) _http_client = httpx.AsyncClient( limits=limits, timeout=timeout, headers={"User-Agent": USER_AGENT}, follow_redirects=True ) return _http_client async def close_http_client(): """ Closes the shared singleton HTTP client on application shutdown. """ global _http_client if _http_client is not None: client = _http_client _http_client = None if not client.is_closed: try: await client.aclose() except Exception: pass async def fetch_wiktionary_wikitext(word: str, client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]: """ Fetches wikitext via Wiktionary modern REST API (fallback to action=parse) and extracts clean IPA phonetics and Russian translations from the English section. """ http = client or get_http_client() words_to_try = [word] if word == word.lower() else [word, word.lower()] for w in words_to_try: wikitext = "" # 1. Try modern Wikimedia REST API (faster, lightweight source) url_rest = f"https://en.wiktionary.org/w/rest.php/v1/page/{urllib.parse.quote(w)}" try: resp = await http.get(url_rest, timeout=4.0) if resp.status_code == 200: data = resp.json() wikitext = data.get("source", "") except Exception: pass # 2. Fallback to legacy action=parse if REST returned empty if not wikitext: url_legacy = "https://en.wiktionary.org/w/api.php" params = { "action": "parse", "page": w, "prop": "wikitext", "format": "json" } try: resp = await http.get(url_legacy, params=params, timeout=4.0) if resp.status_code == 200: data = resp.json() wikitext = data.get("parse", {}).get("wikitext", {}).get("*", "") except Exception: pass if not wikitext: continue # Isolate English section to avoid leakage from other languages on the page en_match = re.search(r"==English==\s*(.*?)(?=\n==[A-Za-z]|\Z)", wikitext, re.DOTALL) section = en_match.group(1) if en_match else wikitext # Extract IPA ipa_raw = re.findall(r"\{\{IPA\|en\|([^}]+)\}\}", section) ipa_cleaned = [] for entry in ipa_raw: for p in entry.split("|"): p = p.strip() if p.startswith("/") and p.endswith("/"): if p not in ipa_cleaned: ipa_cleaned.append(p) elif p.startswith("/"): p_slash = p + "/" if p_slash not in ipa_cleaned: ipa_cleaned.append(p_slash) elif p.startswith("[") and p.endswith("]"): if p not in ipa_cleaned: ipa_cleaned.append(p) # Extract Russian translations ru_raw = re.findall(r"\{\{t[+-]?\|ru\|([^|}]+)", section) ru_cleaned = [] for r in ru_raw: clean = r.replace("[[", "").replace("]]", "").replace("́", "").strip() if clean and clean not in ru_cleaned: ru_cleaned.append(clean) if ipa_cleaned or ru_cleaned: return { "ipa": ipa_cleaned, "audio": [], "ru_translations": ru_cleaned } return {"ipa": [], "audio": [], "ru_translations": []} async def fetch_wiktionary_definitions(word: str, client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]: """ Fetches structured definitions from Wiktionary REST endpoint (https://en.wiktionary.org/api/rest_v1/page/definition/{word}) with HTML unescaping and whitespace normalization. """ http = client or get_http_client() words_to_try = [word] if word == word.lower() else [word, word.lower()] for w in words_to_try: url = f"https://en.wiktionary.org/api/rest_v1/page/definition/{urllib.parse.quote(w)}" try: resp = await http.get(url, timeout=4.0) if resp.status_code == 200: data = resp.json() en_entries = data.get("en", []) if not en_entries: continue parts_of_speech = [] definitions = [] examples = [] for entry in en_entries: pos = entry.get("partOfSpeech") if pos and pos not in parts_of_speech: parts_of_speech.append(pos) for d in entry.get("definitions", []): raw_def = d.get("definition", "") clean_def = html.unescape(re.sub(r"<[^>]+>", "", raw_def)).strip() clean_def = re.sub(r"\s+", " ", clean_def).strip() if clean_def and clean_def not in definitions: definitions.append(clean_def) for ex in d.get("examples", []): clean_ex = html.unescape(re.sub(r"<[^>]+>", "", ex)).strip() clean_ex = re.sub(r"\s+", " ", clean_ex).strip() if clean_ex and clean_ex not in examples: examples.append(clean_ex) return { "parts_of_speech": parts_of_speech, "definitions": definitions[:4], "examples": examples[:3] } except Exception: pass return {"parts_of_speech": [], "definitions": [], "examples": []} async def fetch_mymemory(query: str, client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]: url = "https://api.mymemory.translated.net/get" params = { "q": query, "langpair": "en|ru", "de": config.MYMEMORY_EMAIL } try: http = client or get_http_client() resp = await http.get(url, params=params, timeout=4.0) if resp.status_code == 200: data = resp.json() main_trans = data.get("responseData", {}).get("translatedText", "").strip() matches = data.get("matches", []) alt_list = [] if main_trans: alt_list.append(main_trans) for m in matches: t = m.get("translation", "").strip() if t and t not in alt_list and t.lower() != query.lower(): alt_list.append(t) return { "translation": main_trans, "matches": alt_list[:6] } except Exception: pass return {"translation": "", "matches": []} async def fetch_free_dictionary_api(word: str, client: Optional[httpx.AsyncClient] = None) -> Dict[str, Any]: """Optional tier: api.dictionaryapi.dev with fast timeout.""" url = f"https://api.dictionaryapi.dev/api/v2/entries/en/{urllib.parse.quote(word)}" try: http = client or get_http_client() resp = await http.get(url, timeout=1.5) if resp.status_code == 200: data = resp.json()[0] phonetic = data.get("phonetic") audio_url = None for p in data.get("phonetics", []): if p.get("audio"): audio_url = p.get("audio") break return { "phonetic": phonetic, "audio_url": audio_url } except Exception: pass return {"phonetic": None, "audio_url": None} async def lookup_term(query: str) -> Dict[str, Any]: query_clean = query.strip() if not query_clean: return {} # Check cache first with get_db() as conn: row = conn.execute( "SELECT response_json FROM dictionary_cache WHERE query = ?", (query_clean,) ).fetchone() if row: try: return json.loads(row["response_json"]) except Exception: pass words = query_clean.split() is_sentence = len(words) > 4 result: Dict[str, Any] = { "query": query_clean, "is_sentence": is_sentence, "translation": "", "translations_list": [], "phonetic": "", "audio_file": "", "audio_url": "", "part_of_speech": "", "definitions": [], "examples": [] } # Parallel queries via asyncio import asyncio http_client = get_http_client() if is_sentence: # For long sentences, primary focus is translation mm_data = await fetch_mymemory(query_clean, client=http_client) result["translation"] = mm_data.get("translation", "") result["translations_list"] = mm_data.get("matches", []) else: # For words and short phrases: reuse keep-alive connections across providers tasks = [ fetch_wiktionary_wikitext(query_clean, client=http_client), fetch_wiktionary_definitions(query_clean, client=http_client), fetch_mymemory(query_clean, client=http_client), fetch_free_dictionary_api(query_clean, client=http_client) ] w_wiki, w_defs, mm_data, free_dict = await asyncio.gather(*tasks) # 1. Phonetic IPA if free_dict.get("phonetic"): result["phonetic"] = free_dict["phonetic"] elif w_wiki.get("ipa"): result["phonetic"] = w_wiki["ipa"][0] # 2. Audio URL / File result["audio_file"] = "" result["audio_url"] = free_dict.get("audio_url") or "" # 3. Part of Speech if w_defs.get("parts_of_speech"): result["part_of_speech"] = ", ".join(w_defs["parts_of_speech"][:2]) # 4. Definitions & Examples if w_defs.get("definitions"): result["definitions"] = w_defs["definitions"] if w_defs.get("examples"): result["examples"] = w_defs["examples"] # 5. Translations candidates = [] # Wiktionary curated Russian translations for t in w_wiki.get("ru_translations", []): if t not in candidates: candidates.append(t) # MyMemory translation for t in mm_data.get("matches", []): if t and t not in candidates and t.lower() != query_clean.lower(): candidates.append(t) if candidates: result["translation"] = ", ".join(candidates[:3]) result["translations_list"] = candidates else: result["translation"] = mm_data.get("translation", "") result["translations_list"] = [result["translation"]] if result["translation"] else [] # Alias translation_ru for client compatibility result["translation_ru"] = result["translation"] # Store into SQLite cache with get_db() as conn: conn.execute( "INSERT OR REPLACE INTO dictionary_cache (query, response_json) VALUES (?, ?)", (query_clean, json.dumps(result, ensure_ascii=False)) ) return result