企微组织架构同步到本地

注意事项:

企微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}} &nbsp;·&nbsp; 直属成员: ${{(node.members||[]).length}} &nbsp;·&nbsp; 子部门: ${{(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)}")
相关推荐
Engineer邓祥浩4 小时前
JVM学习笔记(13) 第五部分 高效并发 第12章 Java内存模型与线程
jvm·笔记·学习
我命由我123454 小时前
程序员的心理学学习笔记 - 反刍思维
经验分享·笔记·学习·职场和发展·求职招聘·职场发展·学习方法
南尘NCA86666 小时前
如何解决企业微信防封行业高封号率痛点
python·企业微信
xuhaoyu_cpp_java6 小时前
事务学习(一)
数据库·经验分享·笔记·学习·mysql
代码地平线7 小时前
OpenCode零基础教程完整版
笔记
.Cnn7 小时前
Ajax与Vue 生命周期核心笔记
前端·javascript·vue.js·笔记·ajax
恒哥的爸爸8 小时前
GPT原理笔记
人工智能·笔记·gpt
神奇小梵8 小时前
http详解(笔记保存)
笔记
Pentane.8 小时前
【力扣hot100】【Leetcode 15】三数之和|暴力枚举 双指针 算法笔记及打卡(14/100)
数据结构·笔记·算法·leetcode