/**
* get-page-html.js
* ------------------------------------------------------------
* プリザンターの「画面そのもの」(HTML)を、セッション認証(フォームログイン)で
* 取得して保存するツール。ClaudePleasanter直下に置く共通実装。
* ClaudePleasanter直下から直接実行する(対象プロジェクトは --project で指定)。
*
* APIキーは画面HTML取得には使えない(APIはJSON専用)ため、/users/login に対して
* フォームログインを行いセッションCookieを確立してから、対象URLをGETする。
*
* 使い方:
* node .claude/js/get-page-html.js --project="IC(東京)【引継依頼】" --env=production /items/12345/edit
* node .claude/js/get-page-html.js --project=IC東京 --env=development https://example.pleasanter.jp/items/12345/edit result.html
*
* 事前準備(リポジトリルート直下の config_{env}.json に追記。config.example.json参照):
* {
* "BaseUrl": "https://example.pleasanter.jp/",
* "ApiKey": "...",
* "LoginId": "ログインID",
* "Password": "パスワード"
* }
* ログインフォームの項目名が既定(LoginId / Password)と異なる場合は
* LoginIdField / PasswordField で上書き可能。
*
* パスワードをファイルに置きたくない場合は、config_{env}.json に書かず
* 環境変数 PLEASANTER_LOGIN_PASSWORD で渡してもよい
* (config_{env}.jsonの値より環境変数を優先する)。
*
* 保存先:
* {対象プロジェクトフォルダ}/configs/{env}/html-dump/{接続先ホスト名}/ 配下
* (configs/ はgit管理外のため、取得したHTMLがリポジトリに残ることはない)
* ------------------------------------------------------------
*/
const fs = require("fs");
const path = require("path");
const { sanitize, ensureDir } = require("./site-paths");
const { resolveProjectRoot, loadServerConfig } = require("./resolve-project");
function loadLoginConfig(env) {
const config = loadServerConfig(env);
const LoginId = process.env.PLEASANTER_LOGIN_ID || config.LoginId;
const Password = process.env.PLEASANTER_LOGIN_PASSWORD || config.Password;
const LoginIdField = config.LoginIdField || "LoginId";
const PasswordField = config.PasswordField || "Password";
if (!LoginId || !Password) {
console.error(`[エラー] config_${env}.json に LoginId / Password を設定してください`);
console.error("(Passwordは環境変数 PLEASANTER_LOGIN_PASSWORD で渡すことも可能)。");
process.exit(1);
}
return { BaseUrl: config.BaseUrl, LoginId, Password, LoginIdField, PasswordField };
}
function normalizeBase(baseUrl) {
return baseUrl.replace(/\/+$/, "");
}
function resolveTargetUrl(baseUrl, target) {
if (/^https?:\/\//i.test(target)) return target;
return `${normalizeBase(baseUrl)}/${target.replace(/^\/+/, "")}`;
}
// Set-Cookie群からCookie名=値のみを抜き出しjarへマージする
function mergeCookies(jar, response) {
const setCookies =
typeof response.headers.getSetCookie === "function"
? response.headers.getSetCookie()
: (response.headers.get("set-cookie") ? [response.headers.get("set-cookie")] : []);
for (const raw of setCookies) {
const pair = raw.split(";")[0];
const eq = pair.indexOf("=");
if (eq === -1) continue;
const name = pair.slice(0, eq).trim();
const value = pair.slice(eq + 1).trim();
if (name) jar.set(name, value);
}
}
function cookieHeader(jar) {
return Array.from(jar.entries())
.map(([k, v]) => `${k}=${v}`)
.join("; ");
}
// のname/valueを、属性の並び順に依存せず抽出する
// (CSRFトークン等、実際のフィールド名を仮定しないための処理)
function extractHiddenFields(html) {
const fields = {};
const inputTags = html.match(/]*>/gi) || [];
for (const tag of inputTags) {
const attrs = {};
const attrRe = /([\w-]+)\s*=\s*"([^"]*)"|([\w-]+)\s*=\s*'([^']*)'/g;
let m;
while ((m = attrRe.exec(tag)) !== null) {
const name = (m[1] || m[3] || "").toLowerCase();
const value = m[2] !== undefined ? m[2] : m[4];
attrs[name] = value;
}
if (attrs.type && attrs.type.toLowerCase() === "hidden" && attrs.name) {
fields[attrs.name] = attrs.value || "";
}
}
return fields;
}
function looksLikeLoginPage(html) {
return /type=["']password["']/i.test(html) && /login/i.test(html);
}
async function login(baseUrl, { LoginId, Password, LoginIdField, PasswordField }, jar) {
const loginUrl = `${normalizeBase(baseUrl)}/users/login`;
console.log(`[INFO] ログインページ取得: ${loginUrl}`);
const loginPageRes = await fetch(loginUrl, { redirect: "manual" });
mergeCookies(jar, loginPageRes);
const loginPageHtml = await loginPageRes.text();
const hiddenFields = extractHiddenFields(loginPageHtml);
console.log(`[INFO] ログインフォーム hidden項目: ${Object.keys(hiddenFields).join(", ") || "(なし)"}`);
const form = new URLSearchParams();
for (const [k, v] of Object.entries(hiddenFields)) form.set(k, v);
form.set(LoginIdField, LoginId);
form.set(PasswordField, Password);
console.log("[INFO] ログインPOST送信...");
const loginRes = await fetch(loginUrl, {
method: "POST",
redirect: "manual",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Cookie: cookieHeader(jar),
},
body: form.toString(),
});
mergeCookies(jar, loginRes);
const status = loginRes.status;
const isRedirect = status >= 300 && status < 400;
console.log(`[INFO] ログインレスポンス: HTTP ${status}${isRedirect ? " (redirect)" : ""}`);
if (!isRedirect) {
const body = await loginRes.text().catch(() => "");
if (looksLikeLoginPage(body)) {
console.error("[エラー] ログインに失敗した可能性があります(ログインページが返されました)。");
console.error("LoginId/Password、またはLoginIdField/PasswordFieldの項目名を確認してください。");
return false;
}
}
return true;
}
async function fetchPage(targetUrl, jar) {
console.log(`[INFO] 対象ページ取得: ${targetUrl}`);
const res = await fetch(targetUrl, {
redirect: "follow",
headers: { Cookie: cookieHeader(jar) },
});
mergeCookies(jar, res);
const html = await res.text();
console.log(`[INFO] レスポンス: HTTP ${res.status} (${html.length}文字)`);
return { html, status: res.status };
}
async function main() {
const { baseDir, env, argv } = resolveProjectRoot();
const target = argv[0];
if (!target) {
console.error("[エラー] 対象パスまたはURLを指定してください。");
console.error("使い方: node get-page-html.js --project=<フォルダ名> --env=production <対象パス or フルURL> [出力ファイル名]");
process.exit(1);
}
const outputArg = argv[1];
const config = loadLoginConfig(env);
const jar = new Map();
const ok = await login(config.BaseUrl, config, jar);
if (!ok) process.exit(1);
const targetUrl = resolveTargetUrl(config.BaseUrl, target);
const { html, status } = await fetchPage(targetUrl, jar);
if (looksLikeLoginPage(html)) {
console.warn("[警告] 取得結果がログインページのようです。セッションが確立できていない可能性があります。");
}
let host = "unknown-host";
try {
host = sanitize(new URL(targetUrl).host);
} catch (_) {}
const outDir = ensureDir(path.join(baseDir, "configs", env, "html-dump", host));
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
const fileName = outputArg || `${timestamp}_${sanitize(target)}.html`;
const outFile = path.join(outDir, fileName);
fs.writeFileSync(outFile, html, "utf-8");
console.log(`[OK] 保存しました: ${outFile}`);
if (status >= 400) process.exit(1);
}
main();