/** * 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();