
注意事项:
企微ip白名单可能需配置多个
py案例:
bash
import requests, json, os, sys
# 企业微信id
CORP_ID = "ww3xxxx09"
# 自建应用密钥 、不能通讯录密钥 过时了
CORP_SECRET = "no2BcTxxxxxthYsDY"
BASE = "https://qyapi.weixin.qq.com/cgi-bin"
# ── 鉴权 ──────────────────────────────────────────
def get_token():
r = requests.get(f"{BASE}/gettoken",
params={"corpid": CORP_ID, "corpsecret": CORP_SECRET}).json()
if r.get("errcode") != 0:
raise Exception(f"❌ Token 获取失败: errcode={r.get('errcode')} errmsg={r.get('errmsg')}")
print("✅ Token 获取成功")
return r["access_token"]
TOKEN = get_token()
def get(path, params=None):
p = params or {}
p["access_token"] = TOKEN
resp = requests.get(f"{BASE}{path}", params=p).json()
# ✅ FIX: 检查 errcode,权限不足时提前报错而不是静默返回空
if resp.get("errcode") not in (0, None):
print(f" ⚠️ 接口 {path} 返回错误: errcode={resp['errcode']} errmsg={resp.get('errmsg')}")
return resp
# ── 拉取全量部门 ──────────────────────────────────
def fetch_depts():
res = get("/department/list", {"id": 1, "fetch_child": 1})
if res.get("errcode") != 0:
raise Exception(f"❌ 部门列表获取失败: {res.get('errmsg')}\n"
" → 请确认使用的是【通讯录同步】Secret,或自建应用可见范围已设为全公司")
depts = res.get("department", [])
print(f"✅ 部门:{len(depts)} 个")
return depts
# ── 拉取部门直属成员(含错误处理) ────────────────
def fetch_members(dept_id):
res = get("/user/list", {"department_id": dept_id, "fetch_child": 0})
errcode = res.get("errcode", 0)
if errcode == 60011:
# 无权限访问该部门,常见于自建应用可见范围受限
print(f" ⚠️ 部门 {dept_id} 无权限(errcode 60011),跳过")
return []
if errcode != 0:
print(f" ⚠️ 部门 {dept_id} 获取成员失败 errcode={errcode},跳过")
return []
return [{
"name": u.get("name", ""),
"title": u.get("position", ""),
"avatar": u.get("avatar", ""),
} for u in res.get("userlist", [])]
# ── 构建树结构 ─────────────────────────────────────
def build_tree(all_depts, member_map, parent_id):
children = sorted([d for d in all_depts if d["parentid"] == parent_id],
key=lambda x: x.get("order", 0))
return [{
"name": d["name"],
"id": str(d["id"]),
"members": member_map.get(d["id"], []),
"children": build_tree(all_depts, member_map, d["id"]),
} for d in children]
# ── 主流程 ─────────────────────────────────────────
all_depts = fetch_depts()
member_map = {}
print(f"👥 正在拉取 {len(all_depts)} 个部门的成员...")
success, fail = 0, 0
for d in all_depts:
members = fetch_members(d["id"])
if members:
member_map[d["id"]] = members
success += 1
else:
fail += 1
total_members = sum(len(v) for v in member_map.values())
print(f"✅ 成员:{total_members} 名({success} 个部门有成员,{fail} 个部门为空或无权限)")
if total_members == 0:
print("\n❌ 未获取到任何成员,请排查:")
print(" 1. Secret 是否为【通讯录同步】中的 Secret")
print(" 2. 如果用自建应用 Secret,需在「应用详情→可见范围」设为全公司")
print(" 3. 通讯录同步功能是否已开启(管理后台 → 管理工具 → 通讯录同步)")
sys.exit(1)
root_name = next((d["name"] for d in all_depts if d["id"] == 1), "企业")
tree_data = {
"name": root_name, "id": "1", "members": [],
"children": build_tree(all_depts, member_map, 1),
}
# ── 生成 HTML ──────────────────────────────────────
tree_json = json.dumps(tree_data, ensure_ascii=False)
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{root_name} · 组织架构</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
:root {{
--bg: #f0f4f8;
--surface: #ffffff;
--surface2: #f6f8fa;
--border: #d1d9e0;
--border2: #e8edf2;
--accent: #07c160;
--accent2: #009944;
--accentbg: rgba(7,193,96,0.08);
--accentbd: rgba(7,193,96,0.25);
--text: #1a2332;
--text2: #4a5568;
--text3: #8896a5;
--sidebar-w: 300px;
--header-h: 56px;
}}
*, *::before, *::after {{ box-sizing: border-box; margin: 0; padding: 0; }}
body {{ font-family: 'Noto Sans SC', sans-serif; background: var(--bg); color: var(--text); height: 100vh; overflow: hidden; display: flex; flex-direction: column; }}
header {{ height: var(--header-h); background: var(--surface); border-bottom: 1px solid var(--border); display: flex; align-items: center; padding: 0 20px; gap: 14px; flex-shrink: 0; box-shadow: 0 1px 3px rgba(0,0,0,0.06); position: relative; z-index: 10; }}
.logo {{ width: 34px; height: 34px; background: linear-gradient(135deg, #07c160, #00a050); border-radius: 10px; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 15px; color: #fff; flex-shrink: 0; box-shadow: 0 4px 12px rgba(7,193,96,0.3); }}
.header-title {{ font-size: 15px; font-weight: 600; color: var(--text); letter-spacing: 0.3px; }}
.header-sub {{ font-size: 12px; color: var(--text3); font-family: 'JetBrains Mono', monospace; margin-left: auto; background: var(--surface2); padding: 4px 10px; border-radius: 6px; border: 1px solid var(--border2); }}
.badge {{ font-size: 11px; background: rgba(7,193,96,0.1); color: var(--accent2); border: 1px solid rgba(7,193,96,0.25); padding: 3px 8px; border-radius: 20px; font-family: 'JetBrains Mono', monospace; }}
.layout {{ display: flex; flex: 1; overflow: hidden; }}
aside {{ width: var(--sidebar-w); background: var(--surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0; }}
.search-wrap {{ padding: 12px; border-bottom: 1px solid var(--border2); }}
.search-wrap input {{ width: 100%; background: var(--surface2); border: 1px solid var(--border2); border-radius: 8px; padding: 8px 12px 8px 34px; color: var(--text); font-size: 13px; font-family: 'Noto Sans SC', sans-serif; outline: none; transition: border-color .2s, box-shadow .2s; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%238896a5' stroke-width='2'%3E%3Ccircle cx='11' cy='11' r='8'/%3E%3Cpath d='m21 21-4.35-4.35'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: 10px center; }}
.search-wrap input:focus {{ border-color: var(--accent); box-shadow: 0 0 0 3px rgba(7,193,96,0.12); background-color: #fff; }}
.search-wrap input::placeholder {{ color: var(--text3); }}
#tree {{ flex: 1; overflow-y: auto; padding: 8px 6px; }}
#tree::-webkit-scrollbar {{ width: 4px; }}
#tree::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }}
.node-label {{ display: flex; align-items: center; gap: 7px; padding: 7px 10px; border-radius: 7px; cursor: pointer; font-size: 13px; color: var(--text2); transition: background .15s, color .15s; user-select: none; white-space: nowrap; overflow: hidden; }}
.node-label:hover {{ background: var(--surface2); color: var(--text); }}
.node-label.active {{ background: var(--accentbg); color: var(--accent2); border: 1px solid var(--accentbd); }}
.toggle-icon {{ width: 12px; height: 12px; flex-shrink: 0; color: var(--text3); transition: transform .2s; font-size: 9px; display: flex; align-items: center; justify-content: center; }}
.toggle-icon.open {{ transform: rotate(90deg); }}
.toggle-icon.leaf {{ opacity: 0; }}
.dept-icon {{ font-size: 13px; flex-shrink: 0; }}
.node-name {{ flex: 1; overflow: hidden; text-overflow: ellipsis; }}
.member-count {{ font-size: 10px; font-family: 'JetBrains Mono', monospace; color: var(--text3); background: var(--surface2); padding: 1px 5px; border-radius: 10px; flex-shrink: 0; }}
.node-children {{ padding-left: 16px; }}
.node-children.hidden {{ display: none; }}
main {{ flex: 1; overflow-y: auto; padding: 28px; }}
main::-webkit-scrollbar {{ width: 6px; }}
main::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }}
.dept-header {{ display: flex; align-items: center; gap: 16px; margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--border2); }}
.dept-icon-big {{ font-size: 36px; }}
.dept-title {{ font-size: 22px; font-weight: 600; color: var(--text); }}
.dept-meta {{ font-size: 13px; color: var(--text3); margin-top: 4px; font-family: 'JetBrains Mono', monospace; }}
.section-title {{ font-size: 11px; font-weight: 600; letter-spacing: 1.2px; text-transform: uppercase; color: var(--text3); margin-bottom: 14px; }}
.members-grid {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; margin-bottom: 32px; }}
.member-card {{ background: var(--surface); border: 1px solid var(--border2); border-radius: 10px; padding: 14px 16px; display: flex; align-items: center; gap: 12px; transition: border-color .2s, transform .2s, box-shadow .2s; }}
.member-card:hover {{ border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 6px 20px rgba(7,193,96,0.1); }}
.avatar {{ width: 40px; height: 40px; border-radius: 50%; overflow: hidden; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-weight: 600; font-size: 15px; color: #fff; background: linear-gradient(135deg, #07c160, #00a050); }}
.avatar img {{ width: 100%; height: 100%; object-fit: cover; }}
.member-name {{ font-size: 14px; font-weight: 500; color: var(--text); }}
.member-title {{ font-size: 12px; color: var(--text3); margin-top: 2px; }}
.sub-depts {{ display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px; margin-bottom: 32px; }}
.sub-dept-card {{ background: var(--surface); border: 1px solid var(--border2); border-radius: 10px; padding: 14px 16px; display: flex; align-items: center; gap: 10px; cursor: pointer; transition: border-color .2s, box-shadow .2s; font-size: 13px; color: var(--text2); }}
.sub-dept-card:hover {{ border-color: var(--accent); box-shadow: 0 4px 16px rgba(7,193,96,0.1); color: var(--text); }}
.sub-dept-name {{ font-weight: 500; }}
.sub-dept-count {{ font-size: 11px; color: var(--text3); margin-top: 2px; font-family: 'JetBrains Mono', monospace; }}
.empty {{ color: var(--text3); font-size: 13px; padding: 12px 0; }}
.placeholder {{ display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60vh; color: var(--text3); gap: 12px; font-size: 14px; }}
.placeholder-icon {{ font-size: 48px; opacity: 0.3; }}
@keyframes fadeIn {{ from {{ opacity:0; transform:translateY(8px) }} to {{ opacity:1; transform:none }} }}
.fade-in {{ animation: fadeIn .25s ease; }}
</style>
</head>
<body>
<header>
<div class="logo">微</div>
<div><div class="header-title">{root_name} · 组织架构</div></div>
<div class="badge" id="stat">加载中...</div>
<div class="header-sub">WeCom Org Viewer</div>
</header>
<div class="layout">
<aside>
<div class="search-wrap">
<input id="searchInput" placeholder="搜索部门..." oninput="search(this.value)">
</div>
<div id="tree"></div>
</aside>
<main id="main">
<div class="placeholder">
<div class="placeholder-icon">🏢</div>
<div>点击左侧部门查看详情</div>
</div>
</main>
</div>
<script>
const DATA = {tree_json};
const idx = {{}};
let totalMembers = 0, totalDepts = 0;
function buildIdx(n) {{
idx[n.id] = n; totalDepts++;
totalMembers += (n.members || []).length;
(n.children || []).forEach(buildIdx);
}}
buildIdx(DATA);
document.getElementById('stat').textContent = totalDepts + ' 部门 · ' + totalMembers + ' 成员';
function countMembers(node) {{
let c = (node.members || []).length;
(node.children || []).forEach(ch => c += countMembers(ch));
return c;
}}
function renderNode(node, depth) {{
const hasChild = (node.children || []).length > 0;
const wrap = document.createElement('div');
wrap.className = 'node-wrap'; wrap.dataset.name = node.name;
const label = document.createElement('div');
label.className = 'node-label';
const cnt = countMembers(node);
label.innerHTML =
`<span class="toggle-icon ${{hasChild?'':'leaf'}}">▶</span>` +
`<span class="dept-icon">${{depth===0?'🏢':'📁'}}</span>` +
`<span class="node-name">${{node.name}}</span>` +
(cnt > 0 ? `<span class="member-count">${{cnt}}</span>` : '');
label.onclick = e => {{
e.stopPropagation();
document.querySelectorAll('.node-label').forEach(l => l.classList.remove('active'));
label.classList.add('active'); showDetail(node.id);
if (hasChild) {{
const ch = wrap.querySelector(':scope > .node-children');
ch.classList.toggle('hidden');
label.querySelector('.toggle-icon').classList.toggle('open');
}}
}};
wrap.appendChild(label);
if (hasChild) {{
const ch = document.createElement('div');
ch.className = 'node-children hidden';
node.children.forEach(c => ch.appendChild(renderNode(c, depth + 1)));
wrap.appendChild(ch);
}}
return wrap;
}}
function showDetail(id) {{
const node = idx[id];
const main = document.getElementById('main');
let html = `<div class="fade-in"><div class="dept-header"><div class="dept-icon-big">${{node.id==='1'?'🏢':'📁'}}</div><div><div class="dept-title">${{node.name}}</div><div class="dept-meta">ID: ${{node.id}} · 直属成员: ${{(node.members||[]).length}} · 子部门: ${{(node.children||[]).length}}</div></div></div>`;
if ((node.children || []).length > 0) {{
html += `<div class="section-title">子部门</div><div class="sub-depts">`;
node.children.forEach(c => {{
const cnt = countMembers(c);
html += `<div class="sub-dept-card" onclick="selectNode('${{c.id}}')"><span style="font-size:20px">📁</span><div><div class="sub-dept-name">${{c.name}}</div><div class="sub-dept-count">${{cnt}} 人</div></div></div>`;
}});
html += `</div>`;
}}
html += `<div class="section-title">直属成员</div>`;
if ((node.members || []).length > 0) {{
html += `<div class="members-grid">`;
node.members.forEach(m => {{
const initial = m.name ? m.name[0] : '?';
html += `<div class="member-card"><div class="avatar">${{m.avatar ? `<img src="${{m.avatar}}" onerror="this.parentNode.textContent='${{initial}}'">` : initial}}</div><div><div class="member-name">${{m.name}}</div><div class="member-title">${{m.title || '员工'}}</div></div></div>`;
}});
html += `</div>`;
}} else {{ html += `<div class="empty">暂无直属成员</div>`; }}
html += `</div>`;
main.innerHTML = html;
}}
function selectNode(id) {{
const node = idx[id]; if (!node) return; showDetail(id);
document.querySelectorAll('.node-label').forEach(l => {{
if (l.closest('.node-wrap')?.dataset?.name === node.name) {{
l.classList.add('active'); l.scrollIntoView({{ block: 'nearest' }});
}} else {{ l.classList.remove('active'); }}
}});
}}
function search(q) {{
const kw = q.trim().toLowerCase();
document.querySelectorAll('.node-wrap').forEach(n => {{
n.style.display = kw && !n.dataset.name.toLowerCase().includes(kw) ? 'none' : '';
}});
}}
const treeEl = document.getElementById('tree');
treeEl.appendChild(renderNode(DATA, 0));
showDetail(DATA.id);
</script>
</body>
</html>"""
out = "org_wecom.html"
with open(out, "w", encoding="utf-8") as f:
f.write(html)
print(f"🎉 完成!打开: {os.path.abspath(out)}")