ken_nogi/lineworks-sync/csv-anythingllm.js
Kenichiro NOGI 88a402ce0f up
2026-07-10 18:13:30 +09:00

551 lines
23 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"use strict";
/*
* originalData配下のExcel(顧客相談履歴など)をシート単位でCSVへ分割し、
* LINE WORKS用とは別のAnythingLLMワークスペースにRAG登録するスクリプト。
*
* 使い方:
* node csv-anythingllm.js convert … originalData/ 内の全.xlsxをシート別CSV+設定雛形に変換
* (変換完了後、そのExcelはoriginalData/complete/へ移動)
* node csv-anythingllm.js sync <シート名> … 指定シートのみ同期
* node csv-anythingllm.js sync-all … csv-list.csv でflag=1のシートのみ順番に同期
*
* csv-list.csv (このスクリプトと同じフォルダに配置、sync-all実行には必須):
* sheetName,flag の形式。flag列が "1" の行だけが sync-all の対象になる。
* このファイルが無い、またはflag=1の行が1つも無い場合、sync-allは何も同期せず終了する
* (lineworks-anythingllm.jsのboard-list.csvと異なり、全件同期へのフォールバックはしない)。
* convert実行時に新しく見つかったシートは自動で flag=0 として追記登録される
* (既存行のflag値は上書きされない)。同期したいシートは手動でflagを1に変更すること。
*
* 環境変数:
* ANYTHINGLLM_BASE_URL / ANYTHINGLLM_API_KEY … lineworks-anythingllm.js と共通
* EMBEDDING_BATCH_SIZE … 既定 50
* ORIGINAL_DATA_DIR … 既定 ./originalData
* CSV_ROOT_DIR … 既定 ./csv
* CSV_CONFIG_DIR … 既定 ./csv-config
* CSV_CHUNK_MAX_LENGTH … 既定 2000 (相談履歴のチャンク分割の目安文字数)
* CSV_LOG_DIR … 既定 ./logs (実行ログの保存先)
*
* 実行ログ: logs/csv-anythingllm_<コマンド>_<日時>.log に、コンソール出力を
* 日時([YYYY-MM-DD HH:mm:ss])・レベル(INFO/WARN/ERROR)付きで実行毎に保存する。
*
* シート毎の設定ファイル csv-config/<シート名>.json:
* { "csvDir": "csv/<シート名>", "workspaceSlug": "アップロード先ワークスペースのslug" }
* workspaceSlug は convert 実行後に手動で入力する。
* 指定した名前/slugのワークスペースがAnythingLLM側に存在しない場合は、
* sync実行時にその名前で新規ワークスペースを自動作成し、実際に採番されたslugを
* この設定ファイルに書き戻す。
*
* CSVの列構成は位置ベースで読む(1列目=顧客名, 2列目=日付, 3列目=相談履歴)。
* 相談履歴は「名前:コメント」形式の発言が連続する形式で、2000文字を超える場合は
* 発言(名前)の境界で分割する(発言の途中では切らない。1発言が2000文字を超える場合は
* そのまま1チャンクとする)。
*
* 重複防止: 顧客名+日付+相談履歴の全文からSHA-256ハッシュを生成し、
* csv-config/<シート名>.uploaded-hashes.json に記録済みの行はスキップする。
* originalDataのExcelには過去分すべてのログが毎回まるごと含まれる想定のため、
* 日次実行しても既にアップロード済みの行が重複登録されないようにするための仕組み。
*
* 取り込み完了ファイルの退避: 処理が完了したファイルは、それぞれ元のディレクトリ配下の
* complete/ サブフォルダへ移動し、次回実行時に対象外(再変換・再アップロード対象外)とする。
* - originalData配下のExcel → convertで全シート変換後、originalData/complete/
* - csv/<シート名>配下のCSV → syncで全行処理後、csv/<シート名>/complete/
* - csv_chunks_md/<シート名>配下のMD → syncでアップロード成功後、csv_chunks_md/<シート名>/complete/
* 同名ファイルが既にcomplete側にある場合は上書きせず、タイムスタンプを付けて退避する。
*/
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
const XLSX = require("xlsx");
const { uploadFile, addToWorkspaceEmbeddings, ensureWorkspaceExists } = require("./anythingllm-client");
const { ANYTHINGLLM_BASE_URL, ANYTHINGLLM_API_KEY } = process.env;
const EMBEDDING_BATCH_SIZE = Number(process.env.EMBEDDING_BATCH_SIZE || 50);
const ORIGINAL_DATA_DIR = process.env.ORIGINAL_DATA_DIR
? path.resolve(process.env.ORIGINAL_DATA_DIR)
: path.join(__dirname, "originalData");
const ORIGINAL_DATA_COMPLETE_DIR = path.join(ORIGINAL_DATA_DIR, "complete");
const CSV_ROOT_DIR = process.env.CSV_ROOT_DIR
? path.resolve(process.env.CSV_ROOT_DIR)
: path.join(__dirname, "csv");
const CSV_CONFIG_DIR = process.env.CSV_CONFIG_DIR
? path.resolve(process.env.CSV_CONFIG_DIR)
: path.join(__dirname, "csv-config");
const CSV_CHUNK_DIR = path.join(__dirname, "csv_chunks_md");
const CSV_CHUNK_MAX_LENGTH = Number(process.env.CSV_CHUNK_MAX_LENGTH || 2000);
const LOG_DIR = process.env.CSV_LOG_DIR ? path.resolve(process.env.CSV_LOG_DIR) : path.join(__dirname, "logs");
// csv-list.csv (sheetName,flag) を読み込み、flag列が "1" の行だけを同期対象とする。
// lineworks-anythingllm.js の board-list.csv/loadTargetBoardsFromCsv と同じ流儀。
// このファイルが無い、またはflag=1の行が1つも無い場合は [] を返す(全件同期へのフォールバックはしない)。
const CSV_LIST_CSV_PATH = path.join(__dirname, "csv-list.csv");
function loadTargetSheetsFromCsv(csvPath = CSV_LIST_CSV_PATH) {
if (!fs.existsSync(csvPath)) {
return [];
}
const lines = fs
.readFileSync(csvPath, "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
// 先頭行は "sheetName,flag" のヘッダーなのでスキップ
return lines
.slice(1)
.map((line) => line.split(",").map((s) => s.trim()))
.filter(([, flag]) => flag === "1")
.map(([sheetName]) => sheetName);
}
// ファイル名として安全な形に変換 (パス区切り文字等を除去)
function sanitizeFileName(name) {
return String(name || "file").replace(/[\\/:*?"<>|]/g, "_");
}
function pad2(n) {
return String(n).padStart(2, "0");
}
function timestampForFileName(d = new Date()) {
return `${d.getFullYear()}${pad2(d.getMonth() + 1)}${pad2(d.getDate())}-${pad2(d.getHours())}${pad2(d.getMinutes())}${pad2(d.getSeconds())}`;
}
function formatTimestamp(d = new Date()) {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}:${pad2(d.getSeconds())}`;
}
// console.log/warn/error の出力を、実行毎に日時付きでログファイルへも書き出す。
// 1行ずつ同期書き込みするため、途中で異常終了してもそこまでのログは残る。
function startLogging(commandLabel) {
fs.mkdirSync(LOG_DIR, { recursive: true });
const logFilePath = path.join(LOG_DIR, `csv-anythingllm_${commandLabel || "run"}_${timestampForFileName()}.log`);
const writeLine = (level, args) => {
const message = args.map((a) => (typeof a === "string" ? a : JSON.stringify(a))).join(" ");
fs.appendFileSync(logFilePath, `[${formatTimestamp()}] [${level}] ${message}\n`, "utf8");
};
const original = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
};
console.log = (...args) => {
original.log(...args);
writeLine("INFO", args);
};
console.warn = (...args) => {
original.warn(...args);
writeLine("WARN", args);
};
console.error = (...args) => {
original.error(...args);
writeLine("ERROR", args);
};
return logFilePath;
}
// 処理が完了したファイルを、そのファイルがあったディレクトリ配下の complete/ サブフォルダへ移動する。
// 同名ファイルが既にcomplete側にある場合は上書きせず、タイムスタンプを付けて退避する。
// (originalDataのExcel、csv/配下のCSV、csv_chunks_md/配下のチャンクMDファイルで共通利用)
function moveFileToCompleteDir(srcPath, completeDir) {
fs.mkdirSync(completeDir, { recursive: true });
const filename = path.basename(srcPath);
const ext = path.extname(filename);
const base = path.basename(filename, ext);
let destPath = path.join(completeDir, filename);
if (fs.existsSync(destPath)) {
destPath = path.join(completeDir, `${base}_${timestampForFileName()}${ext}`);
}
fs.renameSync(srcPath, destPath);
return destPath;
}
// 変換が完了したExcelファイルを originalData/complete/ へ移動する。
function moveToCompleteDir(xlsxFileName) {
const destPath = moveFileToCompleteDir(path.join(ORIGINAL_DATA_DIR, xlsxFileName), ORIGINAL_DATA_COMPLETE_DIR);
console.log(` 取り込み完了、completeフォルダへ移動しました: ${destPath}`);
}
function configPath(sheetName) {
return path.join(CSV_CONFIG_DIR, `${sheetName}.json`);
}
function hashesPath(sheetName) {
return path.join(CSV_CONFIG_DIR, `${sheetName}.uploaded-hashes.json`);
}
// ====================================================================
// Excel → シート別CSV変換 + 設定雛形の生成
// ====================================================================
// csv-list.csv に未登録のシート名を flag=0 で追記する (convert実行時の自動登録)。
// 既に登録済みの行(flagの値含む)は一切変更しない。手動でflagを立てた行を壊さないための措置。
function ensureSheetRegisteredInCsvList(sheetName, csvPath = CSV_LIST_CSV_PATH) {
const header = "sheetName,flag";
let lines = [];
if (fs.existsSync(csvPath)) {
lines = fs
.readFileSync(csvPath, "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
}
if (lines.length === 0) {
lines = [header];
}
const alreadyListed = lines.slice(1).some((line) => line.split(",")[0].trim() === sheetName);
if (alreadyListed) {
return;
}
lines.push(`${sheetName},0`);
// board-list.csv と同じくBOM付きUTF-8・LF改行で出力
fs.writeFileSync(csvPath, "" + lines.join("\n") + "\n", "utf8");
console.log(` ${csvPath} に新規シートを登録しました(flag=0): ${sheetName}`);
}
async function convertCommand() {
if (!fs.existsSync(ORIGINAL_DATA_DIR)) {
throw new Error(`originalDataフォルダが見つかりません: ${ORIGINAL_DATA_DIR}`);
}
const xlsxFiles = fs.readdirSync(ORIGINAL_DATA_DIR).filter((f) => f.toLowerCase().endsWith(".xlsx"));
if (xlsxFiles.length === 0) {
console.log(`${ORIGINAL_DATA_DIR} に.xlsxファイルが見つかりませんでした。`);
return;
}
for (const xlsxFileName of xlsxFiles) {
const xlsxPath = path.join(ORIGINAL_DATA_DIR, xlsxFileName);
const xlsxBaseName = path.basename(xlsxFileName, path.extname(xlsxFileName));
console.log(`変換中: ${xlsxFileName}`);
const workbook = XLSX.readFile(xlsxPath);
for (const sheetName of workbook.SheetNames) {
const sheet = workbook.Sheets[sheetName];
const csvText = XLSX.utils.sheet_to_csv(sheet);
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
const safeSheetName = sanitizeFileName(sheetName);
const sheetCsvDir = path.join(CSV_ROOT_DIR, safeSheetName);
fs.mkdirSync(sheetCsvDir, { recursive: true });
const csvFileName = `${safeSheetName}__${sanitizeFileName(xlsxBaseName)}.csv`;
const csvPath = path.join(sheetCsvDir, csvFileName);
// ExcelでUTF-8を正しく開けるようBOM付きで出力(lineworks-anythingllm.jsのlistCommandと同じ流儀)
fs.writeFileSync(csvPath, "" + csvText, "utf8");
console.log(` シート「${sheetName}」: ${Math.max(rows.length - 1, 0)}行 → ${csvPath}`);
if (rows.length > 0) {
console.log(` 1行目(ヘッダー想定): ${JSON.stringify(rows[0])}`);
}
const cfgPath = configPath(safeSheetName);
if (!fs.existsSync(cfgPath)) {
fs.mkdirSync(CSV_CONFIG_DIR, { recursive: true });
const relativeCsvDir = path.relative(__dirname, sheetCsvDir).split(path.sep).join("/");
fs.writeFileSync(
cfgPath,
JSON.stringify({ csvDir: relativeCsvDir, workspaceSlug: "" }, null, 2),
"utf8"
);
console.log(` 設定ファイルを新規作成: ${cfgPath} (workspaceSlugを設定してください)`);
}
ensureSheetRegisteredInCsvList(safeSheetName);
}
try {
moveToCompleteDir(xlsxFileName);
} catch (moveErr) {
console.warn(` [警告] ${xlsxFileName} のcompleteフォルダへの移動に失敗しました: ${moveErr.message}`);
}
}
}
// ====================================================================
// 相談履歴のチャンク分割 (発言=「名前:コメント」の境界でのみ区切る)
// ====================================================================
// 行頭が「名前:」「名前:」の形式にマッチする行を新しい発言の開始とみなす。
// "https://..." のようなURLの先頭を誤って名前と判定しないよう除外する。
const TURN_START_PATTERN = /^([^:\r\n]{1,30})[:](?!\/\/)/;
function parseTurns(historyText) {
const lines = String(historyText || "").split(/\r\n|\r|\n/);
const turns = [];
for (const line of lines) {
if (TURN_START_PATTERN.test(line) || turns.length === 0) {
turns.push(line);
} else {
turns[turns.length - 1] += "\n" + line;
}
}
return turns;
}
// 発言を順に貪欲へ積み上げ、次の発言を足すと上限を超える場合はチャンクを区切る。
// 1発言自体が上限を超える場合はそのまま単独チャンクとする(発言の途中では切らない)。
function splitIntoChunks(turns, maxLen) {
const chunks = [];
let current = "";
for (const turnText of turns) {
if (current.length === 0) {
current = turnText;
} else if (current.length + 1 + turnText.length <= maxLen) {
current += "\n" + turnText;
} else {
chunks.push(current);
current = turnText;
}
}
if (current.length > 0) {
chunks.push(current);
}
return chunks;
}
function buildChunkMarkdown(sheetName, customerName, date, chunkText, partNumber, totalParts) {
const lines = [
`# ${customerName || "(顧客名不明)"}`,
"",
`- 顧客名: ${customerName || ""}`,
`- 日付: ${date || ""}`,
`- シート: ${sheetName}`,
];
if (totalParts > 1) {
lines.push(`- 分割: ${partNumber}/${totalParts}`);
}
lines.push("", chunkText);
return lines.join("\n");
}
// ====================================================================
// 重複防止 (顧客名+日付+相談履歴 のハッシュで既アップロード分をスキップ)
// ====================================================================
function rowHash(customerName, date, history) {
return crypto.createHash("sha256").update(`${customerName}|${date}|${history}`).digest("hex");
}
function loadUploadedHashes(sheetName) {
const p = hashesPath(sheetName);
if (!fs.existsSync(p)) {
return new Set();
}
try {
const arr = JSON.parse(fs.readFileSync(p, "utf8"));
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
function saveUploadedHashes(sheetName, hashSet) {
fs.mkdirSync(CSV_CONFIG_DIR, { recursive: true });
fs.writeFileSync(hashesPath(sheetName), JSON.stringify([...hashSet]), "utf8");
}
// ====================================================================
// CSV → チャンク分割 → アップロード (シート単位)
// ====================================================================
function loadSheetConfig(sheetName) {
const cfgPath = configPath(sheetName);
if (!fs.existsSync(cfgPath)) {
throw new Error(`設定ファイルが見つかりません: ${cfgPath} (先に convert を実行してください)`);
}
return JSON.parse(fs.readFileSync(cfgPath, "utf8"));
}
function resolveCsvDir(csvDir) {
return path.isAbsolute(csvDir) ? csvDir : path.join(__dirname, csvDir);
}
async function syncSheet(sheetName) {
const config = loadSheetConfig(sheetName);
if (!config.workspaceSlug) {
throw new Error(
`シート「${sheetName}」の workspaceSlug が未設定です。${configPath(sheetName)} を編集してください。`
);
}
const actualSlug = await ensureWorkspaceExists(config.workspaceSlug, {
baseUrl: ANYTHINGLLM_BASE_URL,
apiKey: ANYTHINGLLM_API_KEY,
});
if (actualSlug !== config.workspaceSlug) {
config.workspaceSlug = actualSlug;
fs.writeFileSync(configPath(sheetName), JSON.stringify(config, null, 2), "utf8");
console.log(` 設定ファイルのworkspaceSlugを実際のslugに更新しました: ${actualSlug}`);
}
const csvDir = resolveCsvDir(config.csvDir);
if (!fs.existsSync(csvDir)) {
throw new Error(`CSVフォルダが見つかりません: ${csvDir}`);
}
const csvFiles = fs.readdirSync(csvDir).filter((f) => f.toLowerCase().endsWith(".csv"));
if (csvFiles.length === 0) {
console.log(`シート「${sheetName}」: CSVファイルが見つかりませんでした (${csvDir})`);
return;
}
const uploadedHashes = loadUploadedHashes(sheetName);
const chunkDir = path.join(CSV_CHUNK_DIR, sanitizeFileName(sheetName));
const chunkCompleteDir = path.join(chunkDir, "complete");
const csvCompleteDir = path.join(csvDir, "complete");
fs.mkdirSync(chunkDir, { recursive: true });
let newCount = 0;
let skippedCount = 0;
const locations = [];
for (const csvFileName of csvFiles) {
const csvPath = path.join(csvDir, csvFileName);
const workbook = XLSX.readFile(csvPath);
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json(sheet, { header: 1 });
for (let i = 1; i < rows.length; i++) {
const row = rows[i] || [];
const customerName = String(row[0] ?? "").trim();
const date = String(row[1] ?? "").trim();
const history = String(row[2] ?? "").trim();
if (!customerName && !history) {
continue; // 空行はスキップ
}
const hash = rowHash(customerName, date, history);
if (uploadedHashes.has(hash)) {
skippedCount++;
continue;
}
const chunks = splitIntoChunks(parseTurns(history), CSV_CHUNK_MAX_LENGTH);
const rowLocations = [];
for (let partIndex = 0; partIndex < chunks.length; partIndex++) {
const partNumber = partIndex + 1;
const markdown = buildChunkMarkdown(
sheetName,
customerName,
date,
chunks[partIndex],
partNumber,
chunks.length
);
const filename = `${sanitizeFileName(csvFileName.replace(/\.csv$/i, ""))}_row${i}_${sanitizeFileName(
customerName || "unknown"
)}_part${partNumber}.md`;
const filepath = path.join(chunkDir, filename);
fs.writeFileSync(filepath, markdown, "utf8");
const location = await uploadFile(filepath, {
baseUrl: ANYTHINGLLM_BASE_URL,
apiKey: ANYTHINGLLM_API_KEY,
mimeType: "text/markdown",
});
if (location) {
rowLocations.push(location);
moveFileToCompleteDir(filepath, chunkCompleteDir);
}
}
locations.push(...rowLocations);
uploadedHashes.add(hash);
saveUploadedHashes(sheetName, uploadedHashes);
newCount++;
}
// このCSVの全行を処理し終えた(新規アップロード or 重複スキップ)ので、
// 元のCSVファイルもcomplete/へ移動して次回convert/syncの対象から外す。
moveFileToCompleteDir(csvPath, csvCompleteDir);
}
console.log(`シート「${sheetName}」: 新規アップロード ${newCount}件 / スキップ(重複) ${skippedCount}`);
if (locations.length > 0) {
await addToWorkspaceEmbeddings(locations, {
baseUrl: ANYTHINGLLM_BASE_URL,
apiKey: ANYTHINGLLM_API_KEY,
workspaceSlug: config.workspaceSlug,
batchSize: EMBEDDING_BATCH_SIZE,
});
} else {
console.log(" 埋め込み対象の新規ドキュメントはありませんでした。");
}
}
async function syncAllCommand() {
if (!fs.existsSync(CSV_CONFIG_DIR)) {
console.log(`設定フォルダが見つかりません: ${CSV_CONFIG_DIR} (先に convert を実行してください)`);
return;
}
const targetSheetNames = loadTargetSheetsFromCsv();
if (targetSheetNames.length === 0) {
console.log(
`${CSV_LIST_CSV_PATH} に同期対象(flag=1)のシートが見つからないため、何も同期せず終了します。` +
`(csv-list.csv の対象行の flag を 1 にしてください)`
);
return;
}
console.log(`${CSV_LIST_CSV_PATH} から対象シートを読み込みました: ${targetSheetNames.length}`);
targetSheetNames.forEach((name) => console.log(` - ${name}`));
for (const sheetName of targetSheetNames) {
console.log(`=== シート「${sheetName}」を同期します ===`);
try {
await syncSheet(sheetName);
} catch (err) {
console.error(`シート「${sheetName}」の同期に失敗: ${err.message}`);
}
}
}
// ====================================================================
// コマンド
// ====================================================================
async function main() {
const [, , command, ...rest] = process.argv;
const logFilePath = startLogging(command || "run");
console.log(`ログファイル: ${logFilePath}`);
if (command === "convert") {
await convertCommand();
} else if (command === "sync") {
const sheetName = rest[0];
if (!sheetName) {
console.error("使い方: node csv-anythingllm.js sync <シート名>");
process.exitCode = 1;
return;
}
await syncSheet(sheetName);
} else if (command === "sync-all") {
await syncAllCommand();
} else {
console.log("使い方: node csv-anythingllm.js [convert|sync <シート名>|sync-all]");
}
}
main().catch((err) => {
console.error("処理に失敗しました。");
console.error(err.message);
process.exitCode = 1;
});