637 lines
21 KiB
JavaScript
637 lines
21 KiB
JavaScript
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, '"')
|
||
.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)}<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;
|
||
});
|