"""Снимает и размечает скриншоты веб-интерфейса (desktop) и мобильной версии /m. Запуск: python tools/guide/shots_web.py [имя_скриншота ...] Нужен запущенный сервер с тестовыми данными (tools/guide/seed_server.py) на http://localhost:8000. """ import asyncio import json import os import sys import urllib.request from pathlib import Path from PIL import Image sys.path.insert(0, str(Path(__file__).parent)) from annotate import annotate, annotate_side # noqa: E402 from cdp import Browser, Page # noqa: E402 from spec import DESKTOP, MOBILE, PASS, Shot # noqa: E402 BASE = os.environ.get("GUIDE_BASE", "http://localhost:8000") OUT = Path(__file__).resolve().parents[2] / "docs" / "guide" / "img" ANDROID_UA = "Mozilla/5.0 (Linux; Android 14; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Mobile Safari/537.36" FIND = """((sel,text,nth)=>{const els=[...document.querySelectorAll(sel)].filter(e=>{const t=(e.innerText||'')+' '+(e.value||'')+' '+(e.placeholder||'');return !text||t.includes(text)}) .filter(e=>{const r=e.getBoundingClientRect();return r.width>0&&r.height>0});const e=els[nth];if(!e)return null;const r=e.getBoundingClientRect();return [r.x,r.y,r.width,r.height]})""" def api_login(user): req = urllib.request.Request(BASE + "/api/v1/auth/login", data=json.dumps({"username": user, "password": PASS.get(user, "demo1234")}).encode(), headers={"Content-Type": "application/json"}) return json.load(urllib.request.urlopen(req)) async def find(page: Page, sel, text, nth): return await page.eval(f"{FIND}({json.dumps(sel)},{json.dumps(text)},{nth})") async def reset(page: Page): await page.goto("about:blank", 0.2) await page.call("Storage.clearDataForOrigin", {"origin": BASE, "storageTypes": "all"}) async def run_steps(page: Page, shot: Shot): mobile = shot.device == "mobile" for st in shot.steps: op = st[0] if op == "reset": await reset(page) await page.goto(BASE + ("/m" if mobile else "/"), 1.5) elif op == "hash": if mobile: await page.eval(f"location.hash={json.dumps(st[1])}") await asyncio.sleep(0.8) else: await page.goto(BASE + "/" + st[1], 1.2) elif op == "js": await page.eval(st[1]); await asyncio.sleep(0.6) elif op == "wait": await asyncio.sleep(st[1]) elif op == "scroll": await page.eval(f"""(()=>{{const els=[...document.querySelectorAll({json.dumps(st[1])})].filter(e=>!{json.dumps(st[2])}||(e.innerText||'').includes({json.dumps(st[2])})); const e=els[{st[3]}]; if(!e) throw new Error('scroll: нет {st[1]}'); e.scrollIntoView({{block:'start'}}); window.scrollBy(0,-84);}})()""") await asyncio.sleep(0.4) elif op == "click": r = await find(page, st[1], st[2], st[3]) if not r: raise RuntimeError(f"click: не найден {st[1]!r} {st[2]!r}") await page.eval(f"""(()=>{{const els=[...document.querySelectorAll({json.dumps(st[1])})].filter(e=>!{json.dumps(st[2])}||(e.innerText||'').includes({json.dumps(st[2])}));els[{st[3]}].click();}})()""") await asyncio.sleep(0.9) elif op == "m_login": await page.goto(BASE + "/m", 1.5) await page.eval(f"""(()=>{{const [u,p]=document.querySelectorAll('input'); u.value={json.dumps(st[1])}; p.value={json.dumps(st[2])}; document.querySelector('button.block').click();}})()""") await asyncio.sleep(4.5) elif op == "offline": await page.call("Network.enable") await page.call("Network.emulateNetworkConditions", {"offline": st[1], "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1}) else: raise ValueError(op) async def run_shot(page: Page, shot: Shot): mobile = shot.device == "mobile" dpr = 2 if mobile else 1 await page.emulate(390 if mobile else 1280, shot.height or (844 if mobile else 860), dpr, mobile, ANDROID_UA if mobile else None) await page.call("Emulation.setEmulatedMedia", {"features": [{"name": "prefers-color-scheme", "value": "light"}]}) await page.call("Network.enable") await page.call("Network.emulateNetworkConditions", {"offline": False, "latency": 0, "downloadThroughput": -1, "uploadThroughput": -1}) first = shot.steps[0][0] if shot.steps else "" if first != "reset" and first != "m_login": await reset(page) if shot.user and not mobile: t = api_login(shot.user) await page.goto(BASE + "/", 0.8) await page.eval(f"localStorage.setItem('token',{json.dumps(t['access_token'])}); localStorage.setItem('refresh',{json.dumps(t['refresh_token'])});") await page.call("Page.reload") await asyncio.sleep(1.2) if mobile and first == "m_login": await reset(page) await run_steps(page, shot) # убрать подсветку фокуса/каретку await page.eval("document.activeElement && document.activeElement.blur && document.activeElement.blur(); window.getSelection().removeAllRanges()") marks = [] for i, m in enumerate(shot.marks, 1): r = await find(page, m.sel, m.text, m.nth) if not r: raise RuntimeError(f"[{shot.name}] метка {i} не найдена: sel={m.sel!r} text={m.text!r} nth={m.nth}") x, y, w, h = r w, h = w + m.grow[0], h + m.grow[1] marks.append((i, (x * dpr, y * dpr, w * dpr, h * dpr), m.dir)) OUT.mkdir(parents=True, exist_ok=True) raw = OUT / f"{shot.name}.raw.png" await page.screenshot(str(raw)) shot_img = Image.open(raw) img = annotate_side(shot_img, marks, margin=110, scale=dpr * 0.9) if mobile else annotate(shot_img, marks, scale=1.0) if shot.crop: x, y, w, h = shot.crop img = img.crop((x, y, x + w, y + h)) img.save(OUT / f"{shot.name}.png", optimize=True) raw.unlink() print("ok", shot.name, img.size, flush=True) async def main(names): b = Browser() b.start() try: page = Page(b.page_ws()) await page.open() shots = [s for s in MOBILE + DESKTOP if not names or s.name in names] # мобильные первыми: им нужны «свежие» наряды for s in shots: try: await run_shot(page, s) except Exception as e: print("FAIL", s.name, e, flush=True) await page.close() finally: b.stop() if __name__ == "__main__": asyncio.run(main(set(sys.argv[1:])))