141 lines
6.6 KiB
Python
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:])))
|