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>
209 lines
7.8 KiB
JavaScript
209 lines
7.8 KiB
JavaScript
/**
|
||
* 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();
|