feat: 按测测功能重构 H5 首页与各子页,并修正顶栏「+」添加档案菜单
对齐测测交互:首页「+」支持邀请填档案/添加档案/邀请合盘;各 Tab 与工具页按同一视觉与功能标准落地;本地对照截图目录显式忽略。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Executable
+1958
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture CeCe homepage top-right 「+」→ add family/friend archive flow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/Users/jack/Project/digital-psychology")
|
||||
TOOLS = ROOT / "tools" / "android"
|
||||
ANDROID_HOME = Path("/opt/homebrew/share/android-commandlinetools")
|
||||
PKG = "com.xxwolo.cc5"
|
||||
ACT = f"{PKG}/com.cece.app.MainActivity"
|
||||
STAMP = time.strftime("%Y%m%d-%H%M%S")
|
||||
OUT = ROOT / ".tmp" / "cece-validation" / time.strftime("%Y%m%d") / f"home-plus-{STAMP}"
|
||||
NOTES = OUT / "NOTES.md"
|
||||
|
||||
os.environ["ANDROID_HOME"] = str(ANDROID_HOME)
|
||||
os.environ["ANDROID_SDK_ROOT"] = str(ANDROID_HOME)
|
||||
os.environ["PATH"] = (
|
||||
f"{ANDROID_HOME}/emulator:{ANDROID_HOME}/platform-tools:"
|
||||
f"/opt/homebrew/bin:/usr/bin:/bin:" + os.environ.get("PATH", "")
|
||||
)
|
||||
|
||||
|
||||
def sh(args, timeout=120):
|
||||
return subprocess.run(args, capture_output=True, text=True, timeout=timeout)
|
||||
|
||||
|
||||
def adb(*args, timeout=120):
|
||||
return sh(["adb", *args], timeout=timeout)
|
||||
|
||||
|
||||
def tool(*args, timeout=120):
|
||||
r = sh([str(TOOLS), *args, "--json"], timeout=timeout)
|
||||
try:
|
||||
return json.loads(r.stdout or "{}")
|
||||
except Exception:
|
||||
return {"error": r.stderr, "raw": (r.stdout or "")[:500]}
|
||||
|
||||
|
||||
def label(e):
|
||||
return (e.get("text") or e.get("contentDesc") or e.get("contentDescription") or "").strip()
|
||||
|
||||
|
||||
def elements(data):
|
||||
return data.get("elements") or []
|
||||
|
||||
|
||||
def dump():
|
||||
return tool("ui", "dump")
|
||||
|
||||
|
||||
def center(e):
|
||||
c = e.get("center")
|
||||
if isinstance(c, dict):
|
||||
return int(c["x"]), int(c["y"])
|
||||
b = e.get("bounds") or {}
|
||||
if isinstance(b, dict) and "width" in b:
|
||||
return int(b["x"] + b["width"] / 2), int(b["y"] + b["height"] / 2)
|
||||
return None
|
||||
|
||||
|
||||
def area(e):
|
||||
b = e.get("bounds") or {}
|
||||
return int(b.get("width") or 0) * int(b.get("height") or 0)
|
||||
|
||||
|
||||
def tap_xy(x, y, wait=1.6):
|
||||
adb("shell", "input", "tap", str(x), str(y))
|
||||
time.sleep(wait)
|
||||
|
||||
|
||||
def back(n=1):
|
||||
for _ in range(n):
|
||||
adb("shell", "input", "keyevent", "4")
|
||||
time.sleep(1.0)
|
||||
|
||||
|
||||
def shot(rel: str, note: str = ""):
|
||||
path = OUT / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tool("screenshot", "--out", str(path))
|
||||
labs = sorted({label(e).split("\n", 1)[0] for e in elements(dump()) if label(e)})
|
||||
(OUT / rel.replace(".png", ".labels.txt")).write_text("\n".join(labs), encoding="utf-8")
|
||||
with NOTES.open("a", encoding="utf-8") as f:
|
||||
f.write(f"- `{rel}` {note}\n")
|
||||
f.write(f" labels: {', '.join(labs[:40])}\n")
|
||||
print("SHOT", rel, note, flush=True)
|
||||
return path
|
||||
|
||||
|
||||
def save_dump(rel):
|
||||
path = OUT / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
data = dump()
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
return data
|
||||
|
||||
|
||||
def first_lines(data=None):
|
||||
data = data or dump()
|
||||
return [label(e).split("\n", 1)[0] for e in elements(data) if label(e)]
|
||||
|
||||
|
||||
def go_home():
|
||||
adb("shell", "am", "start", "-n", ACT)
|
||||
time.sleep(2.0)
|
||||
# tap 首页 tab
|
||||
tap_xy(108, 2255, wait=1.2)
|
||||
# scroll top
|
||||
for _ in range(2):
|
||||
adb("shell", "input", "swipe", "540", "500", "540", "1600", "280")
|
||||
time.sleep(0.5)
|
||||
|
||||
|
||||
def find_plus_candidates(data):
|
||||
"""Top-right + / 添加 / contentDesc."""
|
||||
hits = []
|
||||
for e in elements(data):
|
||||
lab = label(e)
|
||||
first = lab.split("\n", 1)[0]
|
||||
b = e.get("bounds") or {}
|
||||
y = int(b.get("y") or 0)
|
||||
x = int(b.get("x") or 0)
|
||||
w = int(b.get("width") or 0)
|
||||
h = int(b.get("height") or 0)
|
||||
if y > 280:
|
||||
continue
|
||||
if x < 700:
|
||||
continue
|
||||
# plus-like
|
||||
if first in ("+", "+", "添加", "新建", "添加档案") or "添加" in lab or lab in ("+", "+"):
|
||||
hits.append(e)
|
||||
continue
|
||||
cd = (e.get("contentDesc") or e.get("contentDescription") or "")
|
||||
if any(k in cd for k in ("添加", "新建", "plus", "Plus", "+")):
|
||||
hits.append(e)
|
||||
continue
|
||||
# small square top-right icon without text
|
||||
if e.get("clickable") and not lab and w < 120 and h < 120 and x > 850:
|
||||
hits.append(e)
|
||||
hits.sort(key=lambda e: (area(e), -(center(e) or (0, 0))[0]))
|
||||
return hits
|
||||
|
||||
|
||||
def tap_contains(needle, ymax=9999):
|
||||
data = dump()
|
||||
cands = []
|
||||
for e in elements(data):
|
||||
lab = label(e)
|
||||
if needle not in lab:
|
||||
continue
|
||||
b = e.get("bounds") or {}
|
||||
if int(b.get("y") or 0) > ymax:
|
||||
continue
|
||||
if not e.get("clickable") and area(e) > 200 * 200:
|
||||
continue
|
||||
cands.append(e)
|
||||
cands.sort(key=area)
|
||||
if not cands:
|
||||
return False
|
||||
xy = center(cands[0])
|
||||
if not xy:
|
||||
return False
|
||||
print(f" TAP contains {needle!r} @ {xy}", flush=True)
|
||||
tap_xy(*xy)
|
||||
return True
|
||||
|
||||
|
||||
def explore_add_flow():
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
NOTES.write_text(f"# home + archive capture {STAMP}\n\n", encoding="utf-8")
|
||||
|
||||
if not re.search(r"emulator-\d+\s+device", adb("devices").stdout or ""):
|
||||
raise SystemExit("no emulator")
|
||||
|
||||
go_home()
|
||||
shot("00-home.png", "home before +")
|
||||
data = save_dump("00-home.json")
|
||||
|
||||
plus = find_plus_candidates(data)
|
||||
print("plus candidates:", len(plus), flush=True)
|
||||
for e in plus[:8]:
|
||||
print(" ", label(e) or "(empty)", center(e), e.get("bounds"), flush=True)
|
||||
|
||||
opened = False
|
||||
if plus:
|
||||
xy = center(plus[0])
|
||||
if xy:
|
||||
print(f"tap plus candidate {xy}", flush=True)
|
||||
tap_xy(*xy, wait=2.0)
|
||||
opened = True
|
||||
if not opened:
|
||||
# fallback top-right +
|
||||
for xy in ((1008, 168), (990, 155), (1020, 190), (960, 170)):
|
||||
print(f"fallback tap {xy}", flush=True)
|
||||
tap_xy(*xy, wait=1.8)
|
||||
fls = first_lines()
|
||||
if any(
|
||||
k in " ".join(fls)
|
||||
for k in ("添加档案", "新建档案", "家人", "朋友", "伴侣", "关系", "昵称", "生日", "档案")
|
||||
):
|
||||
opened = True
|
||||
break
|
||||
back(1)
|
||||
time.sleep(0.6)
|
||||
|
||||
shot("01-after-plus.png", "after tapping +")
|
||||
data = save_dump("01-after-plus.json")
|
||||
fls = first_lines(data)
|
||||
print("after-plus labels:", fls[:30], flush=True)
|
||||
|
||||
# Dive relation type chips if present
|
||||
for lab in ("家人", "朋友", "伴侣", "同事", "其他", "添加档案", "新建档案", "创建档案", "立即添加"):
|
||||
if lab in fls or any(lab in x for x in fls):
|
||||
if tap_contains(lab, ymax=1800):
|
||||
time.sleep(1.2)
|
||||
shot(f"02-tap-{lab}.png", f"tap {lab}")
|
||||
save_dump(f"02-tap-{lab}.json")
|
||||
|
||||
# Try fill-ish fields visibility
|
||||
shot("03-form.png", "add archive form state")
|
||||
save_dump("03-form.json")
|
||||
|
||||
# Look for 关系 / 生日 / 保存
|
||||
for lab in ("保存", "完成", "确定", "下一步", "生成"):
|
||||
if tap_contains(lab, ymax=2200):
|
||||
time.sleep(1.2)
|
||||
shot(f"04-{lab}.png", f"tap {lab}")
|
||||
break
|
||||
|
||||
# Summary of labels for NOTES
|
||||
final = first_lines()
|
||||
with NOTES.open("a", encoding="utf-8") as f:
|
||||
f.write("\n## Summary labels after +\n")
|
||||
f.write("\n".join(f"- {x}" for x in final[:80]))
|
||||
f.write("\n")
|
||||
print("DONE", OUT, flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
explore_add_flow()
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/bin/bash
|
||||
# Watchdog: keep deep crawl alive; restart on death.
|
||||
set -u
|
||||
ROOT="/Users/jack/Project/digital-psychology"
|
||||
LOG="/tmp/cece-capture-deep-full.log"
|
||||
PIDFILE="/tmp/cece-capture-deep.pid"
|
||||
MAX_RESTARTS=30
|
||||
cd "$ROOT"
|
||||
|
||||
restart=0
|
||||
while [ "$restart" -lt "$MAX_RESTARTS" ]; do
|
||||
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
sleep 20
|
||||
continue
|
||||
fi
|
||||
restart=$((restart + 1))
|
||||
echo "[$(date '+%F %T')] watchdog start #$restart" >> "$LOG"
|
||||
adb shell am start -n com.xxwolo.cc5/com.cece.app.MainActivity >/dev/null 2>&1 || true
|
||||
sleep 2
|
||||
nohup python3 -u tools/cece_capture_deep.py >> "$LOG" 2>&1 &
|
||||
echo $! > "$PIDFILE"
|
||||
echo "[$(date '+%F %T')] pid=$(cat "$PIDFILE")" >> "$LOG"
|
||||
# wait until death
|
||||
while kill -0 "$(cat "$PIDFILE")" 2>/dev/null; do
|
||||
sleep 15
|
||||
# stall detection: no log growth 3 min
|
||||
if [ -f "$LOG" ]; then
|
||||
sz1=$(wc -c < "$LOG")
|
||||
sleep 90
|
||||
sz2=$(wc -c < "$LOG")
|
||||
if [ "$sz1" -eq "$sz2" ] && kill -0 "$(cat "$PIDFILE")" 2>/dev/null; then
|
||||
echo "[$(date '+%F %T')] stall → kill $(cat "$PIDFILE")" >> "$LOG"
|
||||
kill "$(cat "$PIDFILE")" 2>/dev/null || true
|
||||
sleep 2
|
||||
kill -9 "$(cat "$PIDFILE")" 2>/dev/null || true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done
|
||||
echo "[$(date '+%F %T')] crawl exited, restart soon" >> "$LOG"
|
||||
sleep 5
|
||||
done
|
||||
echo "[$(date '+%F %T')] watchdog gave up after $MAX_RESTARTS" >> "$LOG"
|
||||
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Daemonize + run cece_capture_deep outside the parent process group.
|
||||
|
||||
Cursor/agent shells often SIGKILL the whole process group when a command ends;
|
||||
nohup alone is not enough. Double-fork so the crawl survives.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import faulthandler
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path("/Users/jack/Project/digital-psychology")
|
||||
LOG = Path("/tmp/cece-capture-deep-full.log")
|
||||
FAULT = Path("/tmp/cece-fault.log")
|
||||
PIDFILE = Path("/tmp/cece-capture-deep.pid")
|
||||
|
||||
|
||||
def log(msg: str) -> None:
|
||||
line = f"[{time.strftime('%F %T')}] {msg}\n"
|
||||
with LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
|
||||
|
||||
def daemonize() -> None:
|
||||
# first fork
|
||||
if os.fork() > 0:
|
||||
sys.exit(0)
|
||||
os.setsid()
|
||||
# second fork
|
||||
if os.fork() > 0:
|
||||
sys.exit(0)
|
||||
# detach stdio
|
||||
sys.stdin.close()
|
||||
sys.stdout.flush()
|
||||
sys.stderr.flush()
|
||||
out = open(LOG, "a", encoding="utf-8", buffering=1)
|
||||
sys.stdout = out # type: ignore
|
||||
sys.stderr = out # type: ignore
|
||||
|
||||
|
||||
def main() -> None:
|
||||
os.chdir(ROOT)
|
||||
daemonize()
|
||||
PIDFILE.write_text(str(os.getpid()), encoding="utf-8")
|
||||
ff = FAULT.open("a", encoding="utf-8")
|
||||
faulthandler.enable(file=ff, all_threads=True)
|
||||
log(f"daemon start pid={os.getpid()} argv={sys.argv[1:]}")
|
||||
|
||||
def _atexit() -> None:
|
||||
log(f"daemon atexit pid={os.getpid()}")
|
||||
|
||||
atexit.register(_atexit)
|
||||
# remaining args after this script name
|
||||
args = sys.argv[1:]
|
||||
sys.argv = ["cece_capture_deep.py", *args]
|
||||
try:
|
||||
runpy.run_path(str(ROOT / "tools" / "cece_capture_deep.py"), run_name="__main__")
|
||||
log("daemon finished ok")
|
||||
except BaseException as ex:
|
||||
log(f"daemon error: {type(ex).__name__}: {ex}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stable launcher for cece_capture_deep — logs exit code, avoids silent death."""
|
||||
from __future__ import annotations
|
||||
|
||||
import atexit
|
||||
import faulthandler
|
||||
import os
|
||||
import runpy
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
LOG = Path("/tmp/cece-capture-deep-full.log")
|
||||
FAULT = Path("/tmp/cece-fault.log")
|
||||
ROOT = Path("/Users/jack/Project/digital-psychology")
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
line = f"[{time.strftime('%F %T')}] {msg}\n"
|
||||
with LOG.open("a", encoding="utf-8") as f:
|
||||
f.write(line)
|
||||
sys.stdout.write(line)
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
os.chdir(ROOT)
|
||||
FAULT.parent.mkdir(parents=True, exist_ok=True)
|
||||
ff = FAULT.open("a", encoding="utf-8")
|
||||
faulthandler.enable(file=ff, all_threads=True)
|
||||
_log(f"launcher start pid={os.getpid()} argv={sys.argv[1:]}")
|
||||
|
||||
def _atexit() -> None:
|
||||
_log(f"atexit pid={os.getpid()}")
|
||||
|
||||
atexit.register(_atexit)
|
||||
sys.argv = ["cece_capture_deep.py", *sys.argv[1:]]
|
||||
try:
|
||||
runpy.run_path(str(ROOT / "tools" / "cece_capture_deep.py"), run_name="__main__")
|
||||
_log("launcher normal return")
|
||||
return 0
|
||||
except BaseException as ex:
|
||||
_log(f"launcher exception: {type(ex).__name__}: {ex}")
|
||||
import traceback
|
||||
|
||||
with LOG.open("a", encoding="utf-8") as f:
|
||||
traceback.print_exc(file=f)
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user