487 lines
16 KiB
JavaScript
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);
|
|
});
|