ken_nogi/Pleasanter/.claude/js/get-page-html.js
Kenichiro NOGI ce58cb4be4 初回コミット: dev配下(NodeSrv/Pleasanter等)をGitea管理下に統合
GitHub(nextgroup2706/ken_nogi)は今後使わず自社Gitea運用に切替え。
NodeSrvは旧リポジトリの履歴を破棄しファイルのみ統合(Dokploy用サービスアカウントは
別途mygit-admin/NodeSrv.gitに履歴あり)。notepmエクスポート(12GB)とPleasanter
インストーラzip(208MB)はサイズが大きいため.gitignoreで除外。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 15:37:06 +09:00

209 lines
7.8 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.

/**
* 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("; ");
}
// <input type="hidden" ...> のname/valueを、属性の並び順に依存せず抽出する
// CSRFトークン等、実際のフィールド名を仮定しないための処理
function extractHiddenFields(html) {
const fields = {};
const inputTags = html.match(/<input\b[^>]*>/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();