ken_nogi/NodeJS/アルファオフィススケジュール/getScheduleList.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

487 lines
16 KiB
JavaScript

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 <id|email|me> 対象ユーザー (default: me)');
console.log(' --from <datetime|date> 取得開始日時 例) 2026-04-01 or 2026-04-01T00:00:00+09:00');
console.log(' --until <datetime|date> 取得終了日時 例) 2026-04-30 or 2026-04-30T23:59:59+09:00');
console.log(' --summary-contains <text> 件名(summary)に text を含む予定のみ対象');
console.log(' --calendarId <id> 対象カレンダーを絞り込み');
console.log(' --out <path> 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);
});