File size: 11,298 Bytes
56af988
 
 
 
 
3d91b9b
56af988
 
 
 
3d91b9b
 
56af988
 
 
 
 
 
 
8f036c1
 
 
56af988
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309

import os, json, time, hashlib, httpx, base64, re, asyncio, threading, shutil, logging
from typing import Dict, Any
from urllib.parse import urlparse

import chromedriver_autoinstaller
from selenium import webdriver
from selenium.webdriver.chrome.options import Options as ChromeOptions
from selenium.webdriver.chrome.service import Service as ChromeService

chromedriver_autoinstaller.install()

LOGGER = logging.getLogger("lens_images_core")
if not LOGGER.handlers:
    logging.basicConfig(
        level=logging.INFO,
        format="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
    )

COOKIE_JSON_URL = os.getenv("COOKIE_JSON_URL")
if not COOKIE_JSON_URL:
    raise RuntimeError("Missing COOKIE_JSON_URL secret. Set it in Space Settings > Secrets.")
UA = "Mozilla/5.0 (Lens OCR Images)"

_COMMON_CHROME_PATHS = [
    # Linux
    "/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser",
    "/snap/bin/chromium",
    "/opt/google/chrome/google-chrome",
    # macOS
    "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
    "/Applications/Chromium.app/Contents/MacOS/Chromium",
    # Windows
    r"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
    r"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
]

def _find_chrome_binary() -> str | None:
    env = os.getenv("CHROME_BINARY")
    if env and shutil.which(env):
        return env
    for p in _COMMON_CHROME_PATHS:
        if os.path.isfile(p) and os.access(p, os.X_OK):
            return p
    try:
        import subprocess, shlex
        out = subprocess.check_output(shlex.split("which google-chrome"), stderr=subprocess.DEVNULL).decode().strip()
        if out:
            return out
    except Exception:
        pass
    return None

def _build_chrome() -> webdriver.Chrome:
    bin_loc = _find_chrome_binary() or "/usr/bin/chromium"
    drv_path = os.getenv("CHROMEDRIVER", "/usr/bin/chromedriver")
    opts = ChromeOptions()
    opts.binary_location = bin_loc
    
    extra = os.getenv(
        "CHROME_EXTRA_ARGS",
        "--disable-gpu --no-sandbox --disable-dev-shm-usage --window-size=1920,1080 --headless=new",
    ).split()
    for a in extra:
        if a:
            opts.add_argument(a)
    service = ChromeService(executable_path=drv_path)
    return webdriver.Chrome(service=service, options=opts)

_cached_cookie_obj: Dict[str, Any] | None = None
_cached_cookie_fetched_at: float = 0.0
_CACHE_TTL = 300
_BROWSER_TTL = 900
_cookie_lock = threading.Lock()

_IDLE_TIMEOUT = int(os.getenv("CHROME_IDLE_SECONDS", "60"))
_driver_lock = threading.Lock()
_global_driver = None
_driver_last_use = 0.0

def _ensure_cookie_driver():
    global _global_driver, _driver_last_use
    with _driver_lock:
        if _global_driver is None:
            LOGGER.info("▶️  starting headless Chrome for cookies")
            _global_driver = _build_chrome()
        _driver_last_use = time.time()
        return _global_driver

def _quit_cookie_driver():
    global _global_driver
    try:
        if _global_driver:
            _global_driver.quit()
    except Exception:
        pass
    finally:
        _global_driver = None

def _driver_reaper_loop():
    global _driver_last_use
    while True:
        try:
            time.sleep(1)
            with _driver_lock:
                if _global_driver and (time.time() - _driver_last_use) > _IDLE_TIMEOUT:
                    LOGGER.info("♻️  quitting idle cookie driver")
                    _quit_cookie_driver()
        except Exception:
            pass

_reaper_started = False
def _ensure_reaper_started():
    global _reaper_started
    if _reaper_started:
        return
    try:
        threading.Thread(target=_driver_reaper_loop, daemon=True).start()
        _reaper_started = True
        LOGGER.debug("cookie driver reaper started")
    except Exception as e:
        LOGGER.warning("could not start cookie driver reaper: %s", e)

def _grab_cookies_with_browser() -> Dict[str, Any]:
    drv = _ensure_cookie_driver()
    with _driver_lock:
        drv.get("https://lens.google.com/")
        jar = {}
        for c in drv.get_cookies():
            dom = c.get("domain") or ""
            if dom.endswith(".google.com") or dom.endswith("google.com"):
                jar[c["name"]] = c["value"]
    return {"cookies": jar, "_source": "browser"}

async def _cookie_header() -> str:
    global _cached_cookie_obj, _cached_cookie_fetched_at
    now = time.time()

    _ensure_reaper_started()

    def extract_obj(obj):
        if isinstance(obj, dict):
            return obj.get("cookies", obj)
        return obj

    with _cookie_lock:
        if _cached_cookie_obj:
            ttl = _BROWSER_TTL if _cached_cookie_obj.get("_source") == "browser" else _CACHE_TTL
            if (now - _cached_cookie_fetched_at) < ttl:
                return "; ".join(f"{k}={v}" for k, v in extract_obj(_cached_cookie_obj).items())

    if COOKIE_JSON_URL:
        try:
            async with httpx.AsyncClient(timeout=5) as cli:
                resp = await cli.get(COOKIE_JSON_URL)
                resp.raise_for_status()
                data = resp.json()
            with _cookie_lock:
                data["_source"] = "remote"
                _cached_cookie_obj, _cached_cookie_fetched_at = data, now
            return "; ".join(f"{k}={v}" for k, v in extract_obj(data).items())
        except Exception as e:
            LOGGER.warning("COOKIE_JSON_URL fetch failed: %s – falling back to headless chrome", e)

    loop = asyncio.get_running_loop()
    data: Dict[str, Any] = await loop.run_in_executor(None, _grab_cookies_with_browser)
    with _cookie_lock:
        _cached_cookie_obj, _cached_cookie_fetched_at = data, now
    return "; ".join(f"{k}={v}" for k, v in extract_obj(data).items())

def _sap_header(cookie_header: str) -> dict:
    origin = "https://lens.google.com"
    sid = None
    for c in cookie_header.split("; "):
        if c.startswith("__Secure-3PAPISID=") or c.startswith("SAPISID="):
            sid = c.split("=", 1)[1]
            break
    if not sid:
        return {}
    ts = int(time.time())
    raw = f"{ts} {sid} {origin}"
    sig = hashlib.sha1(raw.encode()).hexdigest()
    return {
        "X-Origin": origin,
        "X-Goog-AuthUser": "0",
        "Authorization": f"SAPISIDHASH {ts}_{sig}",
    }

def _json_url(loc: str, tl: str) -> str:
    from urllib.parse import urlparse, parse_qs

    q = parse_qs(urlparse(loc).query)
    return (
        "https://lens.google.com/translatedimage?"
        f"vsrid={q.get('vsrid', [None])[0]}&gsessionid={q.get('gsessionid', [None])[0]}"
        f"&sl=auto&tl={tl}&sf=1.07&ib=1"
    )

async def translate_lens(image_url: str, lang: str = "en") -> dict:
    start_ts = time.time()
    debug: Dict[str, Any] = {"steps": [], "errors": []}

    ck = await _cookie_header()
    hdr = {
        "User-Agent": UA,
        "Cookie": ck,
        "Referer": "https://lens.google.com/",
        **_sap_header(ck),
    }

    async with httpx.AsyncClient() as cli:
        try:
            o = urlparse(image_url)
            referer = f"{o.scheme}://{o.netloc}/" if o.scheme and o.netloc else None
            hdr_img = {"User-Agent": UA}
            if referer:
                hdr_img["Referer"] = referer
            img_resp = await cli.get(image_url, headers=hdr_img, timeout=10)
            img_resp.raise_for_status()
            debug["steps"].append(f"fetched original image {image_url} status={img_resp.status_code}")
        except httpx.HTTPStatusError as he:
            code = he.response.status_code if he.response is not None else "NA"
            debug["errors"].append(f"fetch image HTTP {code} {image_url}")
            raise RuntimeError(f"fetch image HTTP {code}")
        except httpx.TimeoutException:
            debug["errors"].append(f"fetch image TIMEOUT {image_url}")
            raise RuntimeError("fetch image TIMEOUT")
        except Exception as e:
            debug["errors"].append(f"fetch image ERROR {type(e).__name__} {image_url}")
            raise RuntimeError(f"fetch image ERROR {type(e).__name__}")

        files = {
            "encoded_image": ("file.jpg", img_resp.content, "image/jpeg"),
            "sbisrc": (None, "browser"),
            "rt": (None, "j"),
        }

        up = await cli.post(
            "https://lens.google.com/v3/upload",
            files=files,
            headers=hdr,
            follow_redirects=False,
            timeout=10,
        )
        debug["steps"].append(f"upload response status={up.status_code}")
        if up.status_code not in (302, 303):
            msg = f"Lens upload failed {up.status_code}"
            debug["errors"].append(msg)
            raise RuntimeError(msg)

        loc = up.headers.get("location", "")
        debug["steps"].append(f"got redirect location: {loc}")

        json_url = _json_url(loc, lang)
        debug["steps"].append(f"constructed json_url: {json_url}")

        js = await cli.get(json_url, headers=hdr, timeout=5)
        raw_body = js.text
        debug["steps"].append("fetched translation JSON")

        body = raw_body.lstrip(")]}'")
        try:
            info = json.loads(body)
        except Exception as e:
            debug["errors"].append(f"JSON parse failure: {e}; raw_body snippet: {body[:200]}")
            raise

        data_url = info.get("imageUrl", "")
        extracted_data_url = ""
        if data_url:
            if data_url.startswith("data:image/"):
                extracted_data_url = data_url
                debug["steps"].append("imageUrl already data URL")
            else:
                try:
                    html = base64.b64decode(data_url).decode("utf-8", errors="ignore")
                    m = re.search(r"data:image/[a-zA-Z]+;base64,[A-Za-z0-9+/=]+", html)
                    if m:
                        extracted_data_url = m.group(0)
                        debug["steps"].append("extracted embedded data:image from base64 HTML")
                    else:
                        debug["steps"].append("no embedded data:image found inside decoded HTML")
                except Exception as e:
                    debug["errors"].append(f"error decoding imageUrl: {e}")

            if not extracted_data_url and (data_url.startswith("http://") or data_url.startswith("https://")):
                try:
                    fallback_img = await cli.get(data_url, headers={"User-Agent": UA}, timeout=5)
                    fallback_img.raise_for_status()
                    b64 = base64.b64encode(fallback_img.content).decode("utf-8")
                    extracted_data_url = f"data:image/jpeg;base64,{b64}"
                    debug["steps"].append("fetched fallback image URL and encoded to data URL")
                except Exception as e:
                    debug["errors"].append(f"fallback fetch of imageUrl failed: {e}")

        translated_text = info.get("translatedTextFull", "") or info.get("translatedText", "")

        duration = time.time() - start_ts
        debug["duration_sec"] = duration

        return {
            "image": extracted_data_url,
            "text": translated_text,
            "loc": loc,
            "json_url": json_url,
            "raw_info": info,
            "debug": debug,
        }