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

240 lines
11 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""全站列表页深度采集器 (任务1 + 任务3)。
对每个有 URL 的叶子功能页:
1) 点「更多/更多操作」等入口, 抓取下拉菜单里的隐藏功能项(发现之前未扫描到的界面);
2) 点「自定义列/设置字段/选择表头」(#icon-custom-columns 或文字)弹窗, 抓取全部可选字段
(含"系统字段/自定义字段"分组), 远超截图与导出Excel的可见字段;
3) 兜底: 抓取默认渲染出的可见列头。
结果写 SQLite: page_extras(隐藏入口) + field_specs(完整字段字典)。
复用非均匀拟人间隔与登录态检测。"""
import json, urllib.request, time, os, random, sqlite3, collections
D = "http://127.0.0.1:10086/command"; S = "2haohr"
DB = "db/research.db"; LOG = "db/collect_fields.log"
def call(a, args, t=90):
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=t).read().decode())
def ev(code, t=90):
try:
return call("evaluate", {"code": code}, t).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")
def db():
c = sqlite3.connect(DB); c.row_factory = sqlite3.Row; return c
def ensure():
c = db()
c.execute("""CREATE TABLE IF NOT EXISTS page_extras(
id INTEGER PRIMARY KEY AUTOINCREMENT, node_key TEXT, full_path TEXT, url TEXT,
kind TEXT, label TEXT, ts TEXT)""")
c.execute("""CREATE TABLE IF NOT EXISTS field_specs(
id INTEGER PRIMARY KEY AUTOINCREMENT, node_key TEXT, full_path TEXT, url TEXT,
field TEXT, field_group TEXT, source TEXT, sort_order INTEGER, ts TEXT)""")
c.execute("PRAGMA journal_mode=DELETE")
c.commit(); c.close()
def clear_page(nk):
c = db()
c.execute("DELETE FROM page_extras WHERE node_key=?", (nk,))
c.execute("DELETE FROM field_specs WHERE node_key=?", (nk,))
c.commit(); c.close()
def save_extras(nk, fp, url, items):
c = db(); ts = time.strftime("%Y-%m-%d %H:%M:%S")
for kind, label in items:
c.execute("INSERT INTO page_extras(node_key,full_path,url,kind,label,ts) VALUES(?,?,?,?,?,?)",
(nk, fp, url, kind, label, ts))
c.commit(); c.close()
def save_fields(nk, fp, url, fields, source):
c = db(); ts = time.strftime("%Y-%m-%d %H:%M:%S")
for i, (grp, name) in enumerate(fields):
c.execute("INSERT INTO field_specs(node_key,full_path,url,field,field_group,source,sort_order,ts)"
" VALUES(?,?,?,?,?,?,?,?)", (nk, fp, url, name, grp, source, i, ts))
c.commit(); c.close()
def human_delay(kind="page"):
r = random.random()
if kind == "page":
d = random.uniform(3.5, 8.0) if r < 0.7 else random.uniform(8.0, 18.0)
else:
d = random.uniform(1.0, 3.0) if r < 0.75 else random.uniform(3.0, 7.0)
d = max(0.7, d + random.gauss(0, 0.4))
time.sleep(d); return d
def logged_out():
h = ev("location.href")
return isinstance(h, str) and "/login" in h
# ---- JS 片段 ----
CLICK_MORE = r"""(()=>{const kws=['更多','更多操作','更多设置'];
const b=[...document.querySelectorAll('button,a,span,div')].find(x=>x.childElementCount===0&&kws.includes((x.innerText||'').trim())&&x.getBoundingClientRect().width>0);
if(!b)return 'no-more';b.click();return 'clicked';})()"""
# 抓取下拉/浮层菜单里的操作项: 限 dropdown/popper/menu/popover 容器(可见),
# 且单容器项数<=15(超过则为筛选/树选择面板, 非操作菜单, 丢弃)
MENU_ITEMS = r"""(()=>{const BAD=/搜索|条\/页|进入员工端|进入官网|退出登录|个性化设置|按.*排序|升序|降序|排在前|排在后|帮助/;
const boxes=[...document.querySelectorAll('[class*=dropdown],[class*=Dropdown],[class*=popper],[class*=popover],[class*=Popover],[role=menu]')]
.filter(m=>{const r=m.getBoundingClientRect();return r.width>0&&r.height>0;});
let out=[];
for(const box of boxes){
const its=[...box.querySelectorAll('li,[role=menuitem],[class*=item],[class*=option]')]
.map(e=>(e.innerText||'').replace(/\s+/g,'').trim()).filter(t=>t&&t.length>1&&t.length<16&&!BAD.test(t));
const uniq=[...new Set(its)];
if(uniq.length>=1&&uniq.length<=15)out=out.concat(uniq); // 超过15视为筛选面板污染, 丢弃
}
return JSON.stringify([...new Set(out)]);})()"""
# 点自定义列/设置字段/选择表头 入口(图标或文字)
CLICK_COLSET = r"""(()=>{
const u=[...document.querySelectorAll('use')].find(u=>{const h=(u.getAttribute('xlink:href')||u.getAttribute('href')||'');return h==='#icon-custom-columns'||h==='#icon-setting'&&false;});
if(u){const b=u.closest('button,a,[role=button],span,div');if(b){b.click();return 'icon';}}
const kw=/^(自定义列|设置字段|字段设置|选择表头|表头设置|设置花名册字段|列设置|自定义字段)$/;
const t=[...document.querySelectorAll('button,a,span,div,li')].find(x=>x.childElementCount===0&&kw.test((x.innerText||'').trim())&&x.getBoundingClientRect().width>0);
if(t){t.click();return 'txt';}
return 'none';})()"""
# 从"选择表头/自定义列"弹窗按顺序抓字段(复选框标签法, 已验证=花名册81字段干净)
# 限定弹窗容器(含"选择表头|已选"且checkbox>3且宽180-700高>200), walk遍历childNodes:
# text节点匹配GRPS作分组标题, LABEL标签作字段, NOISE集过滤噪音
GRAB_FIELDS = r"""(()=>{
const pop=[...document.querySelectorAll('div,aside,section')].find(m=>{const t=m.innerText||'';const r=m.getBoundingClientRect();
return r.width>180&&r.width<700&&r.height>200&&/选择表头|已选/.test(t)&&m.querySelectorAll('input[type=checkbox]').length>3;});
if(!pop)return 'no-modal';
const GRPS=['系统字段','自定义字段','基础字段','常用字段','其他字段','个人信息','工作信息','合同信息'];
const NOISE=new Set(['选择表头','没有搜索到相关内容','全部','清除','取消','确定','恢复默认','重置','搜索','已选']);
let seq=[],cur='';
const walk=el=>{for(const c of el.childNodes){
if(c.nodeType===3){const t=(c.textContent||'').trim();if(t&&t.length<15){if(GRPS.includes(t))cur=t;else if(!NOISE.has(t)&&!/^已选/.test(t))seq.push([cur,t]);}}
else if(c.nodeType===1){if(c.tagName==='LABEL'){const t=(c.innerText||'').trim();if(t&&t.length<15&&!NOISE.has(t))seq.push([cur,t]);}else walk(c);}}};
walk(pop);
const seen=new Set(),out=[];for(const [g,n] of seq){if(seen.has(n))continue;seen.add(n);out.push([g,n]);}
return JSON.stringify(out);})()"""
# 处理"页面内容已过期, 请点击 立即刷新"横幅(标签闲置后出现)
REFRESH = r"""(()=>{const b=[...document.querySelectorAll('a,button,span')].find(x=>x.childElementCount===0&&/立即刷新/.test((x.innerText||'').trim())&&x.getBoundingClientRect().width>0);if(b){b.click();return 'refreshed';}return 'none';})()"""
CLOSE_MODAL = r"""(()=>{const b=[...document.querySelectorAll('button,a')].find(x=>{const t=(x.innerText||'').trim();return (t==='取消'||t==='关闭')&&x.getBoundingClientRect().width>0;});
if(b){b.click();return 'closed';}document.body.click();return 'bodyclick';})()"""
# 兜底: 默认渲染的可见列头(过滤 操作/序号/全选/日期列等非字段噪音)
VISIBLE_COLS = r"""(()=>{const BAD=/^(操作|序号|全选|选择|处理)$|^\d+日|^\d+$/;
const heads=[...document.querySelectorAll('[role=columnheader],th,[class*=headerCell],[class*=header_] [class*=cell_],[class*=thead] [class*=cell]')]
.map(e=>(e.innerText||'').replace(/\s+/g,'').trim()).filter(t=>t&&t.length<20&&!BAD.test(t));
return JSON.stringify([...new Set(heads)]);})()"""
def process(p, idx, total):
fp, url, nk = p["full_path"], p["url"], p["node_key"]
log("[%d/%d] %s" % (idx, total, fp))
call("navigate", {"url": url, "newTab": False})
time.sleep(4)
for _ in range(4):
if not logged_out():
break
time.sleep(2)
if logged_out():
log(" !! 登录态失效, 中止"); return -1
# 处理"页面内容已过期"横幅(标签闲置后出现)
if ev(REFRESH) == "refreshed":
time.sleep(3)
clear_page(nk)
# ---- 任务1: 更多菜单隐藏入口(菜单渲染有时滞后, 重试抓取) ----
extras = []
for attempt in range(3):
if ev(CLICK_MORE) != "clicked":
break
time.sleep(1.2 + attempt * 0.8)
try:
items = json.loads(ev(MENU_ITEMS))
except Exception:
items = []
if items:
for it in items:
extras.append(("more_menu", it))
ev(CLOSE_MODAL); time.sleep(0.5)
break
ev(CLOSE_MODAL); time.sleep(0.4)
if extras:
save_extras(nk, fp, url, extras)
log(" 更多菜单项: %d 个 -> %s" % (len(extras), "、".join(x[1] for x in extras[:8])))
# ---- 任务3: 完整字段 ----
fields = []; source = ""
if ev(CLICK_COLSET) != "none":
time.sleep(1.5)
raw = ev(GRAB_FIELDS)
if isinstance(raw, str) and raw.startswith("["):
try:
fields = json.loads(raw)
except Exception:
fields = []
source = "custom_columns"
ev(CLOSE_MODAL); time.sleep(0.5)
if not fields:
# 兜底可见列
try:
cols = json.loads(ev(VISIBLE_COLS))
except Exception:
cols = []
fields = [("", c) for c in cols]
source = "visible_cols"
if fields:
save_fields(nk, fp, url, fields, source)
log(" 字段: %d 个 (来源=%s) -> %s" % (len(fields), source, "、".join(f[1] for f in fields[:10])))
else:
log(" 无字段入口(可能非表格页)")
return len(fields)
def main():
ensure()
call("find_tab", {"url": "2haohr.com"})
c = db()
rows = [dict(r) for r in c.execute("SELECT node_key,parent_key,name,full_path,url FROM features")]
c.close()
kids = collections.defaultdict(int)
for r in rows:
if r["parent_key"]:
kids[r["parent_key"]] += 1
leaves = [r for r in rows if kids.get(r["node_key"], 0) == 0 and r["url"]]
log("=== 全站字段采集, 共 %d 个叶子页 ===" % len(leaves))
total_f = 0
for i, p in enumerate(leaves, 1):
try:
n = process(p, i, len(leaves))
if n == -1:
log("=== 中止 ==="); break
total_f += max(0, n)
except Exception as e:
log(" 页面异常: %s" % str(e)[:100])
human_delay("page")
log("=== DONE 累计字段=%d ===" % total_f)
if __name__ == "__main__":
main()