const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { parse } = require('csv-parse/sync'); const IMPORT_DIR = path.join(__dirname, 'import'); const SUCCESS_DIR = path.join(__dirname, 'success'); const FAIL_DIR = path.join(__dirname, 'fail'); const DEFAULT_TIME_ZONE = process.env.LW_TIMEZONE || 'Asia/Tokyo'; const REQUIRED_CSV_HEADERS = ['登録先', '開始日', '開始時刻', '終了日', '終了時刻', '分類', '予定', '備考']; const LW_TOKEN_URL = 'https://auth.worksmobile.com/oauth2/v2.0/token'; const LW_API_BASE_URL = 'https://www.worksapis.com/v1.0'; const LW_EVENT_ENDPOINT_TEMPLATE = '/users/{userId}/calendar/events'; const PRIVATE_KEY_FILE = path.join(__dirname, 'private_20260306134304.key'); const LW_USER_LOOKUP_DOMAIN_ID = '401101389'; const LW_CLIENT_ID = 'tre8J_Tk8RblfsMSyZRh'; const LW_CLIENT_SECRET = 'O5_R5gcHxg'; const LW_SERVICE_ACCOUNT = 'wh4k8.serviceaccount@next-hd.co.jp'; const LW_PRIVATE_KEY = (fs.existsSync(PRIVATE_KEY_FILE) ? fs.readFileSync(PRIVATE_KEY_FILE, 'utf8') : ''); const LW_SCOPE = process.env.LW_SCOPE || 'calendar'; const LW_USER_LOOKUP_SCOPE = process.env.LW_USER_LOOKUP_SCOPE || 'user.read'; const LW_SEND_NOTIFICATION = process.env.LW_SEND_NOTIFICATION !== '0'; const LW_DRY_RUN = process.env.LW_DRY_RUN === '1'; // 本番に近い検証: 認証やデータ生成は実行し、API POSTのみ停止する const LW_PRE_IMPORT_TEST = process.env.LW_PRE_IMPORT_TEST === '1'; const LW_RETRY_MAX = Number(process.env.LW_RETRY_MAX || '3'); // 1分辺り240回がリミットのため、指数関数的に待機時間を増やす場合のベース時間(ms) const LW_RETRY_BASE_MS = Number(process.env.LW_RETRY_BASE_MS || '5000'); // 1分 const LW_API_WAIT_MS = Number(process.env.LW_API_WAIT_MS || '300'); // APIリクエスト間の最小待機時間(ms) let lastApiRequestAt = 0; function ensureRequiredEnv() { if (LW_DRY_RUN) { return; } const required = [ ['LW_CLIENT_ID', LW_CLIENT_ID], ['LW_CLIENT_SECRET', LW_CLIENT_SECRET], ['LW_SERVICE_ACCOUNT', LW_SERVICE_ACCOUNT], ['LW_PRIVATE_KEY', LW_PRIVATE_KEY] ]; const missing = required.filter(([, value]) => !value).map(([key]) => 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(scopeOverride = LW_SCOPE) { if (LW_DRY_RUN) { return 'dry-run-token'; } 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: scopeOverride }); 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 normalizeUserId(rawUser) { const mapText = process.env.LW_USER_MAP_JSON; if (!mapText) { return rawUser; } try { const map = JSON.parse(mapText); return map[rawUser] || rawUser; } catch (error) { throw new Error(`LW_USER_MAP_JSON のJSON解析に失敗しました: ${error.message}`); } } function extractUserNameFromFilename(fileName) { const base = path.basename(fileName, path.extname(fileName)); const matched = base.match(/^(.*?)(?:_スケジュール)?$/); const user = (matched && matched[1] ? matched[1] : base).trim(); if (!user) { throw new Error(`ユーザー名をファイル名から抽出できません: ${fileName}`); } return user; } function normalizeNameForCompare(value) { return String(value || '').replace(/[\s\u3000]/g, '').toLowerCase(); } function profileNames(profile) { const names = new Set(); const add = (v) => { if (v) names.add(String(v)); }; add(profile.name); add(profile.nickName); add(profile.displayName); add(profile.email); add(profile.userName); if (profile.userName && typeof profile.userName === 'object') { const u = profile.userName; add(u.firstName && u.lastName ? `${u.lastName} ${u.firstName}` : ''); add(u.givenName && u.familyName ? `${u.familyName} ${u.givenName}` : ''); add(u.firstName && u.lastName ? `${u.lastName}${u.firstName}` : ''); add(u.givenName && u.familyName ? `${u.familyName}${u.givenName}` : ''); add(u.displayName); add(u.fullName); } if (profile.name && typeof profile.name === 'object') { const n = profile.name; add(n.displayName); add(n.fullName); add(n.familyName && n.givenName ? `${n.familyName} ${n.givenName}` : ''); add(n.familyName && n.givenName ? `${n.familyName}${n.givenName}` : ''); } return Array.from(names); } async function fetchAllLineworksUsers(accessToken, domainId) { const users = []; let cursor = ''; while (true) { const params = new URLSearchParams(); params.set('count', '100'); if (domainId) { params.set('domainId', domainId); } if (cursor) { params.set('cursor', cursor); } const query = params.toString(); const apiUrl = `${LW_API_BASE_URL}/users${query ? `?${query}` : ''}`; const response = await lwFetch(apiUrl, { method: 'GET', headers: { Authorization: `Bearer ${accessToken}` } }); const text = await response.text(); let data; try { data = JSON.parse(text); } catch (error) { throw new Error('LINE WORKSユーザー一覧の解析に失敗しました'); } if (!response.ok) { throw new Error(`LINE WORKSユーザー一覧取得に失敗しました: ${response.status} ${JSON.stringify(data)}`); } const pageUsers = Array.isArray(data.users) ? data.users : []; users.push(...pageUsers); const nextCursor = data?.responseMetaData?.nextCursor || ''; if (!nextCursor || nextCursor === cursor) { break; } cursor = nextCursor; } return users; } function findLineworksUserByName(users, sourceUserName) { const normalizedSource = normalizeNameForCompare(sourceUserName); for (const user of users) { const candidates = profileNames(user); const matched = candidates.some(name => normalizeNameForCompare(name) === normalizedSource); if (matched) { return user; } } return null; } function normalizeLoginIdForCompare(value) { return String(value || '').trim().toLowerCase(); } function loginIdCandidates(profile) { const ids = new Set(); const add = (v) => { if (v) ids.add(String(v)); }; add(profile.email); add(profile.loginId); add(profile.userId); if (typeof profile.userName === 'string') { add(profile.userName); } if (profile.account && typeof profile.account === 'object') { add(profile.account.loginId); add(profile.account.email); } return Array.from(ids); } function findLineworksUserByLoginId(users, sourceLoginId) { const normalizedSource = normalizeLoginIdForCompare(sourceLoginId); for (const user of users) { const candidates = loginIdCandidates(user); const matched = candidates.some(id => normalizeLoginIdForCompare(id) === normalizedSource); if (matched) { return user; } } return null; } function parseCsvDate(value) { const normalized = String(value || '').trim(); const matched = normalized.match(/^(\d{4})[/-](\d{1,2})[/-](\d{1,2})$/); if (!matched) { throw new Error(`日付形式が不正です: ${value}`); } const [, y, m, d] = matched; return `${y}-${String(Number(m)).padStart(2, '0')}-${String(Number(d)).padStart(2, '0')}`; } function parseCsvTime(value) { const normalized = String(value || '').trim(); if (!normalized) { return ''; } const hm = normalized.match(/^(\d{1,2}):(\d{2})$/); if (hm) { const hour = Number(hm[1]); const minute = Number(hm[2]); if (hour > 23 || minute > 59) { throw new Error(`時刻形式が不正です: ${value}`); } return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}:00`; } const hhmm = normalized.match(/^(\d{2})(\d{2})$/); if (hhmm) { const hour = Number(hhmm[1]); const minute = Number(hhmm[2]); if (hour > 23 || minute > 59) { throw new Error(`時刻形式が不正です: ${value}`); } return `${hhmm[1]}:${hhmm[2]}:00`; } throw new Error(`時刻形式が不正です: ${value}`); } function sanitizeTextField(value) { // LINE WORKS API向けに制御文字を整理し、改行はLFに統一する return String(value || '') .replace(/\r\n?/g, '\n') .replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F]/g, '') .trim(); } function encodeNewlineForApi(value) { return String(value || '').replace(/\n/g, '\\n'); } function toLineworksDateTime(dateText, timeText) { // LINE WORKS仕様: dateTimeはYYYY-MM-DDTHH:mm:ss(オフセットなし) return `${dateText}T${timeText}`; } 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 isRetryableCalendarError(status, responseText) { if (status >= 500 || status === 429) { return true; } try { const body = JSON.parse(responseText); return body && body.code === 'SERVICE_UNAVAILABLE'; } catch (_) { return false; } } function eventStartForLog(eventComponent) { const start = eventComponent && eventComponent.start ? eventComponent.start : {}; if (start.dateTime) { return String(start.dateTime).replace('T', ' '); } if (start.date) { return String(start.date); } return '(date-unknown)'; } function eventLabelForLog(eventComponent) { return `${eventStartForLog(eventComponent)} ${eventComponent.summary}`; } function nextDate(dateText) { const d = new Date(`${dateText}T00:00:00`); if (Number.isNaN(d.getTime())) { throw new Error(`日付変換に失敗しました: ${dateText}`); } d.setDate(d.getDate() + 1); const yyyy = d.getFullYear(); const MM = String(d.getMonth() + 1).padStart(2, '0'); const dd = String(d.getDate()).padStart(2, '0'); return `${yyyy}-${MM}-${dd}`; } function validateCsvHeaders(headers) { const missing = REQUIRED_CSV_HEADERS.filter(header => !headers.includes(header)); if (missing.length > 0) { throw new Error(`必須ヘッダ不足: ${missing.join(', ')}`); } } function extractCsvHeaders(content) { const headerRows = parse(content, { to_line: 1, bom: true, relax_quotes: true }); if (!Array.isArray(headerRows) || headerRows.length === 0) { return []; } return Array.isArray(headerRows[0]) ? headerRows[0].map(v => String(v || '').trim()) : []; } function buildEventComponentFromCsvRow(row) { const startDate = parseCsvDate(row['開始日']); const endDate = parseCsvDate(row['終了日']); const startTime = parseCsvTime(row['開始時刻']); const endTime = parseCsvTime(row['終了時刻']); const category = sanitizeTextField(row['分類']); const rawSummary = sanitizeTextField(row['予定']); const summaryBase = rawSummary === '----' ? (category || '(件名なし)') : (rawSummary || '(件名なし)'); const summary = encodeNewlineForApi(summaryBase); const registerTo = sanitizeTextField(row['登録先']); const note = sanitizeTextField(row['備考']); const descriptionParts = []; if (category) descriptionParts.push(`分類: ${category}`); if (registerTo) descriptionParts.push(`登録先: ${registerTo}`); if (note) descriptionParts.push(note); const component = { summary, description: descriptionParts.join('\n') }; if (!startTime && !endTime) { component.start = { date: startDate }; // 終日予定は終了日を排他的に扱うため1日加算 component.end = { date: nextDate(endDate) }; return component; } if (startTime === '00:00:00' && endTime === '00:00:00') { // 00:00-00:00 は開始日の終日予定として扱う component.start = { date: startDate }; component.end = { date: nextDate(startDate) }; return component; } if (!startTime || !endTime) { throw new Error('開始時刻と終了時刻は両方指定してください'); } component.start = { dateTime: toLineworksDateTime(startDate, startTime), timeZone: DEFAULT_TIME_ZONE }; component.end = { dateTime: toLineworksDateTime(endDate, endTime), timeZone: DEFAULT_TIME_ZONE }; return component; } async function createCalendarEvent(accessToken, userId, eventComponent) { const endpoint = LW_EVENT_ENDPOINT_TEMPLATE.replace('{userId}', encodeURIComponent(userId)); const url = `${LW_API_BASE_URL}${endpoint}`; const requestBody = { eventComponents: [eventComponent], sendNotification: LW_SEND_NOTIFICATION }; if (LW_DRY_RUN) { console.log(`[DRY-RUN] ${userId} ${eventLabelForLog(eventComponent)}`); return; } if (LW_PRE_IMPORT_TEST) { console.log(`[PRE-IMPORT-TEST] POST skipped: ${userId} ${eventLabelForLog(eventComponent)} -> ${url}`); return; } let lastError = null; for (let attempt = 1; attempt <= LW_RETRY_MAX; attempt += 1) { const response = await lwFetch(url, { method: 'POST', headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json' }, body: JSON.stringify(requestBody) }); if (response.ok) { return; } const detail = await response.text(); lastError = new Error(`登録失敗: ${response.status} ${detail}`); if (!isRetryableCalendarError(response.status, detail) || attempt === LW_RETRY_MAX) { break; } const waitMs = LW_RETRY_BASE_MS * (2 ** (attempt - 1)); console.warn(`[RETRY] ${eventLabelForLog(eventComponent)} attempt=${attempt}/${LW_RETRY_MAX} wait=${waitMs}ms`); await sleep(waitMs); } throw lastError; } async function processCsvFile(fileName, accessToken, users) { const filePath = path.join(IMPORT_DIR, fileName); const content = fs.readFileSync(filePath, 'utf8'); const sourceUserName = extractUserNameFromFilename(fileName); const mappedName = normalizeUserId(sourceUserName); let profile = findLineworksUserByName(users, mappedName); if (!profile) { profile = findLineworksUserByLoginId(users, mappedName); if (profile) { console.warn(`[MATCH-FALLBACK] ${fileName}: 名前照合NG -> ログインID照合OK (${mappedName})`); } } if (!profile) { throw new Error(`ユーザー照合失敗(名前/ログインID): file='${sourceUserName}', key='${mappedName}'`); } const userId = profile.userId || profile.id; if (!userId) { throw new Error(`ユーザーID未取得: file='${sourceUserName}'`); } const records = parse(content, { columns: true, skip_empty_lines: true, bom: true }); validateCsvHeaders(extractCsvHeaders(content)); if (records.length === 0) { console.log(`[SKIP] ${fileName}: データ行なし`); return { success: 0, failed: 1 }; } let success = 0; let failed = 0; for (let i = 0; i < records.length; i += 1) { const row = records[i]; try { const eventComponent = buildEventComponentFromCsvRow(row); await createCalendarEvent(accessToken, userId, eventComponent); success += 1; console.log(`[OK] ${fileName} -> ${userId} ${eventLabelForLog(eventComponent)}`); } catch (error) { failed += 1; console.error(`[NG] ${fileName} 行${i + 2}: ${error.message}`); } } return { success, failed }; } function moveProcessedFile(fileName, destinationDir) { const sourcePath = path.join(IMPORT_DIR, fileName); if (!fs.existsSync(sourcePath)) { return; } if (!fs.existsSync(destinationDir)) { fs.mkdirSync(destinationDir, { recursive: true }); } let targetPath = path.join(destinationDir, fileName); if (fs.existsSync(targetPath)) { const parsed = path.parse(fileName); const stamp = Date.now(); targetPath = path.join(destinationDir, `${parsed.name}_${stamp}${parsed.ext}`); } fs.renameSync(sourcePath, targetPath); console.log(`[MOVE] ${fileName} -> ${targetPath}`); } async function main() { ensureRequiredEnv(); if (LW_DRY_RUN) { console.log('mode: DRY_RUN (認証・登録をスキップ)'); } else if (LW_PRE_IMPORT_TEST) { console.log('mode: PRE_IMPORT_TEST (登録直前まで実行、POSTのみスキップ)'); } if (!fs.existsSync(IMPORT_DIR)) { console.log('importフォルダが見つかりません'); return; } const files = fs.readdirSync(IMPORT_DIR) .filter(file => file.toLowerCase().endsWith('.csv')) .sort(); if (files.length === 0) { console.log('importフォルダにcsvファイルが見つかりません'); return; } const accessToken = await getAccessToken(LW_SCOPE); const userLookupToken = await getAccessToken(LW_USER_LOOKUP_SCOPE); const users = await fetchAllLineworksUsers(userLookupToken, LW_USER_LOOKUP_DOMAIN_ID); if (users.length === 0) { throw new Error('LINE WORKSユーザー一覧が0件です'); } console.log(`ユーザー一覧取得: ${users.length}件`); let totalSuccess = 0; let totalFailed = 0; let successFiles = 0; let failedFiles = 0; for (const file of files) { console.log(`--- processing ${file} ---`); let result; try { result = await processCsvFile(file, accessToken, users); } catch (error) { console.error(`[NG] ${file}: ${error.message}`); result = { success: 0, failed: 1 }; } totalSuccess += result.success; totalFailed += result.failed; if (result.failed === 0 && result.success > 0) { moveProcessedFile(file, SUCCESS_DIR); successFiles += 1; } else { moveProcessedFile(file, FAIL_DIR); failedFiles += 1; } } console.log(`ファイル移動結果: success=${successFiles}, fail=${failedFiles}`); console.log(`完了: success=${totalSuccess}, failed=${totalFailed}`); } main().catch(error => { console.error(error.message || error); process.exit(1); });