const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { stringify } = require('csv-stringify/sync'); 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_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') : ''); const LW_SCOPE = process.env.LW_SCOPE || 'calendar'; const LW_API_WAIT_MS = Number(process.env.LW_API_WAIT_MS || '300'); const DEFAULT_TIMEZONE_OFFSET = process.env.LW_DEFAULT_OFFSET || '+09:00'; let lastApiRequestAt = 0; function ensureFetchAvailable() { if (typeof fetch !== 'function') { throw new Error('このスクリプトは Node.js 18+ (fetch対応) を想定しています'); } } function ensureRequiredEnv() { 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 sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function waitApiInterval() { const now = Date.now(); const elapsed = now - lastApiRequestAt; const waitMs = Math.max(0, LW_API_WAIT_MS - elapsed); if (waitMs > 0) { await sleep(waitMs); } lastApiRequestAt = Date.now(); } async function lwFetch(url, options) { await waitApiInterval(); return fetch(url, options); } 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) { 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 lwFetch(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; } function parseArgs(argv) { const args = { userId: process.env.LW_TARGET_USER_ID || 'me', from: '', until: '', summaryContains: process.env.LW_SUMMARY_CONTAINS || '', out: '', deleteMode: false, yes: false, dryRun: false, sendNotification: false, calendarId: '' }; for (let i = 0; i < argv.length; i += 1) { const a = argv[i]; if (a === '--userId' && argv[i + 1]) { args.userId = String(argv[i + 1]); i += 1; continue; } if (a === '--from' && argv[i + 1]) { args.from = String(argv[i + 1]); i += 1; continue; } if (a === '--until' && argv[i + 1]) { args.until = String(argv[i + 1]); i += 1; continue; } if (a === '--summary-contains' && argv[i + 1]) { args.summaryContains = String(argv[i + 1]); i += 1; continue; } if (a === '--out' && argv[i + 1]) { args.out = String(argv[i + 1]); i += 1; continue; } if (a === '--calendarId' && argv[i + 1]) { args.calendarId = String(argv[i + 1]); i += 1; continue; } if (a === '--delete') { args.deleteMode = true; continue; } if (a === '--yes') { args.yes = true; continue; } if (a === '--dry-run') { args.dryRun = true; continue; } if (a === '--sendNotification') { args.sendNotification = true; continue; } if (a === '--help' || a === '-h') { args.help = true; } } return args; } function printHelp() { console.log('LINE WORKS: 指定ユーザー・期間の予定一覧取得(CSV保存)と一括削除'); console.log(''); console.log('使い方:'); console.log(' node getScheduleList.js --userId me --from 2026-04-01 --until 2026-04-30'); console.log(' node getScheduleList.js --userId user@example.com --from 2026-04-01T00:00:00+09:00 --until 2026-04-30T23:59:59+09:00 --out ./schedule_extract.csv'); console.log(' node getScheduleList.js --userId me --from 2026-04-01 --until 2026-04-30 --summary-contains 打合せ'); console.log(' node getScheduleList.js --userId me --from 2026-04-01 --until 2026-04-30 --delete --yes'); console.log(' node getScheduleList.js --userId me --from 2026-04-01 --until 2026-04-30 --calendarId calendar-xxxx --delete --yes --dry-run'); console.log(''); console.log('オプション:'); console.log(' --userId 対象ユーザー (default: me)'); console.log(' --from 取得開始日時 例) 2026-04-01 or 2026-04-01T00:00:00+09:00'); console.log(' --until 取得終了日時 例) 2026-04-30 or 2026-04-30T23:59:59+09:00'); console.log(' --summary-contains 件名(summary)に text を含む予定のみ対象'); console.log(' --calendarId 対象カレンダーを絞り込み'); console.log(' --out CSV出力先 (未指定時は自動命名)'); console.log(' --delete 取得した予定を削除'); console.log(' --yes 削除実行を確定'); console.log(' --dry-run 削除は行わず対象だけ表示'); console.log(' --sendNotification 削除通知を送る (default: false)'); console.log(''); console.log('主な環境変数:'); console.log(' LW_PRIVATE_KEY_FILE, LW_CLIENT_ID, LW_CLIENT_SECRET, LW_SERVICE_ACCOUNT'); console.log(' LW_SUMMARY_CONTAINS (summary絞り込み文字列)'); console.log(' LW_SCOPE (default: calendar), LW_API_WAIT_MS (default: 300)'); } function normalizeDateTimeInput(value, kind) { const v = String(value || '').trim(); if (!v) { throw new Error(`--${kind} を指定してください`); } if (/^\d{4}-\d{2}-\d{2}$/.test(v)) { if (kind === 'from') return `${v}T00:00:00${DEFAULT_TIMEZONE_OFFSET}`; return `${v}T23:59:59${DEFAULT_TIMEZONE_OFFSET}`; } if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/.test(v)) { return `${v}${DEFAULT_TIMEZONE_OFFSET}`; } if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/.test(v)) { return v; } throw new Error(`--${kind} の形式が不正です: ${value}`); } function compactDateForFileName(value) { return String(value || '') .replace(/[^0-9]/g, '') .slice(0, 14); } function safeCell(value) { const raw = String(value == null ? '' : value); // Excel対策: 数式注入を防止 if (/^[=+\-@]/.test(raw)) { return `'${raw}`; } return raw; } async function fetchEventList(accessToken, userId, fromDateTime, untilDateTime) { const endpoint = `/users/${encodeURIComponent(userId)}/calendar/events`; const params = new URLSearchParams({ fromDateTime, untilDateTime }); const url = `${LW_API_BASE_URL}${endpoint}?${params.toString()}`; const response = await lwFetch(url, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }); const text = await response.text(); let data; try { data = JSON.parse(text); } catch (error) { throw new Error(`予定一覧の解析に失敗しました: ${text.slice(0, 200)}`); } if (!response.ok) { throw new Error(`予定一覧取得に失敗しました: ${response.status} ${JSON.stringify(data)}`); } return Array.isArray(data.events) ? data.events : []; } function flattenEvents(events, filterCalendarId) { const rows = []; for (const group of events) { const calendarId = String(group?.organizerCalendarId || ''); if (filterCalendarId && calendarId !== filterCalendarId) { continue; } const components = Array.isArray(group?.eventComponents) ? group.eventComponents : []; for (const ev of components) { const startDate = ev?.start?.date || ''; const startDateTime = ev?.start?.dateTime || ''; const endDate = ev?.end?.date || ''; const endDateTime = ev?.end?.dateTime || ''; const timeZone = ev?.start?.timeZone || ev?.end?.timeZone || ''; const isAllDay = Boolean(startDate && !startDateTime); rows.push({ organizerCalendarId: calendarId, eventId: String(ev?.eventId || ''), summary: String(ev?.summary || ''), description: String(ev?.description || ''), startDate, startDateTime, endDate, endDateTime, timeZone, isAllDay, organizerEmail: String(ev?.organizer?.email || ''), organizerName: String(ev?.organizer?.displayName || ''), visibility: String(ev?.visibility || ''), updatedDateTime: String(ev?.updatedTime?.dateTime || '') }); } } return rows; } function filterRowsBySummary(rows, summaryContains) { const keyword = String(summaryContains || '').trim(); if (!keyword) { return rows; } return rows.filter(row => String(row.summary || '').includes(keyword)); } function writeCsv(filePath, rows) { const columns = [ 'organizerCalendarId', 'eventId', 'summary', 'description', 'startDate', 'startDateTime', 'endDate', 'endDateTime', 'timeZone', 'isAllDay', 'organizerEmail', 'organizerName', 'visibility', 'updatedDateTime' ]; const safeRows = rows.map(r => { const out = {}; for (const c of columns) { out[c] = safeCell(r[c]); } return out; }); const csv = stringify(safeRows, { header: true, columns }); fs.writeFileSync(filePath, csv, 'utf8'); } async function deleteEvent(accessToken, userId, calendarId, eventId, sendNotification) { const endpoint = `/users/${encodeURIComponent(userId)}/calendars/${encodeURIComponent(calendarId)}/events/${encodeURIComponent(eventId)}`; const query = new URLSearchParams({ sendNotification: sendNotification ? 'true' : 'false' }); const url = `${LW_API_BASE_URL}${endpoint}?${query.toString()}`; const response = await lwFetch(url, { method: 'DELETE', headers: { Authorization: `Bearer ${accessToken}` } }); if (response.status === 204) { return; } const detail = await response.text(); throw new Error(`削除失敗: calendarId=${calendarId}, eventId=${eventId}, status=${response.status}, detail=${detail}`); } async function bulkDelete(accessToken, userId, rows, args) { const uniqueTargets = []; const seen = new Set(); for (const row of rows) { const calendarId = String(row.organizerCalendarId || ''); const eventId = String(row.eventId || ''); if (!calendarId || !eventId) { continue; } const key = `${calendarId}::${eventId}`; if (seen.has(key)) { continue; } seen.add(key); uniqueTargets.push({ calendarId, eventId, summary: row.summary || '' }); } if (uniqueTargets.length === 0) { console.log('削除対象がありません'); return; } if (!args.yes) { throw new Error('--delete 実行時は安全のため --yes が必要です'); } if (args.dryRun) { for (const t of uniqueTargets) { console.log(`[DRY-RUN] delete calendarId=${t.calendarId} eventId=${t.eventId} summary=${t.summary}`); } console.log(`DRY-RUN完了: ${uniqueTargets.length}件`); return; } let success = 0; let failed = 0; for (const t of uniqueTargets) { try { await deleteEvent(accessToken, userId, t.calendarId, t.eventId, args.sendNotification); success += 1; console.log(`[DELETE-OK] calendarId=${t.calendarId} eventId=${t.eventId} summary=${t.summary}`); } catch (error) { failed += 1; console.error(`[DELETE-NG] calendarId=${t.calendarId} eventId=${t.eventId} ${error.message || error}`); } } console.log(`削除結果: success=${success}, failed=${failed}, total=${uniqueTargets.length}`); if (failed > 0) { process.exitCode = 1; } } async function main() { ensureFetchAvailable(); ensureRequiredEnv(); const args = parseArgs(process.argv.slice(2)); if (args.help) { printHelp(); return; } const fromDateTime = normalizeDateTimeInput(args.from, 'from'); const untilDateTime = normalizeDateTimeInput(args.until, 'until'); const outPath = args.out ? path.resolve(args.out) : path.resolve(__dirname, `schedule_extract_${compactDateForFileName(fromDateTime)}_${compactDateForFileName(untilDateTime)}.csv`); const accessToken = await getAccessToken(LW_SCOPE); const events = await fetchEventList(accessToken, args.userId, fromDateTime, untilDateTime); const flattenedRows = flattenEvents(events, args.calendarId); const rows = filterRowsBySummary(flattenedRows, args.summaryContains); writeCsv(outPath, rows); console.log(`CSV保存完了: ${outPath}`); if (args.summaryContains) { console.log(`summary絞り込み: "${args.summaryContains}"`); } console.log(`取得件数: ${rows.length}`); if (args.deleteMode) { await bulkDelete(accessToken, args.userId, rows, args); } } main().catch(error => { console.error(error.message || error); process.exit(1); });