#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ 报表/文档导出下载器(Excel/Word 为主)。完整链路(已逐环节实测验证): 入口:svg use[xlink:href=#icon-download] 图标(无文字,代码级定位)+ 文字“下载/生成excel”兜底; 生成:点图标 -> 弹“下载报表/导出”参数窗 -> 点“确定”(默认参数) -> 异步生成; 捕获:hook HTMLAnchorElement.prototype.click 捕获 rpt-cdn/prv-cdn 带 token 直链; 落盘:页面内同步 XHR 取字节 -> base64 回传 -> 写 workspace(绕过 macOS ~/Downloads 的 TCC 限制)。 阶段2:任务中心(taskCenter)收割兜底。产出真实 .xlsx/.docx,写 SQLite downloads 表; 页面间随机 5-15s;断点续跑(同名>2000字节跳过)。 """ import json, urllib.request, base64, time, os, random, sqlite3, re, collections D = "http://127.0.0.1:10086/command"; S = "2haohr" DB = "db/research.db"; LOG = "db/dl_export.log" KW = ['报表', '分析', '仪表盘', '统计', '档案', '模板', '文档', '制度', '公告', '证明'] TXT_BTN = ['直接下载', '生成excel', '导出Excel', '导出报表', '批量导出'] # 文字入口(不含裸“下载/导出”避免误点导航) def call(a, args, timeout=120): b = json.dumps({"action": a, "args": args, "session": S}).encode() r = urllib.request.Request(D, data=b, headers={"Content-Type": "application/json"}) return json.loads(urllib.request.urlopen(r, timeout=timeout).read().decode()) def ev(code, timeout=120): try: return call("evaluate", {"code": code}, timeout).get("data", {}).get("value") except Exception as e: return "EXC:" + str(e)[:80] def log(m): line = time.strftime("%H:%M:%S ") + m print(line, flush=True) with open(LOG, "a") as f: f.write(line + "\n") HOOK = r"""(()=>{window.__cap=window.__cap||[]; if(!HTMLAnchorElement.prototype.__hk){const ac=HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click=function(){try{const h=this.href||''; if(h&&/-cdn\.2haohr|prv-cdn|rpt-cdn|attname=|\.xlsx|\.xls|\.docx|\.doc|\.zip/i.test(h))window.__cap.push(h);}catch(e){} return ac.apply(this,arguments);};HTMLAnchorElement.prototype.__hk=1;} return 'ok';})()""" # 未处理的 #icon-download 数量 COUNT_ICON = r"""(()=>{let n=0;document.querySelectorAll('use').forEach(u=>{ const h=u.getAttribute('xlink:href')||u.getAttribute('href'); if(h==='#icon-download'){const b=u.closest('button,a,[role=button]');if(b&&!b.hasAttribute('data-dl'))n++;}});return n;})()""" # 点击下一个未处理的 #icon-download CLICK_ICON = r"""(()=>{const us=[...document.querySelectorAll('use')].filter(u=>(u.getAttribute('xlink:href')||u.getAttribute('href'))==='#icon-download'); for(const u of us){const b=u.closest('button,a,[role=button]');if(b&&!b.hasAttribute('data-dl')){b.setAttribute('data-dl','1');b.click();return 'ok';}} return 'none';})()""" # 文字入口 COUNT_TXT = (r"""(()=>{const T=%s;let n=0;document.querySelectorAll('button,a,[role=button]').forEach(x=>{""" r"""const t=(x.innerText||'').trim();if(T.includes(t)&&x.getBoundingClientRect().width>0&&!x.hasAttribute('data-dl'))n++;});return n;})()""") % json.dumps(TXT_BTN) CLICK_TXT = (r"""(()=>{const T=%s;for(const t of T){const b=[...document.querySelectorAll('button,a,[role=button]')]""" r""".find(x=>(x.innerText||'').trim()===t&&x.getBoundingClientRect().width>0&&!x.hasAttribute('data-dl'));""" r"""if(b){b.setAttribute('data-dl','1');b.click();return t;}}return 'none';})()""") % json.dumps(TXT_BTN) # 参数窗“确定/下载/导出”——关键词精确定位“下载报表/导出”参数窗(避免误点其它弹层) KWWIN = r"下载报表|导出|下载|部门|员工排序|工作性质|字段|范围|格式|参数|排序|在职" CONFIRM = (r"""(()=>{const cand=['确定','确认','下载','导出','生成excel','生成','立即导出','立即下载']; const KW=/%s/; const ms=[...document.querySelectorAll('[class*=modal],[class*=Modal],[role=dialog],[class*=dialog],[class*=drawer],[class*=Drawer]')]; const vis=ms.filter(m=>{const r=m.getBoundingClientRect();return r.width>200&&r.height>80&&getComputedStyle(m).display!=='none'&&getComputedStyle(m).visibility!=='hidden';}); // 优先带关键词的参数窗, 再退化到任意可见弹层 const ranked=[...vis.filter(m=>KW.test(m.innerText||'')),...vis]; for(const m of ranked){ for(const t of cand){const b=[...m.querySelectorAll('button,a')].find(x=>(x.innerText||'').trim()===t&&x.getBoundingClientRect().width>0); if(b){b.click();return 'modal:'+t;}}} return vis.length?'modal:noconfirm':'nomodal';})()""") % KWWIN CLOSE = r"""(()=>{const ms=[...document.querySelectorAll('[class*=modal],[class*=Modal],[role=dialog],[class*=dialog],[class*=drawer]')]; for(const m of ms){const r=m.getBoundingClientRect();if(r.width>200&&r.height>80&&getComputedStyle(m).display!=='none'){ const c=[...m.querySelectorAll('button,a,[class*=close],[class*=Close]')] .find(x=>{const t=(x.innerText||'').trim();return t==='取消'||t==='关闭'||(x.className+'').toLowerCase().includes('close');}); if(c){c.click();return 'closed';}}}return 'none';})()""" # 数据授权/协议窗:只点“知情并同意”,绝不点“不同意并退出登录”(之前登出的元凶) AGREE = r"""(()=>{const bad=/退出登录|不同意/;const ok=['知情并同意','同意并继续','我已阅读并同意','同意']; const ms=[...document.querySelectorAll('[class*=modal],[class*=Modal],[role=dialog],[class*=dialog],[class*=drawer],[class*=Drawer]')] .filter(m=>{const r=m.getBoundingClientRect();return r.width>200&&r.height>60&&getComputedStyle(m).display!=='none';}); for(const m of ms){const txt=m.innerText||'';if(!/协议|授权|同意|条款|隐私/.test(txt))continue; for(const t of ok){const b=[...m.querySelectorAll('button,a')].find(x=>(x.innerText||'').trim()===t&&!bad.test(x.innerText||'')); if(b){b.click();return 'agree:'+t;}}} return 'none';})()""" BAD = r"退出登录|不同意|立即购买|预约演示|我要更多功能" # 类型B 数据列表导出入口: #icon-export 或 主内容区文字“导出”(排除导航/任务中心/黑名单) CLICK_EXPORT = (r"""(()=>{const BAD=/%s/; const inNav=e=>!!e.closest('nav,[class*=navBar],[class*=sideMenu],[class*=taskCenter]'); const us=[...document.querySelectorAll('use')].filter(u=>(u.getAttribute('xlink:href')||u.getAttribute('href'))==='#icon-export'); for(const u of us){const b=u.closest('button,a,[role=button]');if(b&&!b.hasAttribute('data-dl')&&!inNav(b)){b.setAttribute('data-dl','1');b.click();return 'icon-export';}} const b=[...document.querySelectorAll('button,a,[role=button]')].find(x=>{const t=(x.innerText||'').trim();return t==='导出'&&x.getBoundingClientRect().width>0&&!x.hasAttribute('data-dl')&&!inNav(x)&&!BAD.test(t);}); if(b){b.setAttribute('data-dl','1');b.click();return 'txt-导出';} return 'none';})()""") % BAD # 类型A 报表中心卡片导出: 生成excel/直接下载/下载(排除导航/任务中心/黑名单) CARD_BTN = ['生成excel', '直接下载', '导出Excel', '导出报表', '批量导出', '下载'] CLICK_CARD = (r"""(()=>{const T=%s;const BAD=/%s/; const inNav=e=>!!e.closest('nav,[class*=navBar],[class*=sideMenu],[class*=taskCenter]'); for(const t of T){const b=[...document.querySelectorAll('button,a,[role=button]')].find(x=>(x.innerText||'').trim()===t&&x.getBoundingClientRect().width>0&&!x.hasAttribute('data-dl')&&!inNav(x)&&!BAD.test(x.innerText||'')); if(b){b.setAttribute('data-dl','1');b.click();return t;}} return 'none';})()""") % (json.dumps(CARD_BTN), BAD) FETCH_LAST = r"""(()=>{try{if(!window.__cap||!window.__cap.length)return 'NO_HREF'; const u=window.__cap[window.__cap.length-1];const x=new XMLHttpRequest();x.open('GET',u,false); x.overrideMimeType('text/plain;charset=x-user-defined');x.send();if(x.status!==200)return 'HTTP'+x.status; const s=x.responseText;let out='';const CH=8192;for(let i=0;i{const tc=document.querySelector('[class*=taskCenter]');if(!tc)return '[]'; const dls=[...tc.querySelectorAll('a')].filter(a=>(a.innerText||'').trim()==='下载'); const names=dls.map(a=>{const row=a.closest('[class*=item],li,div');const info=row&&row.querySelector('[class*=info_]'); return info?(info.innerText||'').replace(/\n+/g,' ').trim().slice(0,50):'';}); return JSON.stringify(names);})()""" TC_CLICK = r"""(function(i){const tc=document.querySelector('[class*=taskCenter]');if(!tc)return 'NO_TC'; const dls=[...tc.querySelectorAll('a')].filter(a=>(a.innerText||'').trim()==='下载'); if(i>=dls.length)return 'OOB';dls[i].click();return 'ok';})(%d)""" def db(): c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c def ensure(): c = db(); c.execute("""CREATE TABLE IF NOT EXISTS downloads( id INTEGER PRIMARY KEY AUTOINCREMENT, node_key TEXT, full_path TEXT, url TEXT, kind TEXT, file_path TEXT, status TEXT, size INTEGER, note TEXT, ts TEXT)"""); c.commit(); c.close() def rec(nk, fp, url, kind, path, status, size, note): c = db(); c.execute("INSERT INTO downloads(node_key,full_path,url,kind,file_path,status,size,note,ts) VALUES(?,?,?,?,?,?,?,?,?)", (nk, fp, url, kind, path, status, size, note, time.strftime("%Y-%m-%d %H:%M:%S"))); c.commit(); c.close() def safe(s): return re.sub(r'[\\/:*?"<>|]', '_', (s or "").strip())[:90] def out_dir(shot, full_path): if shot and shot.startswith("screenshots/"): return os.path.dirname(shot.replace("screenshots/", "downloads/", 1)) parts = [safe(p) for p in (full_path or "").split(" / ") if p.strip()] return os.path.join("downloads", *parts[:-1]) if len(parts) > 1 else "downloads/misc" def load_pages(): c = db(); rows = [dict(r) for r in c.execute( "SELECT node_key,parent_key,name,full_path,url,screenshot_path FROM features").fetchall()] kids = collections.defaultdict(int) for r in rows: if r["parent_key"]: kids[r["parent_key"]] += 1 c.close() leaves = [r for r in rows if kids.get(r["node_key"], 0) == 0] return [r for r in leaves if r["url"] and any(k in (r["full_path"] or "") + (r["name"] or "") for k in KW)] SAVED = set() def grab_and_save(odir, nk, fp, url, note): meta = ev(FETCH_LAST) if not (isinstance(meta, str) and meta.startswith("{")): return False, str(meta)[:40] info = json.loads(meta); name = safe(info["name"]); ev("window.__cap=[]") if name in SAVED: return False, "dup" try: data = base64.b64decode(ev("window.__b64||''")) except Exception: data = b"" if len(data) <= 200: return False, "empty" os.makedirs(odir, exist_ok=True); dst = os.path.join(odir, name) if os.path.exists(dst) and os.path.getsize(dst) > 2000: SAVED.add(name); return True, name + "(exists)" open(dst, "wb").write(data); SAVED.add(name) low = name.lower() kind = "xlsx" if low.endswith((".xlsx", ".xls")) else ("docx" if low.endswith((".docx", ".doc")) else ( "pdf" if low.endswith(".pdf") else ("zip" if low.endswith(".zip") else "file"))) rec(nk, fp, url, kind, dst, "ok", len(data), note) log(" OK %s (%d bytes) -> %s" % (kind, len(data), dst)) return True, name def wait_capture(seconds=25): t0 = time.time() while time.time() - t0 < seconds: time.sleep(2) n = ev("(window.__cap&&window.__cap.length)||0") if isinstance(n, int) and n > 0: return True return False def human_delay(kind="page"): """非均匀拟人间隔:大部分短、少量中、偶尔长停顿 + 高斯抖动。""" r = random.random() if kind == "page": if r < 0.60: d = random.uniform(4.0, 9.0) elif r < 0.88: d = random.uniform(9.0, 20.0) else: d = random.uniform(20.0, 42.0) else: # 同页内相邻点击 if r < 0.68: d = random.uniform(1.4, 4.0) elif r < 0.93: d = random.uniform(4.0, 9.0) else: d = random.uniform(9.0, 16.0) d = max(0.8, d + random.gauss(0, 0.5)) time.sleep(d) return d def logged_out(): href = ev("location.href") return isinstance(href, str) and "/login" in href def done_nodes(): c = db() rows = c.execute("SELECT DISTINCT node_key FROM downloads WHERE status='ok' AND node_key<>''").fetchall() c.close() return {r[0] for r in rows} def do_click_cycle(clicked_label, odir, nk, fp, url): """点击入口后:持续处理授权窗/参数窗(可能异步慢加载)并确认, 同时检测直链; 兑底走任务中心。""" ev("window.__cap=[]") got = False # 最多 ~30s: 参数窗可能异步加载, 持续 AGREE+CONFIRM 并检测直链 for _ in range(20): time.sleep(1.5) ev(AGREE) ev(CONFIRM) n = ev("(window.__cap&&window.__cap.length)||0") if isinstance(n, int) and n > 0: got = True break if got: ok, name = grab_and_save(odir, nk, fp, url, "阶段1 " + clicked_label) if not ok: log(" [%s] 抓取失败: %s" % (clicked_label, name)) result = ok else: log(" [%s] 未产出直链(需必选参数/异步未完成, 待任务中心收割)" % clicked_label) rec(nk, fp, url, "pending", "", "skip", 0, clicked_label) result = False ev(CLOSE) return result def process_page(p, idx, total): fp, url, nk = p["full_path"], p["url"], p["node_key"] odir = out_dir(p["screenshot_path"], fp) log("[%d/%d] %s" % (idx, total, fp)) call("navigate", {"url": url, "newTab": False}) # 等页面加载 + 登录态检测 for k in range(6): time.sleep(2) if k == 1 and logged_out(): log(" !! 检测到登录态失效, 中止本轮") return -1 if logged_out(): log(" !! 检测到登录态失效, 中止本轮") return -1 ev(HOOK) ev(AGREE) # 若有数据授权协议窗, 先“知情并同意” saved = 0 hit = False # 四类入口: 图标下载/文字/类型B导出/类型A报表卡片 for label, code in (("icon-download", CLICK_ICON), ("txt", CLICK_TXT), ("export", CLICK_EXPORT), ("card", CLICK_CARD)): for _ in range(8): c = ev(code) if c == "none": break hit = True if do_click_cycle(label + "=" + str(c), odir, nk, fp, url): saved += 1 human_delay("click") if not hit: log(" 无导出入口, skip") rec(nk, fp, url, "none", "", "skip", 0, "无导出入口") else: log(" 本页触发导出入口, 落盘=%d (其余走任务中心)" % saved) return saved def harvest_task_center(): log("=== 阶段2: 收割任务中心 ===") ev(HOOK) odir = "downloads/_任务中心"; total = 0 try: arr = json.loads(ev(TC_LIST)) except Exception: arr = [] log("任务中心可下载任务: %d" % len(arr)) for i, nm in enumerate(arr): ev("window.__cap=[]") if ev(TC_CLICK % i) != "ok": continue if wait_capture(20): ok, _ = grab_and_save(odir, "", "任务中心 / " + (nm or ""), "", "阶段2 任务=" + (nm or "")) if ok: total += 1 time.sleep(random.uniform(2, 4)) return total def main(): ensure() call("find_tab", {"url": "2haohr.com"}) pages = load_pages() log("=== 开始导出下载, 共 %d 页 ===" % len(pages)) stage1 = 0 for i, p in enumerate(pages, 1): try: stage1 += process_page(p, i, len(pages)) except Exception as e: log(" 页面异常: %s" % str(e)[:100]) rec(p["node_key"], p["full_path"], p["url"], "err", "", "fail", 0, str(e)[:80]) time.sleep(random.uniform(5, 15)) stage2 = harvest_task_center() log("=== DONE 阶段1=%d 阶段2=%d 累计文件=%d ===" % (stage1, stage2, len(SAVED))) if __name__ == "__main__": main()