#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 阶段B Part1: 重走全部叶子页 -> 去遮挡 -> 重拍主截图 -> 安全抓取 4 类更深界面。 安全约束(硬性): 浏览器是用户本人登录的真实 Chrome。深层抓取一律"打开 -> 截图 -> 换页重载", 绝不点击 保存/删除/提交/确定/发送 等会产生真实数据变更的按钮(见 BAD 黑名单)。 为杜绝状态污染: 每抓一个深层界面前都重新导航到干净页面, 只打开一个目标后立即截图, 不做任何"确认"操作; 下一个深层界面靠再次重载复位。 4 类深层界面: add 主操作弹窗(顶部工具栏 新增/添加/新建/创建/录入/发起 表单) detail 行详情/编辑(首个数据行的 详情/查看/编辑) filter 筛选面板(#icon-filter / 文本"筛选") columns 自定义列/表头设置(#icon-custom-columns / 文本 自定义列/列设置/表头设置) menu_* 更多菜单里已发现且安全的配置类入口(page_extras 白名单) 存储: 主截图: 覆盖 features.screenshot_path 深层截图: 同目录 __.jpg, 记录到 page_shots(node_key,full_path,url,kind,label,path,ts) 断点续跑: page_shots 已有该 node_key 的 kind=main 记录则整页跳过(除非 --force)。 用法: python3 rewalk.py [start_index] [--force] """ import os, sys, json, time, sqlite3, urllib.request sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from capture_full import capture, ev, shot, call, HIDE_JS SESSION = "2haohr" BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) DB = os.path.join(BASE, "db", "research.db") LOG = os.path.join(BASE, "db", "rewalk_progress.log") # ---- 操作黑名单: 深层抓取时任何匹配的按钮都绝不点击 ---- BAD = (r"删除|移除|停用|注销|作废|清空|批量|放弃|提交|保存|确定|确认|发送|禁用|冻结|解除|" r"终止|结束|撤销|重置|清除|导入|上传|同步|生成|发放|归档|封存|开除|辞退|下架|驳回|" r"通过|拒绝|办理|恢复|启用|锁定|解锁|导出|下载|离职|开票|付款|转账|扣款|发起流程") # ---- 更多菜单安全白名单: 仅打开明确的配置/查看类界面 ---- MENU_SAFE = r"设置|字段|配置|规则|对比|说明|模板|方案|切换视图|调整排序|自定义列|列设置|表头|视图|统计口径" def dbc(): c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c def log(msg): line = time.strftime("%H:%M:%S ") + msg print(line, flush=True) with open(LOG, "a") as f: f.write(line + "\n") def navigate(url): body = json.dumps({"action": "navigate", "args": {"url": url, "newTab": False}, "session": SESSION}).encode() last = None for attempt in range(4): try: req = urllib.request.Request(DAEMON_URL, data=body, headers={"Content-Type": "application/json"}) with _OPENER.open(req, timeout=45) as resp: return json.loads(resp.read().decode()) except Exception as e: last = e time.sleep(1.5 + attempt * 1.5) raise last DAEMON_URL = "http://127.0.0.1:10086/command" _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({})) def wait_ready(max_wait=9): t0 = time.time() while time.time() - t0 < max_wait: try: if ev("(()=>document.readyState)()", SESSION) == "complete": time.sleep(1.2); return True except Exception: pass time.sleep(0.6) return False # 内容稳定探测: docH(整页高度) + '共N条'数据计数 SETTLE_JS = r"""(()=>{const de=document.documentElement;const m=(document.body.innerText||'').match(/共\s*([\d,]+)\s*条/);return JSON.stringify({docH:de.scrollHeight,cnt:m?m[1].replace(/,/g,''):null});})()""" def settle_content(max_wait=12.0): """截图前等内容稳定: 轮询 docH 直到连续两次不变, 且(若页面有'共N条')数据>0。 列表/报表页的表格数据是异步加载的, 加载完成后 docH 才会增长到真实高度; 若在'共0条/加载中'时截图, 会只截到首屏空表(没截全)。""" t0 = time.time(); last = -1; stable = 0 while time.time() - t0 < max_wait: try: m = json.loads(ev(SETTLE_JS, SESSION) or "{}") except Exception: m = {} h = int(m.get("docH") or 0) cnt = m.get("cnt") data_ok = (cnt is None) or (str(cnt).isdigit() and int(cnt) > 0) stable = stable + 1 if (h == last and h > 0) else 0 last = h if stable >= 2 and data_ok and time.time() - t0 > 1.5: return h time.sleep(0.5) return last # 主截图去遮挡: 点"立即刷新"(安全重载数据) + 跳过新手引导 + DOM 隐藏遮罩/协议层 + 隐藏浮窗 DEOCCLUDE_JS = r""" (()=>{ const vis=el=>{const r=el.getBoundingClientRect();return r.width>0&&r.height>0&&getComputedStyle(el).visibility!=='hidden';}; let acts=[]; const refresh=[...document.querySelectorAll('a,button,span')].find(x=>x.childElementCount===0&&/立即刷新/.test((x.innerText||'').trim())&&vis(x)); if(refresh){refresh.click();acts.push('refresh');} const skip=[...document.querySelectorAll('a,button,span,div')].find(x=>x.childElementCount===0&&/^(跳过|我知道了|知道了|跳过引导|不再提示|下次再说|以后再说)$/.test((x.innerText||'').trim())&&vis(x)); if(skip){skip.click();acts.push('skip');} // DOM 隐藏引导遮罩/协议弹层(纯客户端, 不产生服务端动作) const masksel=['[class*=guide]','[class*=Guide]','[class*=driver]','[class*=intro]','[class*=newbie]','[class*=Newbie]','[class*=agreement]','[class*=Agreement]','[class*=guideMask]','[class*=coach]']; masksel.forEach(s=>document.querySelectorAll(s).forEach(el=>{const r=el.getBoundingClientRect();const p=getComputedStyle(el).position;if((p==='fixed'||p==='absolute')&&r.width>window.innerWidth*0.4&&r.height>window.innerHeight*0.3){el.style.setProperty('display','none','important');acts.push('mask');}})); return JSON.stringify(acts); })() """ def deocclude_main(): """返回是否触发了 refresh(需重新等待)。""" try: acts = json.loads(ev(DEOCCLUDE_JS, SESSION) or "[]") except Exception: acts = [] ev(HIDE_JS, SESSION) return "refresh" in acts def save_shot(out_abs): img = shot(SESSION) os.makedirs(os.path.dirname(out_abs), exist_ok=True) img.save(out_abs, "JPEG", quality=80) return img.size # ---------- 4 类深层触发 (均带 BAD 黑名单过滤, 只打开不确认) ---------- OPEN_ADD = r""" (()=>{ const bad=/%BAD%/; const add=/(新增|添加|新建|创建|录入|发起)/; const cands=[...document.querySelectorAll('button,a')].filter(x=>{ const t=(x.innerText||'').trim(); const r=x.getBoundingClientRect(); return t&&t.length<=8&&add.test(t)&&!bad.test(t)&&r.width>0&&r.height>0&&r.top<340;}); cands.sort((a,b)=>a.getBoundingClientRect().top-b.getBoundingClientRect().top); if(!cands.length) return ''; const t=(cands[0].innerText||'').trim(); cands[0].click(); return t; })() """.replace("%BAD%", BAD) OPEN_DETAIL = r""" (()=>{ const bad=/%BAD%/; const want=/^(详情|查看|编辑|查看详情|详情信息)$/; const els=[...document.querySelectorAll('span,a,button')].filter(x=>{ const t=(x.innerText||'').trim(); const r=x.getBoundingClientRect(); return want.test(t)&&!bad.test(t)&&x.childElementCount<=1&&r.width>0&&r.height>0&&r.top>150&&r.top{const ra=a.getBoundingClientRect(),rb=b.getBoundingClientRect();return ra.top-rb.top||ra.left-rb.left;}); if(!els.length) return ''; const t=(els[0].innerText||'').trim(); els[0].click(); return t; })() """.replace("%BAD%", BAD) OPEN_FILTER = r""" (()=>{ let target=null; document.querySelectorAll('use').forEach(u=>{const h=u.getAttribute('xlink:href')||u.getAttribute('href')||'';if(/#icon-filter|#icon-shaixuan|filter/i.test(h)&&!target){target=u;}}); if(!target){target=[...document.querySelectorAll('button,span,div')].find(x=>x.childElementCount<=2&&/^筛选$/.test((x.innerText||'').trim())&&x.getBoundingClientRect().width>0);} if(!target) return ''; let el=target; for(let i=0;i<4&⪙i++){if(el.tagName==='BUTTON'||el.getAttribute('role')==='button'||/btn|button/i.test(el.className||'')){break;}el=el.parentElement;} (el||target).click(); return 'filter'; })() """ OPEN_COLUMNS = r""" (()=>{ let target=null; document.querySelectorAll('use').forEach(u=>{const h=u.getAttribute('xlink:href')||u.getAttribute('href')||'';if(/#icon-custom-columns|#icon-column|#icon-setting-col|columns/i.test(h)&&!target){target=u;}}); if(!target){target=[...document.querySelectorAll('button,span,div')].find(x=>x.childElementCount<=2&&/^(自定义列|列设置|表头设置|表头字段|字段设置)$/.test((x.innerText||'').trim())&&x.getBoundingClientRect().width>0);} if(!target) return ''; let el=target; for(let i=0;i<4&⪙i++){if(el.tagName==='BUTTON'||el.getAttribute('role')==='button'||/btn|button/i.test(el.className||'')){break;}el=el.parentElement;} (el||target).click(); return 'columns'; })() """ CLICK_MORE = r""" (()=>{ const el=[...document.querySelectorAll('button,a,span,div')].find(x=>x.childElementCount<=2&&/^(更多操作|更多功能|···|\.\.\.|⋯)$/.test((x.innerText||'').replace(/\s/g,''))&&x.getBoundingClientRect().width>0&&x.getBoundingClientRect().top>60&&x.getBoundingClientRect().top<420); if(el){el.click();return 'clicked';}return 'none'; })() """ CLICK_MENU_ITEM = r""" (()=>{ const bad=/%BAD%/; const label=%LABEL%; const el=[...document.querySelectorAll('[class*=dropdown] li,[class*=Dropdown] li,[class*=popper] li,[class*=Popover] li,[role=menu] li,[class*=dropdown] a,[class*=menuItem],li,[role=menuitem]')].find(x=>{ const t=(x.innerText||'').trim(); return t===label&&!bad.test(t)&&x.getBoundingClientRect().width>0;}); if(el){el.click();return 'ok';}return 'miss'; })() """ def modal_visible(): js = ("(()=>{const s=['.el-overlay','.el-dialog__wrapper','.el-drawer','.el-drawer__container'," "'[class*=dialog]','[class*=Dialog]','[class*=drawer]','[class*=Drawer]','[class*=popper]'," "'[class*=Popover]','[role=dialog]'];for(const q of s){for(const el of document.querySelectorAll(q)){" "const r=el.getBoundingClientRect();if(r.width>120&&r.height>120&&getComputedStyle(el).display!=='none')return true;}}return false;})()") try: return bool(ev(js, SESSION)) except Exception: return False # 统计当前可见的"浮层面板"(弹窗/抽屉/筛选抑扇/popover)数量, 用于点击前后对比判定是否弹出新面板 PANEL_COUNT_JS = r""" (()=>{let n=0;const W=innerWidth,H=innerHeight; document.querySelectorAll('div,aside,section,ul').forEach(el=>{ const s=getComputedStyle(el);const r=el.getBoundingClientRect();const z=parseInt(s.zIndex)||0; const floaty=(s.position==='fixed'||s.position==='absolute'); if(floaty&&s.display!=='none'&&r.width>=200&&r.width<=W*0.99&&r.height>=160&&r.top60&& (z>=8||/dialog|drawer|popper|popover|modal|mask|panel|Dialog|Drawer/.test(el.className||''))){n++;} });return n;})() """ def panels(): try: return int(ev(PANEL_COUNT_JS, SESSION) or 0) except Exception: return 0 def fresh_page(url): """导航到干净页面: 重载 + 去遮挡 + (若刷新)再等待。""" navigate(url); wait_ready() if deocclude_main(): wait_ready(); deocclude_main() settle_content() time.sleep(0.3) def cur_url(): try: return ev("(()=>location.href)()", SESSION) or "" except Exception: return "" def url_path(u): return (u or "").split("?")[0].split("#")[0] def deep_capture(url, node_key, full_path, out_base, kind, open_js, label, conn, accept_route=True): """重载到干净页 -> 执行 open_js -> 判定出现(整页跳转表单 或 弹层) -> 截图 -> 记录。绝不做确认。""" rel_dir = os.path.dirname(out_base) base = os.path.splitext(os.path.basename(out_base))[0] fname = "%s__%s.jpg" % (base, kind) rel_path = os.path.join(rel_dir, fname) out_abs = os.path.join(BASE, rel_path) if os.path.exists(out_abs) and os.path.getsize(out_abs) > 3000: return None fresh_page(url) before = url_path(cur_url()) b_panels = panels() clicked = ev(open_js, SESSION) if not clicked: return None time.sleep(1.3) after = url_path(cur_url()) routed = after != before if routed and not accept_route: return None # filter/columns 不应发生跳转: 判为误点, 不记录 if not routed: opened = modal_visible() or panels() > b_panels if not opened: return None # 点了没反馈, 放弃, 不记录 if routed: wait_ready(); time.sleep(0.6); deocclude_main() ev(HIDE_JS, SESSION) # 去掉可能盖在上面的浮窗 try: if routed: capture(SESSION, out_abs, 16) # 整页表单: 滚动拼接 else: save_shot(out_abs) # 弹层: 单屏视口 except Exception as e: log(" shot-fail %s %s: %s" % (kind, clicked, str(e)[:80])) return None conn.execute("INSERT INTO page_shots(node_key,full_path,url,kind,label,path,ts) VALUES(?,?,?,?,?,?,?)", (node_key, full_path, url, kind, clicked if isinstance(clicked, str) else label, rel_path, time.strftime("%Y-%m-%d %H:%M:%S"))) conn.commit() return clicked def process_page(r, conn, force): url = r["url"]; node_key = r["node_key"]; full_path = r["full_path"] rel_main = r["screenshot_path"] out_main = os.path.join(BASE, rel_main) # 断点续跑: 已有 main 记录且未 force 则跳过 if not force: done = conn.execute("SELECT 1 FROM page_shots WHERE node_key=? AND kind='main' LIMIT 1", (node_key,)).fetchone() if done: return "skip" # ---- 主截图: 去遮挡后整页拼接, 覆盖原图 ---- fresh_page(url) try: capture(SESSION, out_main, 16) except Exception as e: log(" main-capture FAIL %s: %s" % (full_path, str(e)[:100])) return "fail" conn.execute("DELETE FROM page_shots WHERE node_key=? AND kind='main'", (node_key,)) conn.execute("INSERT INTO page_shots(node_key,full_path,url,kind,label,path,ts) VALUES(?,?,?,?,?,?,?)", (node_key, full_path, url, "main", "主界面", rel_main, time.strftime("%Y-%m-%d %H:%M:%S"))) conn.commit() tally = ["main"] # ---- 4 类深层 ---- for kind, js, acc in (("filter", OPEN_FILTER, False), ("columns", OPEN_COLUMNS, False), ("add", OPEN_ADD, True), ("detail", OPEN_DETAIL, True)): try: c = deep_capture(url, node_key, full_path, rel_main, kind, js, None, conn, accept_route=acc) if c: tally.append("%s(%s)" % (kind, c if isinstance(c, str) else "")) except Exception as e: log(" deep %s err: %s" % (kind, str(e)[:80])) # ---- 更多菜单安全入口(最多 3 个) ---- import re as _re extras = conn.execute("SELECT label FROM page_extras WHERE node_key=?", (node_key,)).fetchall() safe = [] for e in extras: lab = (e["label"] or "").strip() if lab and _re.search(MENU_SAFE, lab) and not _re.search(BAD, lab) and lab not in safe: safe.append(lab) for lab in safe[:3]: item_js = CLICK_MENU_ITEM.replace("%BAD%", BAD).replace("%LABEL%", json.dumps(lab, ensure_ascii=False)) try: c = deep_capture_menu(url, node_key, full_path, rel_main, lab, item_js, conn) if c: tally.append("menu(%s)" % lab) except Exception as e: log(" menu %s err: %s" % (lab, str(e)[:80])) log(" OK %s [%s]" % (full_path, ", ".join(tally))) return "done" def deep_capture_menu(url, node_key, full_path, out_base, label, item_js, conn): """更多菜单类: 重载 -> 打开更多 -> 点安全项 -> 截图。""" rel_dir = os.path.dirname(out_base) base = os.path.splitext(os.path.basename(out_base))[0] safe_label = label.replace("/", "_").replace(" ", "")[:16] rel_path = os.path.join(rel_dir, "%s__menu_%s.jpg" % (base, safe_label)) out_abs = os.path.join(BASE, rel_path) if os.path.exists(out_abs) and os.path.getsize(out_abs) > 3000: return None fresh_page(url) b_panels = panels() ok = False for attempt in range(3): if ev(CLICK_MORE, SESSION) != "clicked": break time.sleep(1.0 + attempt * 0.6) if ev(item_js, SESSION) == "ok": ok = True; break ev("(()=>{document.body.click();return 1;})()", SESSION) time.sleep(0.4) if not ok: return None time.sleep(1.3) after = url_path(cur_url()) routed = after != url_path(url) if not routed and not (modal_visible() or panels() > b_panels): return None if routed: wait_ready(); time.sleep(0.6); deocclude_main() ev(HIDE_JS, SESSION) try: if routed: capture(SESSION, out_abs, 16) else: save_shot(out_abs) except Exception: return None conn.execute("INSERT INTO page_shots(node_key,full_path,url,kind,label,path,ts) VALUES(?,?,?,?,?,?,?)", (node_key, full_path, url, "menu", label, rel_path, time.strftime("%Y-%m-%d %H:%M:%S"))) conn.commit() return label def main(): args = [a for a in sys.argv[1:] if a != "--force"] force = "--force" in sys.argv start = int(args[0]) if args else 0 conn = dbc() conn.execute("PRAGMA journal_mode=DELETE") rows = [dict(r) for r in conn.execute( "SELECT node_key,name,full_path,url,screenshot_path FROM features " "WHERE level=2 AND url IS NOT NULL AND url<>'' ORDER BY sort_order,id")] rows = sorted(rows, key=lambda r: r["full_path"] or "") total = len(rows) log("=== rewalk start total=%d start=%d force=%s ===" % (total, start, force)) done = skip = fail = 0 for i, r in enumerate(rows): if i < start: continue log("[%d/%d] %s" % (i + 1, total, r["full_path"])) try: st = process_page(r, conn, force) except Exception as e: st = "fail"; log(" PAGE-FAIL %s: %s" % (r["full_path"], str(e)[:120])) if st == "done": done += 1 elif st == "skip": skip += 1 else: fail += 1 conn.execute("PRAGMA journal_mode=DELETE"); conn.commit(); conn.close() log("=== DONE done=%d skip=%d fail=%d total=%d ===" % (done, skip, fail, total)) if __name__ == "__main__": main()