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, '>') .replace(/"/g, '"') .replace(/'/g, '''); } 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)}
投稿者: ${escapeHtml(author)}`; const body = escapeHtml(content).replace(/\r\n?/g, '\n').replace(/\n/g, '
'); return `

${header}


${body}

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