Files

141 lines
6.6 KiB
Python

"""Снимает и размечает скриншоты веб-интерфейса (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:])))