
bash
import requests, json, os
# ═══════════════════════════════════════════════════
# 🔶 钉钉组织架构同步脚本
# 获取参数:https://open-dev.dingtalk.com/
# 路径:应用开发 → 钉钉应用 → 点击应用 → 凭证与基础信息
# ═══════════════════════════════════════════════════
APP_KEY = "dinxxxxjrr"
APP_SECRET = "uiGdxxxxx1nOiPoe0-vmkfYWHxMDAeiDIv42QEFY"
BASE = "https://oapi.dingtalk.com"
# ── 鉴权 ──────────────────────────────────────────
def get_token():
r = requests.get(f"{BASE}/gettoken",
params={"appkey": APP_KEY, "appsecret": APP_SECRET}).json()
if r.get("errcode") != 0:
raise Exception(f"❌ Token 获取失败: {r.get('errmsg')}")
print("✅ Token 获取成功")
return r["access_token"]
TOKEN = get_token()
def post(url, body):
return requests.post(url, params={"access_token": TOKEN},
headers={"Content-Type": "application/json"},
json=body).json()
# ── 递归拉取所有子部门 ─────────────────────────────
def fetch_depts(parent_id=1):
res = post(f"{BASE}/topapi/v2/department/listsub",
{"dept_id": parent_id, "language": "zh_CN"})
depts = res.get("result") or []
all_depts = []
for d in depts:
all_depts.append(d)
all_depts.extend(fetch_depts(d["dept_id"]))
return all_depts
# ── 分页拉取部门成员 ───────────────────────────────
def fetch_members(dept_id):
users, cursor = [], 0
while True:
res = post(f"{BASE}/topapi/v2/user/list",
{"dept_id": dept_id, "cursor": cursor, "size": 50,
"contain_access_level": False})
result = res.get("result") or {}
for u in result.get("list", []):
users.append({
"name": u.get("name", ""),
"title": u.get("title", ""),
"avatar": u.get("avatar", ""),
})
if not result.get("has_more"):
break
cursor = result.get("next_cursor", 0)
return users
# ── 构建树结构 ─────────────────────────────────────
def build_tree(all_depts, member_map, parent_id):
children = sorted([d for d in all_depts if d["parent_id"] == parent_id],
key=lambda x: x.get("order", 0))
return [{
"name": d["name"],
"id": str(d["dept_id"]),
"members": member_map.get(d["dept_id"], []),
"children": build_tree(all_depts, member_map, d["dept_id"]),
} for d in children]
# ── 主流程 ─────────────────────────────────────────
print("📡 正在拉取部门...")
all_depts = fetch_depts(1)
print(f"✅ 部门:{len(all_depts)} 个")
member_map = {}
print(f"👥 正在拉取 {len(all_depts)} 个部门的成员...")
for d in all_depts:
members = fetch_members(d["dept_id"])
if members:
member_map[d["dept_id"]] = members
print(f"✅ 成员:{sum(len(v) for v in member_map.values())} 名")
root_res = post(f"{BASE}/topapi/v2/department/get", {"dept_id": 1})
root_name = (root_res.get("result") or {}).get("name", "企业")
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: #0d1117;
--surface: #161b22;
--surface2: #1c2230;
--border: #30363d;
--border2: #21262d;
--accent: #f78166;
--accent2: #ffa657;
--text: #e6edf3;
--text2: #8b949e;
--text3: #6e7681;
--green: #3fb950;
--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 ── */
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;
position: relative;
z-index: 10;
}}
.logo {{
width: 34px; height: 34px;
background: linear-gradient(135deg, #f78166, #ffa657);
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 0 20px rgba(247,129,102,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(63,185,80,0.15);
color: var(--green);
border: 1px solid rgba(63,185,80,0.3);
padding: 3px 8px;
border-radius: 20px;
font-family: 'JetBrains Mono', monospace;
}}
/* ── Layout ── */
.layout {{
display: flex;
flex: 1;
overflow: hidden;
}}
/* ── Sidebar ── */
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(--bg);
border: 1px solid var(--border);
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;
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='%238b949e' 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); }}
.search-wrap input::placeholder {{ color: var(--text3); }}
#tree {{ flex: 1; overflow-y: auto; padding: 8px 6px; }}
#tree::-webkit-scrollbar {{ width: 4px; }}
#tree::-webkit-scrollbar-track {{ background: transparent; }}
#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: rgba(247,129,102,0.1);
color: var(--accent);
border: 1px solid rgba(247,129,102,0.2);
}}
.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 ── */
main {{
flex: 1;
overflow-y: auto;
padding: 28px;
background: var(--bg);
}}
main::-webkit-scrollbar {{ width: 6px; }}
main::-webkit-scrollbar-track {{ background: transparent; }}
main::-webkit-scrollbar-thumb {{ background: var(--border); border-radius: 4px; }}
.dept-header {{
display: flex;
align-items: flex-end;
gap: 16px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--border2);
}}
.dept-icon-big {{
width: 52px; height: 52px;
background: linear-gradient(135deg, rgba(247,129,102,0.2), rgba(255,166,87,0.2));
border: 1px solid rgba(247,129,102,0.3);
border-radius: 14px;
display: flex; align-items: center; justify-content: center;
font-size: 24px;
flex-shrink: 0;
}}
.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;
cursor: default;
}}
.member-card:hover {{
border-color: var(--border);
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,0,0,0.3);
}}
.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, var(--accent), var(--accent2));
}}
.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, background .2s;
font-size: 13px;
color: var(--text2);
}}
.sub-dept-card:hover {{
border-color: var(--accent);
background: rgba(247,129,102,0.05);
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">DingTalk 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;
let 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_dingtalk.html"
with open(out, "w", encoding="utf-8") as f:
f.write(html)
print(f"🎉 完成!打开: {os.path.abspath(out)}")


包含管理员版本:
bash
# -*- coding: utf-8 -*-
"""
钉钉组织架构同步 + 管理员
"""
import requests
from datetime import datetime
# ================== 配置区 ==================
APP_KEY = "dinxxxxxjrr"
APP_SECRET = "uiGdvr7xxxxxxxxe0-vmkfYWxxxxIv42QEFY"
# ===========================================
def get_access_token():
url = "https://oapi.dingtalk.com/gettoken"
params = {"appkey": APP_KEY, "appsecret": APP_SECRET}
resp = requests.get(url, params=params, timeout=10).json()
if resp.get("errcode") == 0:
print("✅ access_token 获取成功")
return resp["access_token"]
raise Exception(f"Token 获取失败: {resp.get('errmsg')}")
def get_admin_list(token):
url = "https://oapi.dingtalk.com/topapi/user/listadmin"
resp = requests.post(url, params={"access_token": token}, json={}).json()
admins = {}
for item in resp.get("result") or []:
userid = item.get("userid")
if userid:
# 优先从 listadmin 取 name,如果没有后面会用 user/get 补充
admins[userid] = {
"name": item.get("name", ""),
"sys_level": item.get("sys_level")
}
print(f"✅ 获取到 {len(admins)} 名管理员")
return admins
def get_user_detail(token, userid):
url = "https://oapi.dingtalk.com/topapi/v2/user/get"
data = {"userid": userid, "language": "zh_CN"}
resp = requests.post(url, params={"access_token": token}, json=data).json()
if resp.get("errcode") == 0:
return resp.get("result", {})
return {}
def get_all_departments(token):
all_depts = []
def recurse(dept_id=1):
url = "https://oapi.dingtalk.com/topapi/v2/department/listsub"
resp = requests.post(url, params={"access_token": token},
json={"dept_id": dept_id, "language": "zh_CN"}).json()
subs = resp.get("result", []) if resp.get("errcode") == 0 else []
for d in subs:
all_depts.append(d)
recurse(d.get("dept_id"))
print("📥 正在递归获取所有部门...")
recurse()
print(f"✅ 共获取到 {len(all_depts)} 个部门")
return all_depts
def get_users_in_dept(token, dept_id):
url = "https://oapi.dingtalk.com/topapi/v2/user/list"
data = {"dept_id": dept_id, "cursor": 0, "size": 100, "language": "zh_CN"}
users = []
while True:
resp = requests.post(url, params={"access_token": token}, json=data).json()
if resp.get("errcode") != 0:
break
users.extend(resp.get("result", {}).get("list", []))
if not resp.get("result", {}).get("has_more"):
break
data["cursor"] = resp.get("result", {}).get("next_cursor")
return users
def build_tree(depts, users_map, admins, token):
dept_dict = {d["dept_id"]: {
"id": d["dept_id"],
"name": d.get("name", "未命名"),
"parent_id": d.get("parent_id", 0),
"children": [],
"users": users_map.get(d["dept_id"], [])
} for d in depts}
# 确保根部门存在(你的根部门是 "api调试专用企业")
root_id = 1
if root_id not in dept_dict:
dept_dict[root_id] = {
"id": root_id,
"name": "xxxx企业",
"parent_id": 0,
"children": [],
"users": []
}
print(" ⚠️ 自动创建根部门")
print("\n=== 强制挂载管理员到根部门 ===")
for userid, admin_base in admins.items():
# 先尝试从 user/get 获取完整信息
detail = get_user_detail(token, userid)
if not detail:
# 如果失败,用 listadmin 的基础信息构造
detail = {
"userid": userid,
"name": admin_base.get("name", "管理员"),
"title": "主管理员",
"mobile": "",
"admin": True,
"boss": True
}
name = detail.get("name") or admin_base.get("name") or "管理员"
if not any(u.get("userid") == userid for u in dept_dict[root_id]["users"]):
dept_dict[root_id]["users"].append(detail)
print(f"✅ 管理员 【{name}】 已强制挂载到根部门 【api调试专用企业】")
# 构建树
root = []
for dept in dept_dict.values():
if dept["parent_id"] == 0 or dept["parent_id"] not in dept_dict:
root.append(dept)
else:
dept_dict[dept["parent_id"]]["children"].append(dept)
return root
def generate_html(tree):
html = f"""
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>钉钉组织架构(含管理员) {datetime.now().strftime('%Y-%m-%d %H:%M')}</title>
<style>
body {{font-family:"Microsoft YaHei",sans-serif;background:#f5f7fa;padding:20px;}}
h1 {{text-align:center;color:#1e90ff;}}
.dept {{margin:10px 0;padding:14px;background:white;border-radius:10px;box-shadow:0 3px 10px rgba(0,0,0,0.1);}}
.dept-name {{font-weight:bold;color:#2c3e50;cursor:pointer;font-size:17px;}}
.users {{margin-left:40px;margin-top:10px;}}
.user {{padding:10px 14px;background:#f8f9fa;margin:6px 0;border-radius:8px;font-size:14.5px;border-left:4px solid #3498db;}}
.user.admin {{border-left:5px solid #e74c3c; background:#fff0f0; font-weight:bold;}}
.highlight {{color:#e74c3c; font-size:16px;}}
</style>
</head>
<body>
<h1>🏢 钉钉组织架构(含管理员)</h1>
<div style="text-align:center;margin-bottom:25px;color:#666;">
同步时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
</div>
<div id="org">
"""
def render(node, level=0):
nonlocal html
html += f'''
<div class="dept" style="margin-left:{level * 28}px">
<div class="dept-name" onclick="this.nextElementSibling.style.display = this.nextElementSibling.style.display==='none'?'block':'none'">
▼ {node.get('name', '未命名部门')} <span style="color:#999;font-size:14px;">({len(node.get('users', []))}人)</span>
</div>
<div class="users">
'''
for u in node.get("users", []):
name = u.get("name", "未知")
is_admin = bool(u.get("admin") or u.get("boss") or u.get("role_list"))
tag = "👑 主管理员" if is_admin else ""
admin_class = ' class="user admin"' if is_admin else ' class="user"'
admin_tag = f'<span class="highlight"> {tag}</span>' if is_admin else ''
html += f'''
<div{admin_class}>
👤 {name}
<span style="color:#666;margin-left:20px;">{u.get("title", "")}</span>
<span style="color:#888;margin-left:20px;">{u.get("mobile", "")}</span>
{admin_tag}
</div>
'''
for child in node.get("children", []):
render(child, level + 1)
html += "</div></div>"
for r in tree:
render(r)
html += """
</div>
</body>
</html>
"""
return html
# ====================== 主程序 ======================
if __name__ == "__main__":
token = get_access_token()
admins = get_admin_list(token)
depts = get_all_departments(token)
print("📥 正在获取成员...")
users_map = {}
for d in depts:
users = get_users_in_dept(token, d["dept_id"])
users_map[d["dept_id"]] = users
tree = build_tree(depts, users_map, admins, token)
html_content = generate_html(tree)
filename = "dingtalk_organization_final.html"
with open(filename, "w", encoding="utf-8") as f:
f.write(html_content)
print(f"\n🎉 生成完成!文件:{filename}")