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

664 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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);
});