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

1099 lines
46 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.

"use strict";
/*
* LINE WORKS 掲示板 → AnythingLLM RAG 連携スクリプト (v3)
*
* getGroupList.js の認証方式(crypto標準モジュールによるJWT自前署名)を流用。
* Node.js 18+ の標準 fetch / FormData / Blob のみを使用するため、
* 追加の npm install は不要です。
*
* v3: 投稿本文に加え、添付ファイル(PDF・画像等)もダウンロードして
* AnythingLLMに個別アップロードする(PDF/画像はAnythingLLM側でOCR/テキスト抽出)。
*
* 仕様書: https://developers.worksmobile.com/jp/docs/board
*
* 使い方:
* node lineworks-anythingllm.js sync
* node lineworks-anythingllm.js list
* node lineworks-anythingllm.js ask "問い合わせ内容"
*
* 環境変数 (getGroupList.js と共通の命名):
* LW_CLIENT_ID
* LW_CLIENT_SECRET
* LW_SERVICE_ACCOUNT
* LW_PRIVATE_KEY (鍵の中身を直接渡す場合)
* LW_PRIVATE_KEY_FILE (鍵ファイルのパスを渡す場合)
* LW_SCOPE 既定: board
*
* board-list.csv (このスクリプトと同じフォルダに配置):
* boardId,boardName,flag の形式。flag列が "1" の行だけが同期対象になる。
*
* ANYTHINGLLM_BASE_URL 例: http://localhost:3001
* ANYTHINGLLM_API_KEY
* ANYTHINGLLM_WORKSPACE_SLUG 例: lineworks-board
*
* 取り込み完了ファイルの退避: AnythingLLMへのアップロードが成功した投稿Markdown/
* 添付ファイルは、それぞれ board_posts_md/complete/ ・ board_attachments/complete/
* へ移動する(次回syncでの重複アップロードを避けるため)。
* 同名ファイルが既にcomplete側にある場合は上書きせず、タイムスタンプを付けて退避する。
*
* 重複防止: board-uploaded-hashes.json (このスクリプトと同じフォルダ) に、
* アップロード済みの投稿・添付ファイルのハッシュを記録し、次回以降のsyncで
* 同一内容の再アップロードをスキップする。
* - 投稿本文: boardId+postId+更新日時+タイトル+本文からハッシュ化。
* 本文が編集され更新日時が進むと別ハッシュになり、再アップロードされる。
* - 添付ファイル: boardId+postId+attachmentId(不変)からハッシュ化。
*/
const fs = require("fs");
const path = require("path");
const crypto = require("crypto");
// ====================================================================
// Rate Limit対策: スロットリング + 429時の自動リトライ
// ====================================================================
// 各API呼び出しの間隔(ミリ秒)。既定250ms = 理論上 最大240req/分相当まで抑制。
const LW_REQUEST_DELAY_MS = Number(process.env.LW_REQUEST_DELAY_MS || 250);
// 429発生時の最大リトライ回数
const LW_MAX_RETRIES = Number(process.env.LW_MAX_RETRIES || 5);
// JSON.parseの数値型(IEEE754)では19桁のboardId/postIdが精度落ちして壊れるため、
// 指定キーの整数値を文字列化してからparseする (importBoard.js から移植)
// 例: "boardId": 4080000000758656500 -> "boardId":"4080000000758656500"
function parseJsonWithQuotedInt64(text, keys) {
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);
}
// board-list.csv (boardId,boardName,flag) を読み込み、flag列が "1" の行だけを取得対象とする
const BOARD_LIST_CSV_PATH = path.join(__dirname, "board-list.csv");
function loadTargetBoardsFromCsv(csvPath = BOARD_LIST_CSV_PATH) {
if (!fs.existsSync(csvPath)) {
return [];
}
const lines = fs
.readFileSync(csvPath, "utf8")
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
// 先頭行は "boardId,boardName" のヘッダーなのでスキップ
return lines
.slice(1)
.map((line) => line.split(",").map((s) => s.trim()))
.filter(([, , flag]) => flag === "1")
.map(([boardId, boardName]) => ({ boardId, boardName }));
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// LINE WORKS API呼び出し専用のfetchラッパー。
// - 呼び出し前に一定間隔のウェイトを入れる(スロットリング)
// - 429 (Rate Limit) の場合はRetry-Afterヘッダ、または指数バックオフで自動リトライ
async function lwFetch(url, options = {}) {
for (let attempt = 0; attempt <= LW_MAX_RETRIES; attempt++) {
await sleep(LW_REQUEST_DELAY_MS);
const response = await fetch(url, options);
if (response.status !== 429) {
return response;
}
if (attempt === LW_MAX_RETRIES) {
return response; // リトライ上限に達したら、429のレスポンスをそのまま返す(呼び出し元でエラー処理)
}
const retryAfterHeader = response.headers.get("retry-after");
const retryAfterMs = retryAfterHeader
? Number(retryAfterHeader) * 1000
: LW_REQUEST_DELAY_MS * Math.pow(2, attempt + 1); // 指数バックオフ
console.warn(` [Rate Limit] 429を検知。${Math.round(retryAfterMs / 1000)}秒待機してリトライします... (${attempt + 1}/${LW_MAX_RETRIES})`);
await sleep(retryAfterMs);
}
}
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
const LW_API_BASE_URL = "https://www.worksapis.com/v1.0";
// getGroupList.js と同じフォルダ配置を想定。別フォルダに置く場合は
// LW_PRIVATE_KEY_FILE 環境変数で鍵ファイルの場所を明示してください。
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@nexthd.jp";
// 掲示板APIには board スコープが必要 (group.read ではアクセス不可)
// 投稿の読み取りには board.read も別途必要な場合があるため両方指定
const LW_SCOPE = process.env.LW_SCOPE || "board board.read";
const LW_BOARD_LIST_COUNT_PER_PAGE = 100;
const LW_POST_LIST_COUNT_PER_PAGE = 40; // 投稿一覧APIのcount上限は40
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") : "");
function ensureRequiredEnvForAuth() {
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(", ")}`);
}
}
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(scope = LW_SCOPE) {
ensureRequiredEnvForAuth();
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;
}
// ====================================================================
// トークン自動更新 (アクセストークンの有効期限は1時間。長時間のsyncで
// 途中失効し401になるのを防ぐため、45分経過ごとに自動再取得する)
// ====================================================================
const TOKEN_REFRESH_INTERVAL_MS = 45 * 60 * 1000; // 45分
function createAuthState(initialToken) {
return { token: initialToken, obtainedAt: Date.now() };
}
async function ensureFreshToken(authState) {
const elapsed = Date.now() - authState.obtainedAt;
if (elapsed > TOKEN_REFRESH_INTERVAL_MS) {
console.log(" [トークン更新] 経過時間が長いためアクセストークンを再取得します...");
authState.token = await getAccessToken();
authState.obtainedAt = Date.now();
}
return authState.token;
}
// ====================================================================
// Board API
// ====================================================================
// アクセス可能な全掲示板を取得 (role=READERで読み取り権限のある掲示板全件)
async function fetchAllBoards(accessToken) {
const boards = [];
let cursor = "";
while (true) {
const params = new URLSearchParams();
params.set("role", "READER");
params.set("count", String(LW_BOARD_LIST_COUNT_PER_PAGE));
if (cursor) params.set("cursor", cursor);
const url = `${LW_API_BASE_URL}/boards?${params.toString()}`;
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const raw = await response.text();
let data;
try {
data = raw ? parseJsonWithQuotedInt64(raw, ["boardId"]) : {};
} catch (_err) {
throw new Error(`掲示板一覧レスポンスがJSONではありません: ${raw}`);
}
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;
}
// 動作確認用: 最新投稿リストの取得 (掲示板を指定せず、直近30日の新着投稿を横断取得)
// 権限モデルが個別のboard指定エンドポイントと異なる可能性があるため、切り分け用に用意
async function fetchRecentPosts(accessToken) {
const url = `${LW_API_BASE_URL}/boards/posts?count=40`;
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const raw = await response.text();
let data;
try {
data = raw ? parseJsonWithQuotedInt64(raw, ["boardId", "postId"]) : {};
} catch (_err) {
throw new Error(`最新投稿レスポンスがJSONではありません: ${raw}`);
}
if (!response.ok) {
throw new Error(`最新投稿取得失敗: ${response.status} ${JSON.stringify(data)}`);
}
return data.posts || [];
}
async function fetchAllPosts(accessToken, boardId) {
const posts = [];
let cursor = "";
while (true) {
const params = new URLSearchParams();
params.set("count", String(LW_POST_LIST_COUNT_PER_PAGE));
if (cursor) params.set("cursor", cursor);
const url = `${LW_API_BASE_URL}/boards/${boardId}/posts?${params.toString()}`;
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const raw = await response.text();
let data;
try {
data = raw ? parseJsonWithQuotedInt64(raw, ["boardId", "postId"]) : {};
} catch (_err) {
throw new Error(`投稿一覧レスポンスがJSONではありません: ${raw}`);
}
if (!response.ok) {
throw new Error(`投稿一覧取得失敗 (board ${boardId}): ${response.status} ${JSON.stringify(data)}`);
}
const pagePosts = Array.isArray(data.posts) ? data.posts : [];
posts.push(...pagePosts);
const nextCursor = data?.responseMetaData?.nextCursor || "";
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return posts;
}
async function fetchPostDetail(accessToken, boardId, postId) {
const url = `${LW_API_BASE_URL}/boards/${boardId}/posts/${postId}`;
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const raw = await response.text();
let data;
try {
data = raw ? parseJsonWithQuotedInt64(raw, ["boardId", "postId"]) : {};
} catch (_err) {
throw new Error(`投稿詳細レスポンスがJSONではありません: ${raw}`);
}
if (!response.ok) {
throw new Error(`投稿詳細取得失敗 (post ${postId}): ${response.status} ${JSON.stringify(data)}`);
}
return data;
}
// 添付ファイル一覧を取得 (PDF・画像等、fileCount > 0の投稿にのみ存在)
async function fetchPostAttachments(accessToken, boardId, postId) {
const attachments = [];
let cursor = "";
while (true) {
const params = new URLSearchParams();
params.set("count", "20"); // 仕様上の最大値
if (cursor) params.set("cursor", cursor);
const url = `${LW_API_BASE_URL}/boards/${boardId}/posts/${postId}/attachments?${params.toString()}`;
const response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
});
const raw = await response.text();
let data;
try {
data = raw ? JSON.parse(raw) : {}; // attachmentIdは既にstring型なので精度落ちの心配なし
} catch (_err) {
throw new Error(`添付ファイル一覧レスポンスがJSONではありません: ${raw}`);
}
if (!response.ok) {
throw new Error(`添付ファイル一覧取得失敗 (post ${postId}): ${response.status} ${JSON.stringify(data)}`);
}
const pageAttachments = Array.isArray(data.attachments) ? data.attachments : [];
attachments.push(...pageAttachments);
const nextCursor = data?.responseMetaData?.nextCursor || "";
if (!nextCursor || nextCursor === cursor) break;
cursor = nextCursor;
}
return attachments;
}
// 添付ファイル本体をダウンロード (302リダイレクト先から取得。fetchは既定でリダイレクトを自動追跡する)
async function downloadAttachment(accessToken, boardId, postId, attachmentId) {
const url = `${LW_API_BASE_URL}/boards/${boardId}/posts/${postId}/attachments/${attachmentId}`;
// 重要: fetchは既定で別ドメインへのリダイレクト時にAuthorizationヘッダーを
// 自動的に取り除く。しかしLINE WORKSのリダイレクト先(apis-storage.worksmobile.com)は
// 同じBearerトークンでの認証を要求するため、リダイレクトを手動で処理し
// 自前でAuthorizationヘッダーを引き継ぐ必要がある。
let response = await lwFetch(url, {
headers: { Authorization: `Bearer ${accessToken}` },
redirect: "manual",
});
if (response.status >= 300 && response.status < 400) {
const location = response.headers.get("location");
if (!location) {
throw new Error(`添付ファイルダウンロード失敗 (attachment ${attachmentId}): リダイレクト先が取得できません`);
}
response = await lwFetch(location, {
headers: { Authorization: `Bearer ${accessToken}` },
});
}
if (!response.ok) {
let bodyText = "";
try {
bodyText = await response.text();
} catch (_err) {
bodyText = "(本文取得失敗)";
}
throw new Error(
`添付ファイルダウンロード失敗 (attachment ${attachmentId}): ${response.status} ` +
`finalUrl=${response.url} body=${bodyText}`
);
}
const arrayBuffer = await response.arrayBuffer();
return Buffer.from(arrayBuffer);
}
// ====================================================================
// 投稿をMarkdownファイル化 / 添付ファイルの保存
// ====================================================================
const EXPORT_DIR = path.join(__dirname, "board_posts_md");
const EXPORT_COMPLETE_DIR = path.join(EXPORT_DIR, "complete");
const ATTACHMENT_DIR = path.join(__dirname, "board_attachments");
const ATTACHMENT_COMPLETE_DIR = path.join(ATTACHMENT_DIR, "complete");
// AnythingLLMへのアップロードが完了したファイルを、元のディレクトリ配下の
// complete/ サブフォルダへ移動する(次回syncで再アップロード対象に含めないため)。
// 同名ファイルが既にcomplete側にある場合は上書きせず、タイムスタンプを付けて退避する。
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;
}
// ====================================================================
// 重複防止 (投稿本文・添付ファイルの再アップロードをスキップ)
// ====================================================================
//
// - 投稿本文: boardId+postId+更新日時+タイトル+本文からハッシュを生成する。
// 本文が編集され更新日時が進むとハッシュが変わり、再アップロード対象になる。
// - 添付ファイル: attachmentIdはLINE WORKS側で不変のため、boardId+postId+attachmentId
// のみでハッシュ化する(内容の再ダウンロード・再アップロードを避けるため)。
// 記録先: board-uploaded-hashes.json (このスクリプトと同じフォルダ)
const UPLOADED_HASHES_PATH = path.join(__dirname, "board-uploaded-hashes.json");
function contentHash(...parts) {
return crypto.createHash("sha256").update(parts.join("|")).digest("hex");
}
function postContentHash(boardId, postId, detail, bodyText) {
return contentHash("post", boardId, postId, detail.updatedTime || detail.createdTime || "", detail.title || "", bodyText);
}
function attachmentIdentityHash(boardId, postId, attachment) {
return contentHash("attachment", boardId, postId, attachment.attachmentId);
}
function loadUploadedHashes() {
if (!fs.existsSync(UPLOADED_HASHES_PATH)) {
return new Set();
}
try {
const arr = JSON.parse(fs.readFileSync(UPLOADED_HASHES_PATH, "utf8"));
return new Set(Array.isArray(arr) ? arr : []);
} catch {
return new Set();
}
}
function saveUploadedHashes(hashSet) {
fs.writeFileSync(UPLOADED_HASHES_PATH, JSON.stringify([...hashSet]), "utf8");
}
function stripHtml(html) {
return (html || "").replace(/<[^>]+>/g, "").trim();
}
// ファイル名として安全な形に変換 (パス区切り文字等を除去)
function sanitizeFileName(name) {
return String(name || "file").replace(/[\\/:*?"<>|]/g, "_");
}
function writePostAsMarkdown(boardId, post, detail, attachments = []) {
fs.mkdirSync(EXPORT_DIR, { recursive: true });
const filename = `board${boardId}_post${post.postId}.md`;
const filepath = path.join(EXPORT_DIR, filename);
const lines = [
`# ${detail.title || "(無題)"}`,
"",
`- 掲示板ID: ${boardId}`,
`- 投稿ID: ${post.postId}`,
`- 投稿日時: ${detail.createdTime || ""}`,
`- 投稿者: ${detail.userName || detail.userId || ""}`,
];
if (attachments.length > 0) {
lines.push(`- 添付ファイル: ${attachments.map((a) => a.fileName).join(", ")}`);
}
lines.push("", stripHtml(detail.body));
fs.writeFileSync(filepath, lines.join("\n"), "utf8");
return filepath;
}
// ====================================================================
// 規則集(章・条形式)の分割 — 正規表現による確実な分割 (LLM不使用)
// ====================================================================
// 「第◯条」を条区切りとして検出。見出しが()内にある場合はそれも取得する。
// 例: "第76条特別休暇" "第94条(事業引継)" "第123条"
const ARTICLE_PATTERN = /第([0-9-]+)条\s*[(]?([^)\n第]{0,30})?[)]?/g;
// 分割対象かどうかを判定する閾値。短い投稿や条文形式でない投稿は対象外。
const ARTICLE_SPLIT_MIN_LENGTH = 3000;
const ARTICLE_SPLIT_MIN_COUNT = 3;
function splitIntoArticles(bodyText) {
const matches = [...bodyText.matchAll(ARTICLE_PATTERN)];
if (bodyText.length < ARTICLE_SPLIT_MIN_LENGTH || matches.length < ARTICLE_SPLIT_MIN_COUNT) {
return null; // 分割対象外(通常の投稿として扱う)
}
const articles = [];
// 最初の条文より前にある前文(総則等)を1つの塊として保持
const firstIndex = matches[0].index;
if (firstIndex > 50) {
const preamble = bodyText.slice(0, firstIndex).trim();
if (preamble) {
articles.push({ articleNumber: "前文", heading: "", text: preamble });
}
}
for (let i = 0; i < matches.length; i++) {
const start = matches[i].index;
const end = i + 1 < matches.length ? matches[i + 1].index : bodyText.length;
const articleNumber = matches[i][1];
const heading = (matches[i][2] || "").trim();
const text = bodyText.slice(start, end).trim();
articles.push({ articleNumber, heading, text });
}
return articles;
}
// 条ごとに個別のMarkdownファイルとして出力
function writeArticlesAsMarkdown(boardId, post, detail, articles) {
fs.mkdirSync(EXPORT_DIR, { recursive: true });
const filepaths = [];
for (const article of articles) {
const label = article.articleNumber === "前文" ? "前文" : `${article.articleNumber}`;
const headingSuffix = article.heading ? `${article.heading}` : "";
const filename = `board${boardId}_post${post.postId}_art${sanitizeFileName(article.articleNumber)}.md`;
const filepath = path.join(EXPORT_DIR, filename);
const lines = [
`# ${detail.title || "(無題)"} - ${label}${headingSuffix}`,
"",
`- 掲示板ID: ${boardId}`,
`- 投稿ID: ${post.postId}`,
`- 元の投稿タイトル: ${detail.title || ""}`,
`- 条番号: ${label}${headingSuffix}`,
`- 投稿日時: ${detail.createdTime || ""}`,
"",
article.text,
];
fs.writeFileSync(filepath, lines.join("\n"), "utf8");
filepaths.push(filepath);
}
return filepaths;
}
// ====================================================================
// 条文形式でない長文投稿のAI分割 — LM Studioに「区切り位置」だけ判断させ、
// 分割自体はプログラム側で機械的に行う(本文の書き換え・要約はさせない)
// ====================================================================
const AI_SPLIT_MIN_LENGTH = Number(process.env.AI_SPLIT_MIN_LENGTH || 3000);
const LMSTUDIO_CHAT_URL = process.env.LMSTUDIO_CHAT_URL || "http://localhost:1234/v1/chat/completions";
const LMSTUDIO_MODEL_NAME = process.env.LMSTUDIO_MODEL_NAME || "qwen2.5-14b-instruct";
const AI_SPLIT_MARKER = "===SPLIT===";
// 分割結果が原文を改変していないかの許容誤差(文字数ベース)
const AI_SPLIT_LENGTH_TOLERANCE = 0.1; // ±10%まで許容
async function splitWithAI(bodyText) {
const prompt =
`以下の文章を、意味のまとまり(話題)ごとに分割してください。\n` +
`分割したい箇所にだけ、改行して「${AI_SPLIT_MARKER}」という文字列を挿入してください。\n` +
`【厳守事項】\n` +
`- 元の文章は一字一句変更しないでください。要約・言い換え・省略は禁止です。\n` +
`- 「${AI_SPLIT_MARKER}」以外の文字は追加しないでください。\n` +
`- 出力は、区切り記号を挿入した本文全体のみとしてください(前置きや説明文は不要です)。\n\n` +
`【本文】\n${bodyText}`;
const res = await fetch(LMSTUDIO_CHAT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: LMSTUDIO_MODEL_NAME,
messages: [{ role: "user", content: prompt }],
temperature: 0,
max_tokens: 8000,
}),
});
if (!res.ok) {
throw new Error(`LM Studio呼び出し失敗: ${res.status} ${await res.text()}`);
}
const data = await res.json();
const content = data.choices?.[0]?.message?.content || "";
const segments = content
.split(AI_SPLIT_MARKER)
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (segments.length < 2) {
return null; // 分割されなかった(そのまま1ファイルとして扱う)
}
// 改変チェック: 分割後の合計文字数が原文から大きくズレていないか検証
const totalLength = segments.reduce((sum, s) => sum + s.length, 0);
const ratio = totalLength / bodyText.length;
if (ratio < 1 - AI_SPLIT_LENGTH_TOLERANCE || ratio > 1 + AI_SPLIT_LENGTH_TOLERANCE) {
console.warn(` [警告] AI分割で文字数が想定より変化(${Math.round(ratio * 100)}%)のため分割を破棄`);
return null;
}
return segments;
}
// AI分割の結果を、それぞれ個別のMarkdownファイルとして出力
function writeAiSegmentsAsMarkdown(boardId, post, detail, segments) {
fs.mkdirSync(EXPORT_DIR, { recursive: true });
const filepaths = [];
segments.forEach((segmentText, index) => {
const partNumber = index + 1;
const filename = `board${boardId}_post${post.postId}_part${partNumber}.md`;
const filepath = path.join(EXPORT_DIR, filename);
const lines = [
`# ${detail.title || "(無題)"} - Part${partNumber}/${segments.length}`,
"",
`- 掲示板ID: ${boardId}`,
`- 投稿ID: ${post.postId}`,
`- 元の投稿タイトル: ${detail.title || ""}`,
`- 投稿日時: ${detail.createdTime || ""}`,
"",
segmentText,
];
fs.writeFileSync(filepath, lines.join("\n"), "utf8");
filepaths.push(filepath);
});
return filepaths;
}
// 添付ファイル本体をローカルに保存
function saveAttachmentFile(boardId, postId, attachment, buffer) {
fs.mkdirSync(ATTACHMENT_DIR, { recursive: true });
const safeName = sanitizeFileName(attachment.fileName);
const filename = `board${boardId}_post${postId}_${attachment.attachmentId.replace(/[^a-zA-Z0-9._-]/g, "")}_${safeName}`;
const filepath = path.join(ATTACHMENT_DIR, filename);
fs.writeFileSync(filepath, buffer);
return filepath;
}
// 拡張子からMIMEタイプを簡易判定 (AnythingLLM側の処理分岐に必要)
function guessMimeType(filename) {
const ext = path.extname(filename).toLowerCase();
const map = {
".pdf": "application/pdf",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".md": "text/markdown",
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
".csv": "text/csv",
};
return map[ext] || "application/octet-stream";
}
// ====================================================================
// AnythingLLM API (標準 fetch + FormData + Blob のみ使用)
// ====================================================================
const {
ANYTHINGLLM_BASE_URL,
ANYTHINGLLM_API_KEY,
ANYTHINGLLM_WORKSPACE_SLUG,
} = process.env;
async function uploadToAnythingLLM(filepath, mimeType = "text/markdown") {
const fileBuffer = fs.readFileSync(filepath);
const blob = new Blob([fileBuffer], { type: mimeType });
const form = new FormData();
form.append("file", blob, path.basename(filepath));
const res = await fetch(`${ANYTHINGLLM_BASE_URL}/api/v1/document/upload`, {
method: "POST",
headers: { Authorization: `Bearer ${ANYTHINGLLM_API_KEY}` },
body: form,
});
if (!res.ok) {
throw new Error(`AnythingLLMアップロード失敗: ${res.status} ${await res.text()}`);
}
const data = await res.json();
return data.documents?.[0]?.location;
}
// 一度に大量のファイルを埋め込もうとするとAnythingLLM/Ollama側でタイムアウトする
// (fetch failed) ため、一定件数ごとにバッチ分割して順番に送信する
const EMBEDDING_BATCH_SIZE = Number(process.env.EMBEDDING_BATCH_SIZE || 50);
async function addToWorkspaceEmbeddings(locations) {
const totalBatches = Math.ceil(locations.length / EMBEDDING_BATCH_SIZE);
let succeededCount = 0;
const failedBatches = [];
for (let i = 0; i < locations.length; i += EMBEDDING_BATCH_SIZE) {
const batch = locations.slice(i, i + EMBEDDING_BATCH_SIZE);
const batchNumber = Math.floor(i / EMBEDDING_BATCH_SIZE) + 1;
console.log(` 埋め込み中... バッチ ${batchNumber}/${totalBatches} (${batch.length}件)`);
try {
const res = await fetch(
`${ANYTHINGLLM_BASE_URL}/api/v1/workspace/${ANYTHINGLLM_WORKSPACE_SLUG}/update-embeddings`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ANYTHINGLLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ adds: batch, deletes: [] }),
}
);
if (!res.ok) {
throw new Error(`${res.status} ${await res.text()}`);
}
succeededCount += batch.length;
} catch (err) {
console.warn(` [警告] バッチ ${batchNumber} の埋め込みに失敗: ${err.message}`);
failedBatches.push({ batchNumber, count: batch.length, reason: err.message });
}
}
console.log(`埋め込み完了: 成功 ${succeededCount}/${locations.length}`);
if (failedBatches.length > 0) {
console.warn(`失敗したバッチ: ${failedBatches.length}`);
failedBatches.forEach((b) => console.warn(` - バッチ${b.batchNumber} (${b.count}件): ${b.reason}`));
}
return { succeededCount, failedBatches };
}
async function askWorkspace(question) {
const res = await fetch(
`${ANYTHINGLLM_BASE_URL}/api/v1/workspace/${ANYTHINGLLM_WORKSPACE_SLUG}/chat`,
{
method: "POST",
headers: {
Authorization: `Bearer ${ANYTHINGLLM_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({ message: question, mode: "query" }),
}
);
if (!res.ok) {
throw new Error(`質問失敗: ${res.status} ${await res.text()}`);
}
return res.json();
}
// ====================================================================
// コマンド
// ====================================================================
async function syncCommand() {
if (typeof fetch !== "function") {
throw new Error("この Node.js 実行環境は fetch に未対応です。Node.js 18 以上を使用してください。");
}
const authState = createAuthState(await getAccessToken());
// board-list.csv の flag列が "1" の行を優先、該当が無ければ全掲示板を自動取得
const csvBoards = loadTargetBoardsFromCsv();
let targetBoards;
if (csvBoards.length > 0) {
console.log(`${BOARD_LIST_CSV_PATH} から対象掲示板を読み込みました: ${csvBoards.length}`);
csvBoards.forEach((b) => console.log(` - [${b.boardId}] ${b.boardName}`));
targetBoards = csvBoards;
} else {
console.log(`${BOARD_LIST_CSV_PATH} に取得対象(flag=1)の掲示板が無いため、アクセス可能な全掲示板を取得します...`);
targetBoards = await fetchAllBoards(authState.token);
console.log(`対象掲示板: ${targetBoards.length}`);
targetBoards.forEach((b) => console.log(` - [${b.boardId}] ${b.boardName}`));
}
if (targetBoards.length === 0) {
throw new Error("アクセス可能な掲示板が見つかりませんでした。Service Accountの権限を確認してください。");
}
const uploadedLocations = [];
const skippedBoards = [];
const uploadedHashes = loadUploadedHashes();
let skippedPostCount = 0;
let skippedAttachmentCount = 0;
for (const board of targetBoards) {
const boardId = board.boardId;
try {
const accessToken = await ensureFreshToken(authState);
const posts = await fetchAllPosts(accessToken, boardId);
console.log(`board ${boardId} (${board.boardName}): ${posts.length}`);
for (const post of posts) {
try {
const accessToken = await ensureFreshToken(authState);
const detail = await fetchPostDetail(accessToken, boardId, post.postId);
// 添付ファイルがあれば先に取得しておき、Markdownの説明欄にも反映する
let attachments = [];
if (Number(post.fileCount) > 0) {
try {
attachments = await fetchPostAttachments(accessToken, boardId, post.postId);
} catch (attachListErr) {
console.warn(` [警告] 添付ファイル一覧取得に失敗 (post ${post.postId}): ${attachListErr.message}`);
}
}
const bodyText = stripHtml(detail.body);
const postHash = postContentHash(boardId, post.postId, detail, bodyText);
if (uploadedHashes.has(postHash)) {
console.log(` [スキップ(重複)] ${detail.title}`);
skippedPostCount++;
} else {
const articles = splitIntoArticles(bodyText);
if (articles) {
// 規則集など条文形式: 条ごとに個別ファイルとしてアップロード
const filepaths = writeArticlesAsMarkdown(boardId, post, detail, articles);
console.log(` 取り込み完了(条文分割): ${detail.title}${filepaths.length}件に分割`);
for (const fp of filepaths) {
const location = await uploadToAnythingLLM(fp);
if (location) {
uploadedLocations.push(location);
moveFileToCompleteDir(fp, EXPORT_COMPLETE_DIR);
}
}
} else if (bodyText.length > AI_SPLIT_MIN_LENGTH) {
// 条文形式ではないが長文: AIに区切り位置だけ判断させて分割を試みる
let aiSegments = null;
try {
aiSegments = await splitWithAI(bodyText);
} catch (aiErr) {
console.warn(` [警告] AI分割に失敗、通常アップロードにフォールバック: ${aiErr.message}`);
}
if (aiSegments) {
const filepaths = writeAiSegmentsAsMarkdown(boardId, post, detail, aiSegments);
console.log(` 取り込み完了(AI分割): ${detail.title}${filepaths.length}件に分割`);
for (const fp of filepaths) {
const location = await uploadToAnythingLLM(fp);
if (location) {
uploadedLocations.push(location);
moveFileToCompleteDir(fp, EXPORT_COMPLETE_DIR);
}
}
} else {
// AI分割が使えなかった場合は通常通り1ファイルとして扱う
const filepath = writePostAsMarkdown(boardId, post, detail, attachments);
const location = await uploadToAnythingLLM(filepath);
if (location) {
uploadedLocations.push(location);
moveFileToCompleteDir(filepath, EXPORT_COMPLETE_DIR);
}
console.log(` 取り込み完了: ${detail.title}`);
}
} else {
// 通常の投稿: 1投稿1ファイルのまま
const filepath = writePostAsMarkdown(boardId, post, detail, attachments);
const location = await uploadToAnythingLLM(filepath);
if (location) {
uploadedLocations.push(location);
moveFileToCompleteDir(filepath, EXPORT_COMPLETE_DIR);
}
console.log(` 取り込み完了: ${detail.title}`);
}
uploadedHashes.add(postHash);
saveUploadedHashes(uploadedHashes);
}
// 添付ファイル本体をダウンロードしてAnythingLLMにも個別にアップロード
// (attachmentIdは不変のため、投稿本文の重複可否に関わらず添付単位で個別に判定する)
for (const attachment of attachments) {
try {
const attachmentHash = attachmentIdentityHash(boardId, post.postId, attachment);
if (uploadedHashes.has(attachmentHash)) {
console.log(` [スキップ(重複)] 添付ファイル ${attachment.fileName}`);
skippedAttachmentCount++;
continue;
}
const freshToken = await ensureFreshToken(authState);
const buffer = await downloadAttachment(freshToken, boardId, post.postId, attachment.attachmentId);
const attachmentPath = saveAttachmentFile(boardId, post.postId, attachment, buffer);
const mimeType = guessMimeType(attachment.fileName);
const attachmentLocation = await uploadToAnythingLLM(attachmentPath, mimeType);
if (attachmentLocation) {
uploadedLocations.push(attachmentLocation);
moveFileToCompleteDir(attachmentPath, ATTACHMENT_COMPLETE_DIR);
uploadedHashes.add(attachmentHash);
saveUploadedHashes(uploadedHashes);
}
console.log(` 添付ファイル取り込み完了: ${attachment.fileName}`);
} catch (attachErr) {
console.warn(` [スキップ] 添付ファイル ${attachment.fileName} の処理に失敗: ${attachErr.message}`);
}
}
} catch (postErr) {
console.warn(` [スキップ] 投稿 ${post.postId} の処理に失敗: ${postErr.message}`);
}
}
} catch (boardErr) {
console.warn(`[スキップ] board ${boardId} (${board.boardName}) の処理に失敗: ${boardErr.message}`);
skippedBoards.push({ boardId, boardName: board.boardName, reason: boardErr.message });
}
}
console.log(`重複スキップ: 投稿 ${skippedPostCount}件 / 添付ファイル ${skippedAttachmentCount}`);
if (uploadedLocations.length > 0) {
console.log(`${uploadedLocations.length} 件をワークスペースに埋め込み中...`);
await addToWorkspaceEmbeddings(uploadedLocations);
}
if (skippedBoards.length > 0) {
console.log(`\n=== スキップされた掲示板 (${skippedBoards.length}件) ===`);
skippedBoards.forEach((b) => console.log(` - [${b.boardId}] ${b.boardName}: ${b.reason}`));
console.log("↑ 権限不足の可能性があります。管理画面でService Accountの閲覧権限を確認してください。");
}
console.log("\n同期完了。");
}
async function askCommand(question) {
console.log("検索・回答生成中...");
const result = await askWorkspace(question);
console.log("\n=== 回答 ===\n");
console.log(result.textResponse);
if (result.sources?.length) {
console.log("\n=== 参照した投稿 ===");
for (const s of result.sources) {
console.log(`- ${s.title || s.filename}`);
}
}
}
async function testRecentCommand() {
const accessToken = await getAccessToken();
console.log("最新投稿リストの取得(全掲示板横断)を試します...");
const posts = await fetchRecentPosts(accessToken);
console.log(`取得成功: ${posts.length}`);
posts.slice(0, 5).forEach((p) => {
console.log(` - [board ${p.boardId}] ${p.title}`);
});
}
// ====================================================================
// CSV出力用の簡易エスケープ (カンマ・改行・ダブルクォートを含む値に対応)
// ====================================================================
function csvEscape(value) {
const str = String(value ?? "");
if (/[",\n\r]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
}
function timestampForFileName() {
const d = new Date();
const pad = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}`;
}
async function listCommand() {
const accessToken = await getAccessToken();
console.log("アクセス可能な掲示板一覧を取得します...");
const boards = await fetchAllBoards(accessToken);
console.log(`取得件数: ${boards.length}`);
const outputPath = path.join(__dirname, `board-list_${timestampForFileName()}.csv`);
const lines = ["boardId,boardName"];
for (const b of boards) {
lines.push(`${csvEscape(b.boardId)},${csvEscape(b.boardName)}`);
}
// ExcelでUTF-8を正しく開けるようBOM付きで出力
fs.writeFileSync(outputPath, "\uFEFF" + lines.join("\n"), "utf8");
console.log(`保存先: ${outputPath}`);
boards.forEach((b) => console.log(` - [${b.boardId}] ${b.boardName}`));
}
async function main() {
const [, , command, ...rest] = process.argv;
if (command === "sync") {
await syncCommand();
} else if (command === "list") {
await listCommand();
} else if (command === "test-recent") {
await testRecentCommand();
} else if (command === "ask") {
const question = rest.join(" ");
if (!question) {
console.error('使い方: node lineworks-anythingllm.js ask "問い合わせ内容"');
process.exitCode = 1;
return;
}
await askCommand(question);
} else {
console.log('使い方: node lineworks-anythingllm.js [sync|list|ask "質問文"]');
}
}
main().catch((err) => {
console.error("処理に失敗しました。");
console.error(err.message);
process.exitCode = 1;
});