Files
twoJob-demo/scripts/capture_full.py
T
2026-09-03 15:35:23 +08:00

225 lines
8.8 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
整页滚动截图 (scroll & stitch) —— 针对 2号人事部 window 整页滚动 + fixed 顶栏
用法:
python3 capture_full.py <session> <out_path> [max_tiles]
原理:
1. 隐藏帮助/反馈/AI助手/购买浮层等固定挂件, 避免遮挡
2. 读取滚动指标, 探测顶部 fixed 顶栏高度
3. 逐屏滚动截图(视口图), 除首屏外裁掉与固定顶栏重复的部分
4. 纵向拼接为整页长图
依赖: PIL(Pillow), 通过 daemon(127.0.0.1:10086) 调用 evaluate / screenshot
"""
import sys, os, json, time, base64, io, urllib.request
DAEMON = "http://127.0.0.1:10086/command"
_OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
def call(action, args, session):
body = json.dumps({"action": action, "args": args, "session": session}).encode()
req = urllib.request.Request(DAEMON, data=body, headers={"Content-Type": "application/json"})
with _OPENER.open(req, timeout=40) as resp:
return json.loads(resp.read().decode())
def ev(code, session):
r = call("evaluate", {"code": code}, session)
return r.get("data", {}).get("value")
def shot(session):
r = call("screenshot", {"format": "jpeg", "quality": 70}, session)
b64 = r.get("data", {}).get("data")
if not b64:
raise RuntimeError("no screenshot data: " + json.dumps(r)[:200])
from PIL import Image
return Image.open(io.BytesIO(base64.b64decode(b64))).convert("RGB")
HIDE_JS = r"""
(()=>{
const sels = ['[class*="helpBox"]','[class*="aiAssistant"]','[class*="product_"]',
'[class*="feedback"]','[class*="Feedback"]','[class*="floatBar"]',
'[class*="sideBar"]','[class*="robot"]','[class*="advert"]'];
let n=0;
sels.forEach(s=>document.querySelectorAll(s).forEach(el=>{
const r=el.getBoundingClientRect();
if(r.width<600 && getComputedStyle(el).position==='fixed'){el.style.setProperty('display','none','important');n++;}
}));
// 反馈竖条
document.querySelectorAll('div,a,button').forEach(el=>{
if(el.children.length<=3 && /^反\s*馈$/.test((el.innerText||'').replace(/\s/g,'')) && getComputedStyle(el).position==='fixed'){el.style.setProperty('display','none','important');n++;}
});
return n;
})()
"""
METRICS_JS = r"""
(()=>{
let hdr=0;
const walk=(el,d)=>{ if(d>5)return; for(const c of el.children){ const s=getComputedStyle(c); const r=c.getBoundingClientRect();
if((s.position==='fixed'||s.position==='sticky') && r.top<=1 && r.height>10 && r.height<200 && r.width>window.innerWidth*0.6){ if(r.height>hdr)hdr=r.height; }
walk(c,d+1);} };
walk(document.body,0);
return JSON.stringify({winH:window.innerHeight, winW:window.innerWidth,
docH:Math.max(document.documentElement.scrollHeight,document.body.scrollHeight), hdr:Math.round(hdr)});
})()
"""
def scroll_to(y, session):
ev(f"(()=>{{window.scrollTo(0,{y});return window.scrollY;}})()", session)
time.sleep(0.35)
return ev("(()=>window.scrollY)()", session)
# 探测主内容区的内部滚动容器(表格/列表在固定高度面板内自行滚动的情况)
DETECT_SCROLLER_JS = r"""
(()=>{
const W=innerWidth,H=innerHeight; let best=null,score=0;
document.querySelectorAll('div,main,section,ul').forEach(el=>{
const s=getComputedStyle(el); const oy=s.overflowY;
if(oy!=='auto'&&oy!=='scroll') return;
const extra=el.scrollHeight-el.clientHeight; if(extra<40) return;
const r=el.getBoundingClientRect();
if(r.width<W*0.4||r.height<H*0.25) return;
if(r.bottom<H*0.4||r.top>H*0.85) return;
const sc=r.width*r.height+extra*300;
if(sc>score){score=sc;best=el;}
});
if(!best) return JSON.stringify({found:false});
document.querySelectorAll('[data-capscroll]').forEach(e=>e.removeAttribute('data-capscroll'));
best.setAttribute('data-capscroll','1');
const r=best.getBoundingClientRect();
let hs=0;
best.querySelectorAll(':scope>*,:scope>*>*,:scope>*>*>*').forEach(c=>{
const cs=getComputedStyle(c); const cr=c.getBoundingClientRect();
if((cs.position==='sticky'||cs.position==='fixed')&&cr.top<=r.top+3&&cr.height>10&&cr.height<160&&cr.width>r.width*0.5){ if(cr.height>hs)hs=cr.height; }
});
return JSON.stringify({found:true,left:Math.round(r.left),top:Math.round(r.top),
width:Math.round(r.width),height:Math.round(r.height),
scrollHeight:best.scrollHeight,clientHeight:best.clientHeight,hdr:Math.round(hs)});
})()
"""
def scroll_inner(y, session):
return ev("(()=>{const e=document.querySelector('[data-capscroll]');if(!e)return 0;e.scrollTop=%d;return Math.round(e.scrollTop);})()" % int(y), session)
def _inner_stitch(session, sc, scale, max_tiles):
"""对内部滚动容器逐屏滚动 -> 裁剪容器区域 -> 纵向拼接(含粘性表头去重)。"""
from PIL import Image
left, top = sc["left"], sc["top"]
width, height = sc["width"], sc["height"]
hs = sc.get("hdr", 0) or 0
maxScroll = max(0, sc["scrollHeight"] - sc["clientHeight"])
box = (int(round(left * scale)), int(round(top * scale)),
int(round((left + width) * scale)), int(round((top + height) * scale)))
def tile():
return shot(session).crop(box)
scroll_inner(0, session); time.sleep(0.35)
first = tile()
parts = [first]
prev_bottom = height
target = height - hs
guard = 0
while guard < max_tiles:
guard += 1
actualY = int(scroll_inner(target, session) or 0); time.sleep(0.3)
img = tile()
crop_logical = prev_bottom - actualY
if crop_logical < 0:
crop_logical = 0
crop_px = int(round(crop_logical * scale))
if crop_px >= img.height:
crop_px = int(round(hs * scale))
parts.append(img.crop((0, crop_px, img.width, img.height)))
prev_bottom = actualY + height
if actualY >= maxScroll - 1:
break
target = prev_bottom - hs
total_h = sum(p.height for p in parts)
stitched = Image.new("RGB", (first.width, total_h), (255, 255, 255))
y = 0
for p in parts:
stitched.paste(p, (0, y)); y += p.height
scroll_inner(0, session)
return stitched, guard + 1
def capture(session, out_path, max_tiles=14):
from PIL import Image
ev(HIDE_JS, session)
m = json.loads(ev(METRICS_JS, session))
winH, winW, docH = m["winH"], m["winW"], m["docH"]
hdr = m["hdr"] or 0
maxY = max(0, docH - winH)
# 首屏
scroll_to(0, session)
first = shot(session)
scale = first.width / winW # 物理/逻辑
hdr_px = int(round(hdr * scale))
tiles = [(first, 0, first.height)] # (img, cropTop_px, used content bottom logical)
prev_bottom = winH # 已覆盖内容底部(逻辑)
ntiles = 1
mode = "single"
if docH <= winH + 4:
# window 不滚动: 尝试内部滚动容器(表格/列表页)
sc = {}
try:
sc = json.loads(ev(DETECT_SCROLLER_JS, session) or "{}")
except Exception:
sc = {}
if sc.get("found") and (sc["scrollHeight"] - sc["clientHeight"]) > 40:
stitched, ntiles = _inner_stitch(session, sc, scale, max_tiles)
mode = "inner"
else:
stitched = first
else:
parts = [first]
target = winH - hdr
guard = 0
last_y = 0
while guard < max_tiles:
guard += 1
actualY = scroll_to(target, session)
# 页面实际没有继续前进(虚拟网格/内滚容器/滚动被重置): 停止, 避免重复拼接同一屏
if actualY <= last_y + 8:
break
img = shot(session)
crop_logical = prev_bottom - actualY
if crop_logical < 0:
crop_logical = 0
crop_px = int(round(crop_logical * scale))
if crop_px >= img.height:
crop_px = hdr_px
parts.append(img.crop((0, crop_px, img.width, img.height)))
prev_bottom = actualY + winH
last_y = actualY
if actualY >= maxY - 1:
break
target = prev_bottom - hdr
total_h = sum(p.height for p in parts)
stitched = Image.new("RGB", (first.width, total_h), (255, 255, 255))
y = 0
for p in parts:
stitched.paste(p, (0, y)); y += p.height
ntiles = len(parts)
mode = "window" if len(parts) > 1 else "single"
scroll_to(0, session)
os.makedirs(os.path.dirname(out_path), exist_ok=True)
stitched.save(out_path, "JPEG", quality=78)
print(json.dumps({"path": out_path, "size": [stitched.width, stitched.height],
"tiles": ntiles, "mode": mode,
"docH": docH, "winH": winH, "hdr": hdr}, ensure_ascii=False))
if __name__ == "__main__":
_session = sys.argv[1]
_out = sys.argv[2]
_mt = int(sys.argv[3]) if len(sys.argv) > 3 else 14
capture(_session, _out, _mt)