学長AI(ベータ版)を、Claude Code(クロコ)や Codex CLI のセッション内から使えるようにするツールです。
AIに「学長に聞いて」と言うだけで、質問の整形⇨学長AIへの送信⇨回答の取り込み⇨AIの分析との統合回答まで自動になります。
回答は「学長AI回答」フォルダに自動保存されるので、聞いた内容が自分の資産として貯まります。Obsidianは不要です。
スマホだけの方は、リベシティアプリで学長AIに聞いて、回答をコピーしてClaude等に貼る手動運用でどうぞ。自動化はPC限定です。
node gakucho_ai.mjs login を実行して、開いたブラウザで自分でログイン質問は500字以内&追い質問不可の仕様なので、前提をまとめて1回で聞くのがコツです。固有名詞(アプリ名・制度名)はたまに間違えるので、大事な判断はAIの裏取りとセットでどうぞ。
あなたに「学長AI CLI」をセットアップしてもらいます。以下の手順で進めてください。
1. 現在のプロジェクト内に gakucho-ai-cli フォルダを作り、その中に下記の【ファイル: gakucho_ai.mjs】を作成する
2. そのフォルダで `npm init -y && npm i playwright && npx playwright install chromium` を実行する(Node.jsが無ければ先に導入する)
3. 下記の【AI連携ルール】を、Claude Code なら CLAUDE.md に、Codex なら AGENTS.md に追記する
4. 完了したら、ユーザーに「cd gakucho-ai-cli して node gakucho_ai.mjs login を実行し、開いたブラウザでご自分でリベシティにログインしてください」と案内する。パスワードを聞いたり代行入力したりしてはいけない
【ファイル: gakucho_ai.mjs】
#!/usr/bin/env node
import { access, chmod, mkdir, writeFile } from "node:fs/promises";
import { constants as fsConstants } from "node:fs";
import { homedir } from "node:os";
import path from "node:path";
import process from "node:process";
const LOGIN_URL = "https://libecity.com/signin";
const ASK_URL = "https://libecity.com/room_list?gakucho_ai=1";
const TEXTAREA_SELECTOR =
'textarea[placeholder="学長に聞いてみたいことを入力してください"]';
const SEND_SELECTOR = 'input[type="submit"].btn_send[value="送信"]';
const ANSWER_END_MARKER = "※AIのため、回答を間違えることがあります";
const LOGIN_TIMEOUT_MS = 5 * 60 * 1000;
const ANSWER_TIMEOUT_MS = 120 * 1000;
const MAX_QUESTION_LENGTH = 500;
const CONFIG_DIR = path.join(homedir(), ".gakucho_ai");
// リベシティはFirebase認証のためログイン情報がIndexedDBに入る。
// cookieのみのstorageStateでは復元できないので、永続プロファイルを使う。
const PROFILE_DIR = path.join(CONFIG_DIR, "profile");
class SessionExpiredError extends Error {}
async function loadChromium() {
try {
const playwright = await import("playwright");
return playwright.chromium;
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
throw new Error(
"Playwrightが見つかりません。READMEのセットアップを先に実行してください。",
);
}
throw error;
}
}
function isLibecityPath(rawUrl, pathnamePrefix) {
try {
const url = new URL(rawUrl);
const isLibecity =
url.hostname === "libecity.com" ||
url.hostname.endsWith(".libecity.com");
return isLibecity && url.pathname.startsWith(pathnamePrefix);
} catch {
return false;
}
}
async function fileExists(filePath) {
try {
await access(filePath, fsConstants.R_OK);
return true;
} catch {
return false;
}
}
async function gotoWithOneRetry(page, url) {
let lastError;
for (let attempt = 0; attempt < 2; attempt += 1) {
try {
const response = await page.goto(url, {
waitUntil: "domcontentloaded",
timeout: 60_000,
});
if (response && response.status() >= 500) {
throw new Error("ページを開けませんでした(HTTP " + response.status() + ")");
}
return;
} catch (error) {
lastError = error;
if (attempt === 0) {
console.error("通信に失敗したため、1回だけ再試行します。");
}
}
}
throw lastError;
}
async function closeBrowser(browser) {
if (!browser) {
return;
}
try {
await browser.close();
} catch {
// すでに手動で閉じられている場合は、そのまま終了します。
}
}
function normalizeText(value) {
return String(value ?? "")
.replace(/\r\n?/g, "\n")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function cleanAnswerCandidate(candidate, question) {
let answer = normalizeText(candidate);
const markerIndex = answer.indexOf(ANSWER_END_MARKER);
if (markerIndex === -1) {
return "";
}
const questionIndex = answer.indexOf(question);
const questionAppearsNearStart =
questionIndex !== -1 &&
questionIndex < markerIndex &&
(questionIndex <= 80 || questionIndex <= answer.length * 0.25);
if (questionAppearsNearStart) {
answer = answer.slice(questionIndex + question.length).trim();
}
const answerMarkerIndex = answer.indexOf(ANSWER_END_MARKER);
if (answerMarkerIndex === -1) {
return "";
}
answer = answer
.slice(0, answerMarkerIndex + ANSWER_END_MARKER.length)
.replace(/^回答を考えています(\.{3}|…)?\s*/u, "")
.replace(/^(学長AIの回答|学長AI|回答)[::\s]+/u, "")
// 評価UI(この回答はどうでしたか?/良い/イマイチ)と末尾マーカーは本文から除く
.replace(/この回答はどうでしたか[??]?[\s\S]*$/u, "")
.replace(new RegExp(ANSWER_END_MARKER.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "\\s*$", "u"), "")
.trim();
return answer;
}
function extractInsertedText(beforeText, afterText) {
const before = normalizeText(beforeText);
const after = normalizeText(afterText);
let prefixLength = 0;
while (
prefixLength < before.length &&
prefixLength < after.length &&
before[prefixLength] === after[prefixLength]
) {
prefixLength += 1;
}
let suffixLength = 0;
while (
suffixLength < before.length - prefixLength &&
suffixLength < after.length - prefixLength &&
before[before.length - 1 - suffixLength] ===
after[after.length - 1 - suffixLength]
) {
suffixLength += 1;
}
return after.slice(
prefixLength,
suffixLength === 0 ? after.length : after.length - suffixLength,
);
}
async function extractAnswer(page, beforeText, afterText, question) {
const domCandidate = await page.evaluate((marker) => {
const candidates = [];
for (const element of document.querySelectorAll("body *")) {
const style = window.getComputedStyle(element);
if (
style.display === "none" ||
style.visibility === "hidden" ||
element.getAttribute("aria-hidden") === "true"
) {
continue;
}
const text = (element.innerText || "").trim();
if (!text.includes(marker) || text === marker) {
continue;
}
const textBeforeMarker = text.slice(0, text.indexOf(marker)).trim();
if (!textBeforeMarker) {
continue;
}
let depth = 0;
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
depth += 1;
}
candidates.push({ text, depth });
}
candidates.sort(
(left, right) =>
left.text.length - right.text.length || right.depth - left.depth,
);
return candidates[0]?.text || "";
}, ANSWER_END_MARKER);
const domAnswer = cleanAnswerCandidate(domCandidate, question);
if (domAnswer) {
return domAnswer;
}
const insertedText = extractInsertedText(beforeText, afterText);
const differenceAnswer = cleanAnswerCandidate(insertedText, question);
if (differenceAnswer) {
return differenceAnswer;
}
const normalizedAfter = normalizeText(afterText);
const markerIndex = normalizedAfter.indexOf(ANSWER_END_MARKER);
const questionIndex = normalizedAfter.lastIndexOf(question, markerIndex);
if (markerIndex !== -1 && questionIndex !== -1) {
const questionAndAnswer = normalizedAfter.slice(
questionIndex,
markerIndex + ANSWER_END_MARKER.length,
);
const finalAnswer = cleanAnswerCandidate(questionAndAnswer, question);
if (finalAnswer) {
return finalAnswer;
}
}
throw new Error(
"回答の完了は確認できましたが、回答本文を抽出できませんでした。",
);
}
function pad2(value) {
return String(value).padStart(2, "0");
}
function formatTimestamp(date) {
return (
date.getFullYear() +
"-" +
pad2(date.getMonth() + 1) +
"-" +
pad2(date.getDate()) +
" " +
pad2(date.getHours()) +
":" +
pad2(date.getMinutes()) +
":" +
pad2(date.getSeconds())
);
}
function formatFilenameTimestamp(date) {
return (
date.getFullYear() +
pad2(date.getMonth() + 1) +
pad2(date.getDate()) +
"_" +
pad2(date.getHours()) +
pad2(date.getMinutes()) +
pad2(date.getSeconds())
);
}
async function saveAnswer(question, answer) {
const savedAt = new Date();
const outputDir = path.join(process.cwd(), "学長AI回答");
const outputPath = path.join(
outputDir,
"学長AI回答_" + formatFilenameTimestamp(savedAt) + ".md",
);
const markdown = [
"# 質問",
"",
question,
"",
"# 学長AIの回答",
"",
answer,
"",
"取得日時: " + formatTimestamp(savedAt),
"",
].join("\n");
await mkdir(outputDir, { recursive: true });
await writeFile(outputPath, markdown, "utf8");
return outputPath;
}
async function runLogin() {
let browser;
try {
const chromium = await loadChromium();
await mkdir(CONFIG_DIR, { recursive: true, mode: 0o700 });
await chmod(CONFIG_DIR, 0o700);
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: false,
});
browser = context;
const page = context.pages()[0] ?? (await context.newPage());
await gotoWithOneRetry(page, LOGIN_URL);
console.error(
"ブラウザでリベシティにログインしてください。パスワードは自分で入力してください。",
);
try {
await page.waitForURL(
(url) => isLibecityPath(url.toString(), "/mypage"),
{ timeout: LOGIN_TIMEOUT_MS },
);
} catch {
throw new Error(
"5分以内にログイン完了を確認できませんでした。もう一度 login を実行してください。",
);
}
// Firebase認証トークンがIndexedDBへ書き込まれるのを待つ
await page.waitForTimeout(3_000);
await chmod(PROFILE_DIR, 0o700);
console.log("ログイン状態を保存しました。次回から ask が使えます");
} finally {
await closeBrowser(browser);
}
}
async function runAsk(question) {
if (question.length === 0) {
throw new Error("質問文を入力してください。");
}
if (question.length > MAX_QUESTION_LENGTH) {
throw new Error(
"質問文は500字以内にしてください(現在 " + question.length + "字です)。",
);
}
if (!(await fileExists(PROFILE_DIR))) {
throw new Error(
"先に login を実行してください: node gakucho_ai.mjs login",
);
}
let browser;
try {
const chromium = await loadChromium();
const context = await chromium.launchPersistentContext(PROFILE_DIR, {
headless: true,
});
browser = context;
const page = context.pages()[0] ?? (await context.newPage());
await gotoWithOneRetry(page, ASK_URL);
if (isLibecityPath(page.url(), "/signin")) {
throw new SessionExpiredError();
}
const textarea = page.locator(TEXTAREA_SELECTOR).first();
const submit = page.locator(SEND_SELECTOR).first();
try {
await textarea.waitFor({ state: "visible", timeout: 30_000 });
await submit.waitFor({ state: "visible", timeout: 30_000 });
} catch (error) {
if (isLibecityPath(page.url(), "/signin")) {
throw new SessionExpiredError();
}
throw error;
}
const beforeText = await page.locator("body").innerText();
await textarea.fill(question);
await page.waitForFunction(
(selector) => {
const button = document.querySelector(selector);
return button instanceof HTMLInputElement && !button.disabled;
},
SEND_SELECTOR,
{ polling: 100, timeout: 10_000 },
);
await submit.click();
try {
await page.waitForFunction(
(marker) =>
Boolean(document.body && document.body.innerText.includes(marker)),
ANSWER_END_MARKER,
{ polling: 500, timeout: ANSWER_TIMEOUT_MS },
);
} catch (error) {
if (isLibecityPath(page.url(), "/signin")) {
throw new SessionExpiredError();
}
throw error;
}
// マーカーは回答ストリーミング中も表示されるため、
// 「本文が3秒間変化しない」ことをもって回答完了とみなす。
{
const deadline = Date.now() + ANSWER_TIMEOUT_MS;
let previousText = "";
let stableCount = 0;
while (Date.now() < deadline) {
await page.waitForTimeout(1_000);
const currentText = await page.locator("body").innerText();
const stillThinking = currentText.includes("回答を考えています");
if (!stillThinking && currentText === previousText) {
stableCount += 1;
if (stableCount >= 3) {
break;
}
} else {
stableCount = 0;
}
previousText = currentText;
}
}
const afterText = await page.locator("body").innerText();
const answer = await extractAnswer(page, beforeText, afterText, question);
const outputPath = await saveAnswer(question, answer);
console.log(answer);
console.error("保存先: " + outputPath);
} finally {
await closeBrowser(browser);
}
}
function printUsage() {
console.error(
[
"使い方:",
" node gakucho_ai.mjs login",
' node gakucho_ai.mjs ask "質問文"',
].join("\n"),
);
}
async function main() {
const [command, ...rest] = process.argv.slice(2);
if (command === "login") {
if (rest.length > 0) {
throw new Error("login に引数は不要です。");
}
await runLogin();
return;
}
if (command === "ask") {
await runAsk(rest.join(" ").trim());
return;
}
printUsage();
process.exitCode = 1;
}
main().catch((error) => {
if (error instanceof SessionExpiredError) {
console.error(
"ログインが切れています。node gakucho_ai.mjs login を再実行してください",
);
} else {
console.error("エラー: " + (error?.message || String(error)));
}
process.exitCode = 1;
});
【ファイルここまで】
【AI連携ルール(CLAUDE.md / AGENTS.md に追記)】
# 学長AI連携ルール
ユーザーが「学長に聞いて」「学長AIに相談して」と依頼した場合は、次の順番で対応します。
1. 相談内容を、【前提】の箇条書きと【相談】の形式に整えます。学長AIは追い質問ができないため、判断に必要な前提を1回の質問にまとめます。質問文は500字以内にし、実名・住所・勤務先など、個人を特定できる情報は書きません。
2. 整形した質問文をユーザーに見せます。送信してよいか確認し、明確にOKをもらうまでは送信しません。
3. OKをもらったら、質問文を1つの引数として安全に引用し、gakucho-ai-cli フォルダで `node gakucho_ai.mjs ask "<質問文>"` を実行します。1回の依頼につき、質問は1回だけ送信します。
4. 学長AIの回答を読み、自分の分析と突き合わせます。「学長AIの見解」「自分の分析」「統合結論」の3つに分けてユーザーへ回答します。アプリ名・制度名などの固有名詞は誤っている可能性があるため、信頼できる情報源で必ず裏取りします。裏取りできない場合は、未確認であることを明記します。
5. 実際に作成された保存先ファイルのパスを案内します。
「ログインが切れています」と表示された場合は、`node gakucho_ai.mjs login` をユーザー自身に再実行してもらいます。
ログイン画面のパスワードや認証情報は、質問したり、受け取ったり、自動入力したりしません。
新しく質問する前に、学長AI回答フォルダの既存ファイルを確認します。同じテーマの過去回答があれば、それも読んだうえで回答に活かします。このフォルダは Claude Code と Codex のどちらからも読める共通の記録場所です(Obsidian利用者はvault内に置くとノートとして育てられます。使っていない人はそのままでOK)。
【AI連携ルールここまで】