学長AI CLI

学長AI(ベータ版)を、Claude Code(クロコ)や Codex CLI のセッション内から使えるようにするツールです。

AIに「学長に聞いて」と言うだけで、質問の整形⇨学長AIへの送信⇨回答の取り込み⇨AIの分析との統合回答まで自動になります。

回答は「学長AI回答」フォルダに自動保存されるので、聞いた内容が自分の資産として貯まります。Obsidianは不要です。

必要なもの

スマホだけの方は、リベシティアプリで学長AIに聞いて、回答をコピーしてClaude等に貼る手動運用でどうぞ。自動化はPC限定です。

注意(大事なので先に)

使い方

  1. 下の「コピー」ボタンで全文をコピーして、クロコまたはCodexに貼って送る(AIがセットアップまでやってくれます)
  2. 初回だけ node gakucho_ai.mjs login を実行して、開いたブラウザで自分でログイン
  3. 以降はAIに「学長に聞いて」と言うだけ

質問は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連携ルールここまで】