"use strict"; const fs = require("fs"); const path = require("path"); const crypto = require("crypto"); const { stringify } = require("csv-stringify/sync"); /* 仕様書 https://developers.worksmobile.com/jp/docs/group-list 使い方: node getGroupList.js [outputCsvPath] 例: node getGroupList.js 10000001 node getGroupList.js 10000001 ./groups-10000001.csv 環境変数: LW_CLIENT_ID LW_CLIENT_SECRET LW_SERVICE_ACCOUNT LW_PRIVATE_KEY LW_PRIVATE_KEY_FILE LW_SCOPE 既定: group.read */ const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token"; const LW_API_BASE_URL = "https://www.worksapis.com/v1.0"; const PRIVATE_KEY_FILE = process.env.LW_PRIVATE_KEY_FILE ? path.resolve(process.env.LW_PRIVATE_KEY_FILE) : path.join(__dirname, "private_20260307184804.key"); const LW_CLIENT_ID = process.env.LW_CLIENT_ID || "tre8J_Tk8RblfsMSyZRh"; const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET || "O5_R5gcHxg"; const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT || "wh4k8.serviceaccount@next-hd.co.jp"; const LW_SCOPE = process.env.LW_SCOPE || "group.read"; const LW_COUNT_PER_PAGE = 100; const LW_PRIVATE_KEY = (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim()) ? process.env.LW_PRIVATE_KEY : (fs.existsSync(PRIVATE_KEY_FILE) ? fs.readFileSync(PRIVATE_KEY_FILE, "utf8") : ""); function parseArgs() { const domainIdRaw = process.argv[2]; const outputPathRaw = process.argv[3]; if (!domainIdRaw) { throw new Error("tenantDomainId を引数で指定してください。例: node getGroupList.js 10000001"); } const domainId = Number(domainIdRaw); if (!Number.isInteger(domainId) || domainId <= 0) { throw new Error(`tenantDomainId が不正です: ${domainIdRaw}`); } const outputPath = outputPathRaw ? path.resolve(outputPathRaw) : path.join(__dirname, `groups-${domainId}-${formatDateForFile(new Date())}.csv`); return { domainId, outputPath }; } function formatDateForFile(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, "0"); const d = String(date.getDate()).padStart(2, "0"); const hh = String(date.getHours()).padStart(2, "0"); const mm = String(date.getMinutes()).padStart(2, "0"); const ss = String(date.getSeconds()).padStart(2, "0"); return `${y}${m}${d}-${hh}${mm}${ss}`; } function ensureRequiredEnvForAuth() { const missing = []; if (!LW_CLIENT_ID) missing.push("LW_CLIENT_ID"); if (!LW_CLIENT_SECRET) missing.push("LW_CLIENT_SECRET"); if (!LW_SERVICE_ACCOUNT) missing.push("LW_SERVICE_ACCOUNT"); if (!LW_PRIVATE_KEY) missing.push("LW_PRIVATE_KEY"); if (missing.length > 0) { if (missing.includes("LW_PRIVATE_KEY") && !fs.existsSync(PRIVATE_KEY_FILE)) { throw new Error(`秘密鍵が見つかりません: ${PRIVATE_KEY_FILE}`); } throw new Error(`環境変数が不足しています: ${missing.join(", ")}`); } } function base64UrlEncode(value) { return Buffer.from(value) .toString("base64") .replace(/=/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); } function createJwtAssertion() { const now = Math.floor(Date.now() / 1000); const header = { alg: "RS256", typ: "JWT" }; const payload = { iss: LW_CLIENT_ID, sub: LW_SERVICE_ACCOUNT, aud: LW_TOKEN_URL, iat: now, exp: now + 300, }; const encodedHeader = base64UrlEncode(JSON.stringify(header)); const encodedPayload = base64UrlEncode(JSON.stringify(payload)); const signingInput = `${encodedHeader}.${encodedPayload}`; const signer = crypto.createSign("RSA-SHA256"); signer.update(signingInput); signer.end(); const signature = signer .sign(LW_PRIVATE_KEY) .toString("base64") .replace(/=/g, "") .replace(/\+/g, "-") .replace(/\//g, "_"); return `${signingInput}.${signature}`; } async function getAccessToken(scope = LW_SCOPE) { ensureRequiredEnvForAuth(); const assertion = createJwtAssertion(); const form = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion, client_id: LW_CLIENT_ID, client_secret: LW_CLIENT_SECRET, scope, }); const response = await fetch(LW_TOKEN_URL, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: form, }); if (!response.ok) { const detail = await response.text(); throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`); } const data = await response.json(); if (!data.access_token) { throw new Error("アクセストークン取得失敗: access_token が返却されませんでした"); } return data.access_token; } async function fetchAllGroups(accessToken, domainId) { const groups = []; let cursor = ""; while (true) { const params = new URLSearchParams(); params.set("domainId", String(domainId)); params.set("count", String(LW_COUNT_PER_PAGE)); if (cursor) { params.set("cursor", cursor); } const url = `${LW_API_BASE_URL}/groups?${params.toString()}`; const response = await fetch(url, { method: "GET", headers: { Authorization: `Bearer ${accessToken}`, }, }); const raw = await response.text(); let data; try { data = raw ? JSON.parse(raw) : {}; } catch (_err) { throw new Error(`グループ一覧レスポンスがJSONではありません: ${raw}`); } if (!response.ok) { throw new Error(`グループ一覧取得失敗: ${response.status} ${JSON.stringify(data)}`); } const pageGroups = Array.isArray(data.groups) ? data.groups : []; groups.push(...pageGroups); const nextCursor = data?.responseMetaData?.nextCursor || ""; if (!nextCursor || nextCursor === cursor) { break; } cursor = nextCursor; } return groups; } function csvSafeJoin(values) { if (!Array.isArray(values) || values.length === 0) { return ""; } return values.join(";"); } function toCsvRows(groups) { return groups.map((group) => { const administrators = Array.isArray(group.administrators) ? group.administrators : []; const members = Array.isArray(group.members) ? group.members : []; const recipientAllowed = Array.isArray(group.membersAllowedToUseGroupEmailAsRecipient) ? group.membersAllowedToUseGroupEmailAsRecipient : []; const senderAllowed = Array.isArray(group.membersAllowedToUseGroupEmailAsSender) ? group.membersAllowedToUseGroupEmailAsSender : []; return { domainId: group.domainId ?? "", groupId: group.groupId || "", groupName: group.groupName || "", description: group.description || "", visible: group.visible ?? "", useServiceNotification: group.useServiceNotification ?? "", serviceManageable: group.serviceManageable ?? "", groupExternalKey: group.groupExternalKey || "", administratorsCount: administrators.length, administratorsUserIds: csvSafeJoin(administrators.map((a) => a.userId || "")), membersCount: members.length, memberUsersCount: members.filter((m) => m?.type === "USER").length, memberOrgUnitsCount: members.filter((m) => m?.type === "ORGUNIT").length, memberGroupsCount: members.filter((m) => m?.type === "GROUP").length, membersSummary: csvSafeJoin(members.map((m) => `${m.type || ""}:${m.id || ""}`)), useMessage: group.useMessage ?? "", useNote: group.useNote ?? "", useCalendar: group.useCalendar ?? "", useTask: group.useTask ?? "", useFolder: group.useFolder ?? "", useMail: group.useMail ?? "", groupEmail: group.groupEmail || "", aliasEmails: csvSafeJoin(group.aliasEmails || []), canReceiveExternalMail: group.canReceiveExternalMail ?? "", toExternalEmails: csvSafeJoin(group.toExternalEmails || []), mailRecipientAllowedCount: recipientAllowed.length, mailRecipientAllowedUserIds: csvSafeJoin(recipientAllowed.map((x) => x.userId || "")), mailSenderAllowedCount: senderAllowed.length, mailSenderAllowedUserIds: csvSafeJoin(senderAllowed.map((x) => x.userId || "")), }; }); } function writeCsv(outputPath, rows) { const outputDir = path.dirname(outputPath); fs.mkdirSync(outputDir, { recursive: true }); const csv = stringify(rows, { header: true, bom: true, }); fs.writeFileSync(outputPath, csv, "utf8"); } async function main() { if (typeof fetch !== "function") { throw new Error("この Node.js 実行環境は fetch に未対応です。Node.js 18 以上を使用してください。"); } const { domainId, outputPath } = parseArgs(); const accessToken = await getAccessToken(); const groups = await fetchAllGroups(accessToken, domainId); const rows = toCsvRows(groups); writeCsv(outputPath, rows); console.log(`取得件数: ${groups.length}`); console.log(`CSV出力先: ${outputPath}`); } main().catch((err) => { console.error("グループリスト出力に失敗しました。"); console.error(err.message); process.exit(1); });