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

637 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 iconv = require('iconv-lite');
const { parse } = require('csv-parse/sync');
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 LW_BOARD_LIST_ENDPOINT = '/boards';
const LW_BOARD_POST_CREATE_ENDPOINT_TEMPLATE = '/boards/{boardId}/posts';
// 認証設定importCal.js から転記・流用)
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 TARGET_BOARD_NAME = '私たちの羅針盤(過去分)';
// Scope (importCal.js と同様に環境変数で上書き可能)
const LW_SCOPE = process.env.LW_SCOPE || 'board board.read';
// 実行制御
const LW_DRY_RUN = process.env.LW_DRY_RUN === '1';
const LW_POST_LIMIT = Number(process.env.LW_POST_LIMIT || '1'); // 1=まずは1件だけ, 0=全件
const LW_ITEM_WAIT_MS = Number(process.env.LW_ITEM_WAIT_MS || '300');
const LW_API_WAIT_MS = Number(process.env.LW_API_WAIT_MS || '300');
const LW_RETRY_MAX = Number(process.env.LW_RETRY_MAX || '3');
const LW_RETRY_BASE_MS = Number(process.env.LW_RETRY_BASE_MS || '1000');
const LW_SEND_NOTIFICATIONS = process.env.LW_SEND_NOTIFICATIONS === '1';
const LW_ENABLE_COMMENT = process.env.LW_ENABLE_COMMENT !== '0';
const LW_KEY_DEBUG = process.env.LW_KEY_DEBUG === '1';
const LW_EXPORT_PUBLIC_KEY = (process.env.LW_EXPORT_PUBLIC_KEY || '').trim();
const LW_EXPORT_INDEXES = (process.env.LW_EXPORT_INDEXES || '').trim(); // 例: "121,122,183"
const LW_EXPORT_INDEX_ERROR = (process.env.LW_EXPORT_INDEX_ERROR || '').trim();
// 文字コードboardContents.CsvがShift-JISの場合に備える
const LW_CSV_ENCODING = (process.env.LW_CSV_ENCODING || '').trim(); // '', 'utf8', 'cp932', 'shift_jis'
const BOARD_CONTENTS_FILE = process.env.LW_SOURCE_CSV
? path.resolve(process.env.LW_SOURCE_CSV)
: path.join(__dirname, 'boardContents.Csv');
const FAIL_DIR = process.env.LW_FAIL_CSV_DIR
? path.resolve(process.env.LW_FAIL_CSV_DIR)
: path.join(__dirname, 'fail');
const LW_FAIL_CSV_BOM = process.env.LW_FAIL_CSV_BOM !== '0';
const REQUIRED_CSV_HEADERS = ['タイトル', '内容', '日付', '投稿者'];
let lastApiRequestAt = 0;
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function normalizeForCompare(value) {
return String(value || '').replace(/[\s\u3000]/g, '').toLowerCase();
}
function escapeHtml(text) {
return String(text || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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 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 decodeCsvBuffer(buffer) {
const tryDecode = (enc) => {
const name = enc || 'utf8';
if (!enc || enc.toLowerCase() === 'utf8') {
return buffer.toString('utf8');
}
if (enc.toLowerCase() === 'cp932' || enc.toLowerCase() === 'shift_jis' || enc.toLowerCase() === 'shift-jis') {
return iconv.decode(buffer, 'cp932');
}
return iconv.decode(buffer, enc);
};
if (LW_CSV_ENCODING) {
return tryDecode(LW_CSV_ENCODING);
}
// 自動フォールバック: utf8->cp932
const utf8 = tryDecode('utf8');
try {
// 軽くパースしてみる失敗したらcp932へ
parse(utf8, { columns: true, bom: true, relax_quotes: true, skip_empty_lines: true });
return utf8;
} catch (_) {
return tryDecode('cp932');
}
}
function base64UrlEncode(value) {
return Buffer.from(value)
.toString('base64')
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
function keyFingerprintSha256() {
// 公開鍵(SPKI)のSHA-256で識別秘密鍵自体は出力しない
const privateKey = crypto.createPrivateKey(LW_PRIVATE_KEY);
const publicKey = crypto.createPublicKey(privateKey);
const spkiDer = publicKey.export({ type: 'spki', format: 'der' });
const digest = crypto.createHash('sha256').update(spkiDer).digest('hex');
return digest;
}
function exportPublicKeyPem(targetPath) {
const privateKey = crypto.createPrivateKey(LW_PRIVATE_KEY);
const publicKey = crypto.createPublicKey(privateKey);
const pem = publicKey.export({ type: 'spki', format: 'pem' });
fs.writeFileSync(targetPath, pem, 'utf8');
}
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}`;
}
function hasCredentials() {
if (!LW_CLIENT_ID || !LW_CLIENT_SECRET || !LW_SERVICE_ACCOUNT || !LW_PRIVATE_KEY) {
return false;
}
return true;
}
function ensureRequiredEnvForNetwork() {
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(', ')}`);
}
}
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 isRetryable(status) {
return status >= 500 || status === 429;
}
function parseJsonWithQuotedInt64(text, keys) {
// JSON.parseの数値(IEEE754)でint64が壊れるのを避けるため、指定キーの整数を文字列にしてからparseする
// 例: "boardId": 4080... -> "boardId":"4080..."
let patched = String(text || '');
for (const key of keys) {
const re = new RegExp(`"${key}"\\s*:\\s*(\\d+)`, 'g');
patched = patched.replace(re, `"${key}":"$1"`);
}
return JSON.parse(patched);
}
async function getAccessToken(scope) {
ensureRequiredEnvForNetwork();
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;
}
async function fetchAllBoards(accessToken, role) {
const boards = [];
let cursor = '';
while (true) {
const params = new URLSearchParams();
params.set('count', '200');
if (role) params.set('role', role);
if (cursor) params.set('cursor', cursor);
const url = `${LW_API_BASE_URL}${LW_BOARD_LIST_ENDPOINT}?${params.toString()}`;
const response = await lwFetch(url, {
method: 'GET',
headers: {
Authorization: `Bearer ${accessToken}`
}
});
const text = await response.text();
let data;
try {
data = parseJsonWithQuotedInt64(text, ['boardId']);
} catch (error) {
throw new Error(`掲示板リストの解析に失敗しました: ${text.slice(0, 200)}`);
}
if (!response.ok) {
throw new Error(`掲示板リスト取得に失敗しました: ${response.status} ${JSON.stringify(data)}`);
}
const pageBoards = Array.isArray(data.boards) ? data.boards : [];
boards.push(...pageBoards);
const nextCursor = data?.responseMetaData?.nextCursor || '';
if (!nextCursor || nextCursor === cursor) {
break;
}
cursor = nextCursor;
}
return boards;
}
function findBoardIdByName(boards, boardName) {
const normalizedTarget = normalizeForCompare(boardName);
const matched = boards.find(b => normalizeForCompare(b.boardName) === normalizedTarget);
return matched ? String(matched.boardId) : null;
}
function buildPostBodyHtml(row) {
const dateText = parseCsvDate(row['日付']);
const author = String(row['投稿者'] || '').trim();
const content = String(row['内容'] || '');
const header = `日付: ${escapeHtml(dateText)}<br/>投稿者: ${escapeHtml(author)}`;
const body = escapeHtml(content).replace(/\r\n?/g, '\n').replace(/\n/g, '<br/>');
return `<p>${header}</p><hr/><p>${body}</p>`;
}
async function createBoardPost(accessToken, boardId, row) {
const title = String(row['タイトル'] || '').trim();
if (!title) {
throw new Error('タイトルが空です');
}
const body = buildPostBodyHtml(row);
const endpoint = LW_BOARD_POST_CREATE_ENDPOINT_TEMPLATE.replace('{boardId}', encodeURIComponent(String(boardId)));
const url = `${LW_API_BASE_URL}${endpoint}`;
const requestBody = {
title,
body,
enableComment: LW_ENABLE_COMMENT,
sendNotifications: LW_SEND_NOTIFICATIONS
};
if (LW_DRY_RUN) {
console.log(`[DRY-RUN] POST /boards/${boardId}/posts title='${title}' date='${row['日付']}' author='${row['投稿者']}'`);
return { skipped: true };
}
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) {
const okText = await response.text();
try {
return parseJsonWithQuotedInt64(okText, ['boardId', 'postId']);
} catch (_) {
return {};
}
}
const detail = await response.text();
lastError = new Error(`投稿失敗: ${response.status} ${detail}`);
if (!isRetryable(response.status) || attempt === LW_RETRY_MAX) {
break;
}
const waitMs = LW_RETRY_BASE_MS * (2 ** (attempt - 1));
console.warn(`[RETRY] title='${title}' attempt=${attempt}/${LW_RETRY_MAX} wait=${waitMs}ms`);
await sleep(waitMs);
}
throw lastError;
}
function loadBoardContentsCsv() {
if (!fs.existsSync(BOARD_CONTENTS_FILE)) {
throw new Error(`CSVが見つかりません: ${BOARD_CONTENTS_FILE}`);
}
const buffer = fs.readFileSync(BOARD_CONTENTS_FILE);
const content = decodeCsvBuffer(buffer);
validateCsvHeaders(extractCsvHeaders(content));
const records = parse(content, {
columns: true,
skip_empty_lines: true,
bom: true,
relax_quotes: true
});
return Array.isArray(records) ? records : [];
}
function limitRecords(records) {
if (!Number.isFinite(LW_POST_LIMIT) || LW_POST_LIMIT < 0) {
return records;
}
if (LW_POST_LIMIT === 0) {
return records;
}
return records.slice(0, LW_POST_LIMIT);
}
function timestampForFileName() {
const d = new Date();
const yyyy = d.getFullYear();
const MM = String(d.getMonth() + 1).padStart(2, '0');
const dd = String(d.getDate()).padStart(2, '0');
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
return `${yyyy}${MM}${dd}-${hh}${mm}${ss}`;
}
function writeFailedRowsCsv(failedRows) {
if (!Array.isArray(failedRows) || failedRows.length === 0) {
return '';
}
if (!fs.existsSync(FAIL_DIR)) {
fs.mkdirSync(FAIL_DIR, { recursive: true });
}
const sourceBase = path.basename(BOARD_CONTENTS_FILE, path.extname(BOARD_CONTENTS_FILE));
const outPath = path.join(FAIL_DIR, `${sourceBase}_failed_${timestampForFileName()}.csv`);
const columns = [...REQUIRED_CSV_HEADERS, '行番号', 'エラー'];
const csv = stringify(failedRows, {
header: true,
columns,
quoted: true,
record_delimiter: '\n'
});
const payload = LW_FAIL_CSV_BOM ? `\uFEFF${csv}` : csv;
fs.writeFileSync(outPath, payload, 'utf8');
return outPath;
}
function parseRecordIndexes(text) {
if (!text) return [];
const parts = String(text)
.split(/[\s,]+/)
.map(v => v.trim())
.filter(Boolean);
const indexes = [];
for (const part of parts) {
const n = Number(part);
if (!Number.isFinite(n) || n < 1 || !Number.isInteger(n)) {
throw new Error(`LW_EXPORT_INDEXES が不正です: '${part}'`);
}
indexes.push(n);
}
return Array.from(new Set(indexes)).sort((a, b) => a - b);
}
function writePickedRowsCsv(records, recordIndexes) {
if (!Array.isArray(recordIndexes) || recordIndexes.length === 0) {
return '';
}
const rows = [];
for (const idx of recordIndexes) {
const row = records[idx - 1];
if (!row) {
rows.push({
'タイトル': '',
'内容': '',
'日付': '',
'投稿者': '',
'行番号': idx + 1,
'エラー': `index out of range: ${idx}`
});
continue;
}
rows.push({
'タイトル': row['タイトル'],
'内容': row['内容'],
'日付': row['日付'],
'投稿者': row['投稿者'],
'行番号': idx + 1, // CSVヘッダ行を1行目として数える
'エラー': LW_EXPORT_INDEX_ERROR
});
}
if (!fs.existsSync(FAIL_DIR)) {
fs.mkdirSync(FAIL_DIR, { recursive: true });
}
const sourceBase = path.basename(BOARD_CONTENTS_FILE, path.extname(BOARD_CONTENTS_FILE));
const outPath = path.join(FAIL_DIR, `${sourceBase}_picked_${timestampForFileName()}.csv`);
const columns = [...REQUIRED_CSV_HEADERS, '行番号', 'エラー'];
const csv = stringify(rows, {
header: true,
columns,
quoted: true,
record_delimiter: '\n'
});
const payload = LW_FAIL_CSV_BOM ? `\uFEFF${csv}` : csv;
fs.writeFileSync(outPath, payload, 'utf8');
return outPath;
}
async function main() {
if (LW_KEY_DEBUG) {
try {
const fp = keyFingerprintSha256();
const keyFileLabel = fs.existsSync(PRIVATE_KEY_FILE) ? PRIVATE_KEY_FILE : '(missing)';
console.log(`[KEY] privateKeySource=${process.env.LW_PRIVATE_KEY ? 'env:LW_PRIVATE_KEY' : `file:${keyFileLabel}`}`);
console.log(`[KEY] publicKey.spki.sha256=${fp}`);
} catch (error) {
console.warn(`[KEY] 鍵の読み込み/変換に失敗しました: ${error.message}`);
}
}
if (LW_EXPORT_PUBLIC_KEY) {
try {
const outPath = path.resolve(LW_EXPORT_PUBLIC_KEY);
exportPublicKeyPem(outPath);
console.log(`[KEY] exported public key: ${outPath}`);
} catch (error) {
console.warn(`[KEY] 公開鍵の書き出しに失敗しました: ${error.message}`);
}
}
const records = loadBoardContentsCsv();
if (records.length === 0) {
console.log('boardContents.Csv に投稿データがありません');
return;
}
// 直近実行の失敗番号など、任意のレコード番号だけをCSVへ書き出す投稿はしない
if (LW_EXPORT_INDEXES) {
const indexes = parseRecordIndexes(LW_EXPORT_INDEXES);
const outPath = writePickedRowsCsv(records, indexes);
console.log(`[EXPORT] rows=${indexes.join(',')} -> ${outPath}`);
return;
}
const targetRecords = limitRecords(records);
const isPostingAll = LW_POST_LIMIT === 0;
if (LW_DRY_RUN) {
console.log('mode: DRY_RUN (API POSTは実行しません)');
}
/** @type {string|null} */
let boardId = process.env.LW_BOARD_ID ? String(process.env.LW_BOARD_ID).trim() : null;
let accessToken = '';
if (!boardId) {
if (!hasCredentials()) {
if (LW_DRY_RUN) {
console.warn('[DRY-RUN] 認証情報が不足しているため、掲示板ID取得はスキップします (LW_BOARD_ID を指定するとboardId固定でテストできます)');
boardId = 'unknown';
} else {
ensureRequiredEnvForNetwork();
}
}
}
if (!LW_DRY_RUN) {
// 掲示板の一覧取得には board.read、投稿には board が必要
accessToken = await getAccessToken(LW_SCOPE);
}
if (!LW_DRY_RUN && !boardId && accessToken) {
const boards = await fetchAllBoards(accessToken, 'WRITER');
boardId = findBoardIdByName(boards, TARGET_BOARD_NAME);
if (!boardId) {
const candidates = boards.map(b => b.boardName).slice(0, 20);
throw new Error(`掲示板が見つかりません: '${TARGET_BOARD_NAME}'. 先頭候補: ${candidates.join(', ')}`);
}
console.log(`[BOARD] '${TARGET_BOARD_NAME}' -> boardId=${boardId}`);
}
if (!boardId) {
if (LW_DRY_RUN) {
boardId = 'unknown';
} else {
throw new Error('boardId を特定できませんでした。LW_BOARD_ID を指定するか、認証情報を確認してください。');
}
}
let success = 0;
let failed = 0;
const failedRows = [];
for (let i = 0; i < targetRecords.length; i += 1) {
const row = targetRecords[i];
try {
const result = await createBoardPost(accessToken, boardId, row);
success += 1;
const postId = result && result.postId ? result.postId : '';
console.log(`[OK] ${i + 1}/${targetRecords.length} title='${row['タイトル']}' ${postId ? `postId=${postId}` : ''}`);
} catch (error) {
failed += 1;
console.error(`[NG] ${i + 1}/${targetRecords.length} title='${row['タイトル']}' ${error.message}`);
failedRows.push({
'タイトル': row['タイトル'],
'内容': row['内容'],
'日付': row['日付'],
'投稿者': row['投稿者'],
'行番号': i + 2, // CSVヘッダ行を1行目として数える
'エラー': String(error && error.message ? error.message : error)
});
}
// 全件投入時は1件あたり300ms待つ
if (isPostingAll && i < targetRecords.length - 1) {
await sleep(LW_ITEM_WAIT_MS);
}
}
const failCsvPath = writeFailedRowsCsv(failedRows);
if (failCsvPath) {
console.log(`[FAIL-CSV] ${failedRows.length}件 -> ${failCsvPath}`);
}
console.log(`[DONE] success=${success} failed=${failed} total=${targetRecords.length}`);
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});