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>
253 lines
12 KiB
Python
253 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Pleasanter画面の描画後HTML(Ajax反映済みDOM)をSeleniumで取得する。
|
||
API不可のためフォームログイン→Cookie確立→対象URLへ遷移→描画待ち→page_source取得。
|
||
|
||
使い方:
|
||
python scrape_rendered_html.py <URL> [--output result.html] [--wait-selector ".grid-container"]
|
||
[--headless] [--timeout 15] [--screenshot result.png]
|
||
|
||
事前準備:
|
||
pip install selenium
|
||
環境変数 PLEASANTER_LOGIN_ID / PLEASANTER_LOGIN_PASSWORD を設定するか、
|
||
スキル直下の .env ファイル(.env.example を複製して値を埋める。Git除外済み)に記載する。
|
||
環境変数が優先、無ければ .env を読む。
|
||
(Chrome or Edgeインストール済みならSelenium Managerがdriver自動解決、追加手順不要)
|
||
"""
|
||
import argparse
|
||
import os
|
||
import sys
|
||
import time
|
||
from pathlib import Path
|
||
from urllib.parse import urlsplit
|
||
|
||
from selenium import webdriver
|
||
from selenium.webdriver.common.by import By
|
||
from selenium.webdriver.common.keys import Keys
|
||
from selenium.webdriver.support.ui import WebDriverWait
|
||
from selenium.webdriver.support import expected_conditions as EC
|
||
|
||
LOGIN_PATH = "/pleasanter/users/login"
|
||
DEFAULT_WAIT_SELECTOR = "#Editor, .grid-container" # 編集画面(#Editor)/一覧画面(.grid-container)両対応
|
||
ENV_FILE = Path(__file__).resolve().parent.parent / ".env" # スキル直下(scripts/の1つ上)
|
||
|
||
|
||
def load_dotenv_fallback(env_path: Path) -> dict:
|
||
"""外部パッケージ非依存の簡易.envパーサ。KEY=VALUE形式のみ対応、既存os.environは上書きしない。"""
|
||
values = {}
|
||
if not env_path.exists():
|
||
return values
|
||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||
line = line.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, _, value = line.partition("=")
|
||
key = key.strip()
|
||
value = value.strip().strip('"').strip("'")
|
||
if key and key not in os.environ:
|
||
values[key] = value
|
||
return values
|
||
|
||
|
||
def build_driver(headless: bool, capture_console: bool = False):
|
||
options = webdriver.ChromeOptions()
|
||
if headless:
|
||
options.add_argument("--headless=new")
|
||
options.add_argument("--disable-gpu")
|
||
if capture_console:
|
||
# ブラウザのconsole.log/error等をSelenium側で取得できるようにする
|
||
# (driver.get_log('browser')で後から回収する)。
|
||
options.set_capability("goog:loggingPrefs", {"browser": "ALL"})
|
||
return webdriver.Chrome(options=options)
|
||
|
||
|
||
def login(driver, base_url: str, login_id: str, password: str, timeout: int):
|
||
driver.get(f"{base_url}{LOGIN_PATH}")
|
||
WebDriverWait(driver, timeout).until(
|
||
EC.presence_of_element_located((By.ID, "Users_LoginId"))
|
||
)
|
||
driver.find_element(By.ID, "Users_LoginId").send_keys(login_id)
|
||
# ログインボタンはフッターリンク等と重なりclick()が弾かれる場合あり。
|
||
# フォームのdata-enter="#Login"仕様に合わせEnterキー送信で submit する。
|
||
driver.find_element(By.ID, "Users_Password").send_keys(password, Keys.RETURN)
|
||
# ログイン完了判定: ログインフォームが消える(別画面へ遷移)まで待つ
|
||
WebDriverWait(driver, timeout).until_not(
|
||
EC.presence_of_element_located((By.ID, "Users_LoginId"))
|
||
)
|
||
|
||
|
||
def run_actions(driver, actions: list, timeout: int):
|
||
"""タブクリック・フィールド入力等、描画後の操作を順番に実行する。
|
||
形式: click:SELECTOR / radio:SELECTOR / type:SELECTOR:TEXT / blur:SELECTOR / js:CODE / wait:SECONDS
|
||
(type/blurのSELECTOR自体にコロンを含む複雑なCSSセレクタ(:checked等)は非対応、
|
||
素直なID/属性セレクタのみ想定)。各アクション後に短い小休止を入れ、Ajax/DOM更新の反映を待つ。"""
|
||
for action in actions:
|
||
kind, _, rest = action.partition(":")
|
||
if kind == "click":
|
||
el = WebDriverWait(driver, timeout).until(
|
||
EC.presence_of_element_located((By.CSS_SELECTOR, rest))
|
||
)
|
||
# Pleasanterのカスタムラジオ/チェックボックス等は装飾要素が重なり通常clickが
|
||
# ElementClickInterceptedExceptionで弾かれることがあるため、JS経由clickにフォールバックする。
|
||
try:
|
||
el.click()
|
||
except Exception:
|
||
driver.execute_script("arguments[0].click();", el)
|
||
elif kind == "type":
|
||
selector, _, text = rest.partition(":")
|
||
# 分類切替直後は対象フィールドがまだ非表示(display:none)の場合があるため、
|
||
# 存在チェックだけでなく可視化されるまで待つ。
|
||
el = WebDriverWait(driver, timeout).until(
|
||
EC.visibility_of_element_located((By.CSS_SELECTOR, selector))
|
||
)
|
||
el.clear()
|
||
el.send_keys(text)
|
||
elif kind == "radio":
|
||
# Pleasanterのラジオボタンはjs UIのカスタムウィジェットで装飾されており、
|
||
# ネイティブDOMのcheckedプロパティ変更+dispatchEventだけではPleasanter側の
|
||
# change処理($p.on('change', ...)、jQueryベース)まで届かないことがあるため、
|
||
# jQueryが使えればjQuery経由でprop設定+trigger、無ければネイティブ実装にフォールバックする。
|
||
el = driver.find_element(By.CSS_SELECTOR, rest)
|
||
driver.execute_script(
|
||
"if (window.jQuery) {"
|
||
" jQuery(arguments[0]).prop('checked', true).trigger('click').trigger('change');"
|
||
"} else {"
|
||
" arguments[0].checked = true;"
|
||
" arguments[0].dispatchEvent(new Event('click', {bubbles: true}));"
|
||
" arguments[0].dispatchEvent(new Event('change', {bubbles: true}));"
|
||
"}",
|
||
el,
|
||
)
|
||
elif kind == "blur":
|
||
el = driver.find_element(By.CSS_SELECTOR, rest)
|
||
# blur()だけではPleasanter側のchangeハンドラが発火しない場合があるため、
|
||
# change/blur両イベントを明示的にdispatchする。
|
||
driver.execute_script(
|
||
"arguments[0].blur();"
|
||
"arguments[0].dispatchEvent(new Event('change', {bubbles: true}));"
|
||
"arguments[0].dispatchEvent(new Event('blur', {bubbles: true}));",
|
||
el,
|
||
)
|
||
elif kind == "js":
|
||
# page_source(HTML属性のスナップショット)はJSで変更したvalue/checked等の
|
||
# 現在値プロパティを反映しないため、return文を含むjsコードなら結果を標準エラーへ出す。
|
||
result = driver.execute_script(rest)
|
||
if result is not None:
|
||
print(f"[JS結果] {result}", file=sys.stderr)
|
||
elif kind == "wait":
|
||
time.sleep(float(rest))
|
||
else:
|
||
print(f"[警告] 不明なaction種別を無視: {action}", file=sys.stderr)
|
||
continue
|
||
time.sleep(0.3)
|
||
|
||
|
||
def capture_full_page_screenshot(driver, path: str):
|
||
"""headlessモードではset_window_sizeがそのままビューポートに反映されるため、
|
||
ページ全体の高さに合わせてウィンドウサイズを広げてから撮影する(フルページ対応)。"""
|
||
width = driver.execute_script("return document.body.scrollWidth") or 1920
|
||
height = driver.execute_script("return document.body.scrollHeight") or 1080
|
||
driver.set_window_size(max(width, 1280), height + 50)
|
||
driver.save_screenshot(path)
|
||
|
||
|
||
def dump_console_log(driver, label: str):
|
||
"""goog:loggingPrefsを有効にしたdriverからブラウザconsoleログを取得しstderrへ出力する。
|
||
driver.get_log('browser')は呼ぶたびにその時点までの未取得ログを返す(消費型)ため、
|
||
タイミングを分けて複数回呼べば区間ごとのログを分離して取得できる。"""
|
||
try:
|
||
entries = driver.get_log("browser")
|
||
except Exception as e:
|
||
print(f"[警告] コンソールログ取得に失敗しました: {e}", file=sys.stderr)
|
||
return
|
||
print(f"--- [コンソールログ: {label}] ({len(entries)}件) ---", file=sys.stderr)
|
||
for entry in entries:
|
||
print(f" [{entry.get('level')}] {entry.get('message')}", file=sys.stderr)
|
||
|
||
|
||
def fetch_rendered_html(url: str, wait_selector: str, headless: bool, timeout: int, screenshot: str = None, actions: list = None, capture_console: bool = False) -> str:
|
||
dotenv_values = load_dotenv_fallback(ENV_FILE)
|
||
login_id = os.environ.get("PLEASANTER_LOGIN_ID") or dotenv_values.get("PLEASANTER_LOGIN_ID")
|
||
password = os.environ.get("PLEASANTER_LOGIN_PASSWORD") or dotenv_values.get("PLEASANTER_LOGIN_PASSWORD")
|
||
if not login_id or not password:
|
||
sys.exit(
|
||
"[エラー] ログイン情報未設定。環境変数 PLEASANTER_LOGIN_ID / PLEASANTER_LOGIN_PASSWORD を設定するか、"
|
||
f"{ENV_FILE} に記載してください(.env.example を複製して値を埋める)。"
|
||
)
|
||
|
||
parts = urlsplit(url)
|
||
base_url = f"{parts.scheme}://{parts.netloc}"
|
||
|
||
driver = build_driver(headless, capture_console)
|
||
try:
|
||
login(driver, base_url, login_id, password, timeout)
|
||
driver.get(url)
|
||
WebDriverWait(driver, timeout).until(
|
||
EC.presence_of_element_located((By.CSS_SELECTOR, wait_selector))
|
||
)
|
||
if capture_console:
|
||
dump_console_log(driver, "初期表示まで")
|
||
if actions:
|
||
run_actions(driver, actions, timeout)
|
||
if capture_console:
|
||
dump_console_log(driver, "action実行後")
|
||
if screenshot:
|
||
capture_full_page_screenshot(driver, screenshot)
|
||
print(f"[OK] スクリーンショット保存: {screenshot}", file=sys.stderr)
|
||
return driver.page_source
|
||
finally:
|
||
driver.quit()
|
||
|
||
|
||
def main():
|
||
# Windows既定のcp932コンソールだと日本語print/stderrが文字化けするため、
|
||
# 標準出力・標準エラーをUTF-8に固定する(ファイルへのリダイレクト時も正しく保存される)。
|
||
for stream in (sys.stdout, sys.stderr):
|
||
if hasattr(stream, "reconfigure"):
|
||
stream.reconfigure(encoding="utf-8")
|
||
|
||
parser = argparse.ArgumentParser(description="Pleasanter画面の描画後HTML取得(Selenium)")
|
||
parser.add_argument("url", help="取得対象URL(フルURL)")
|
||
parser.add_argument("--output", "-o", help="保存先ファイルパス(省略時は標準出力へ先頭2000文字)")
|
||
parser.add_argument(
|
||
"--wait-selector",
|
||
default=DEFAULT_WAIT_SELECTOR,
|
||
help=f"描画完了待ちCSSセレクタ(既定: {DEFAULT_WAIT_SELECTOR})",
|
||
)
|
||
parser.add_argument("--headless", action="store_true", help="ヘッドレスモードで実行")
|
||
parser.add_argument("--timeout", type=int, default=15, help="待機タイムアウト秒(既定15)")
|
||
parser.add_argument("--screenshot", help="フルページスクリーンショット保存先(PNG)。指定時のみ撮影")
|
||
parser.add_argument(
|
||
"--console-log",
|
||
action="store_true",
|
||
help="ブラウザのconsole出力(context.Logのサーバーログ転送含む)をstderrへ出力する。"
|
||
"初期表示完了時点と、--action実行後の2区間に分けて出力する。",
|
||
)
|
||
parser.add_argument(
|
||
"--action",
|
||
action="append",
|
||
default=[],
|
||
dest="actions",
|
||
help="描画待ち後に順番実行する操作。click:SELECTOR / type:SELECTOR:TEXT / blur:SELECTOR / "
|
||
"wait:SECONDS 形式(複数回指定可、指定順に実行)。例: "
|
||
"--action 'click:li[aria-controls=\"FieldSetTab1\"]' --action 'type:#Results_Class029:東京都渋谷区1-1-1' "
|
||
"--action 'blur:#Results_Class029'",
|
||
)
|
||
args = parser.parse_args()
|
||
|
||
html = fetch_rendered_html(
|
||
args.url, args.wait_selector, args.headless, args.timeout, args.screenshot, args.actions,
|
||
args.console_log,
|
||
)
|
||
|
||
if args.output:
|
||
with open(args.output, "w", encoding="utf-8") as f:
|
||
f.write(html)
|
||
print(f"[OK] 保存: {args.output} ({len(html)}文字)")
|
||
else:
|
||
print(html[:2000])
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|