518 lines
22 KiB
Python
518 lines
22 KiB
Python
"""
|
||
e-Gov ログイン Playwright スクレイパー
|
||
GビズID プレミアム (TOTP) によるログインを自動化します。
|
||
"""
|
||
import asyncio
|
||
import json
|
||
import base64
|
||
from pathlib import Path
|
||
|
||
from app.modules.egov.state import state, EGovStatus
|
||
|
||
# ── 定数 ─────────────────────────────────────────────────────
|
||
EGOV_TOP_URL = "https://shinsei.e-gov.go.jp/"
|
||
EGOV_DATA_DIR = Path("/app/egov_data")
|
||
COOKIES_PATH = EGOV_DATA_DIR / "cookies.json"
|
||
|
||
# e-Gov ログインリンク
|
||
SEL_LOGIN_LINK = 'a:has-text("ログイン"), button:has-text("ログイン")'
|
||
SEL_GBIZID_BTN = 'a:has-text("GビズID"), button:has-text("GビズID"), [alt*="GビズID"]'
|
||
|
||
# GビズID 認証フォーム(Keycloak 標準 DOM)
|
||
SEL_USERNAME = '#username, input[name="username"]'
|
||
SEL_PASSWORD = '#password, input[name="password"]'
|
||
SEL_SUBMIT = 'input[type="submit"], button[type="submit"]'
|
||
|
||
# TOTP フォーム(Keycloak 標準 DOM)
|
||
SEL_OTP = '#otp, input[name="otp"], #totp, input[name="totp"]'
|
||
|
||
# ログイン済み判定キーワード(「ログアウト」ボタンは認証済みページにのみ存在)
|
||
LOGGEDIN_MARKERS = ["ログアウト", "logout"]
|
||
|
||
# MFA 待機タイムアウト(秒)
|
||
MFA_TIMEOUT = 300
|
||
|
||
|
||
# ── メイン処理 ───────────────────────────────────────────────
|
||
|
||
async def run_login(user_id: str, password: str):
|
||
"""e-Gov ログイン処理(FastAPI BackgroundTask として実行)"""
|
||
try:
|
||
from playwright.async_api import async_playwright # 遅延import
|
||
|
||
# 既存ブラウザセッションを閉じてからリスタート
|
||
await state.close_browser()
|
||
|
||
state.status = EGovStatus.STARTING
|
||
state.message = "ブラウザを起動中..."
|
||
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
# context manager ではなく .start() で起動 → 手動で lifetime を管理
|
||
pw = await async_playwright().start()
|
||
browser = await pw.chromium.launch(
|
||
headless=True,
|
||
args=["--no-sandbox", "--disable-dev-shm-usage"],
|
||
)
|
||
context = await _load_context(browser)
|
||
page = await context.new_page()
|
||
page.set_default_timeout(30_000)
|
||
|
||
# ── e-Gov トップにアクセス ──────────────────────
|
||
state.message = "e-Gov にアクセス中..."
|
||
await page.goto(EGOV_TOP_URL, wait_until="domcontentloaded")
|
||
|
||
await _screenshot(page)
|
||
|
||
# ── セッション復元チェック ──────────────────────
|
||
if await _is_logged_in(page):
|
||
await _save_cookies(context)
|
||
# ブラウザを閉じずに保持(download で再利用)
|
||
state._playwright = pw
|
||
state._browser = browser
|
||
state._context = context
|
||
state._page = page
|
||
state.status = EGovStatus.LOGGED_IN
|
||
state.message = "ログイン済み(セッション復元)"
|
||
return
|
||
|
||
# ── ログイン → GビズID へ誘導 ──────────────────
|
||
state.message = "GビズID ログインページへ移動中..."
|
||
await _navigate_to_gbizid(page)
|
||
await _screenshot(page)
|
||
|
||
# ── ナビゲーション後の状態を確認(SSO 自動ログインの検出)──────
|
||
state.message = "ログイン画面を確認中..."
|
||
await page.wait_for_timeout(2000)
|
||
await _screenshot(page)
|
||
|
||
# Keycloak SSO が有効で e-Gov に自動リダイレクトされた場合
|
||
if await _is_logged_in(page):
|
||
await _save_cookies(context)
|
||
state._playwright = pw
|
||
state._browser = browser
|
||
state._context = context
|
||
state._page = page
|
||
state.status = EGovStatus.LOGGED_IN
|
||
state.message = "ログイン完了(SSO自動ログイン)"
|
||
return
|
||
|
||
# ── ID / パスワード入力(フォームがある場合のみ)─────────────
|
||
state.message = "GビズID・パスワードを入力中..."
|
||
username_visible = False
|
||
try:
|
||
await page.wait_for_selector(SEL_USERNAME, timeout=12_000)
|
||
username_visible = True
|
||
except Exception:
|
||
pass # OTP フォームのみ表示される場合(記憶されたセッション)
|
||
|
||
if username_visible:
|
||
await page.fill(SEL_USERNAME, user_id)
|
||
await page.fill(SEL_PASSWORD, password)
|
||
await _screenshot(page)
|
||
await page.locator(SEL_SUBMIT).first.click()
|
||
else:
|
||
# OTP フォームが表示されているか確認
|
||
otp_showing = await page.evaluate(
|
||
"() => !!document.querySelector('#otp, input[name=\"otp\"], #totp, input[name=\"totp\"]')"
|
||
)
|
||
if not otp_showing:
|
||
current_url = page.url
|
||
await _screenshot(page)
|
||
# ブラウザを保持: /egov/debug-page でページ状態を診断できるよう state._page を設定
|
||
state._playwright = pw
|
||
state._browser = browser
|
||
state._context = context
|
||
state._page = page
|
||
state.status = EGovStatus.ERROR
|
||
state.message = (
|
||
f"GビズID ログインフォームが見つかりません。"
|
||
f"現在URL: {current_url} |"
|
||
f" /egov/debug-page で詳細を確認してください。"
|
||
)
|
||
return
|
||
# OTP フォームのみ → ID/PW ステップをスキップして次へ
|
||
|
||
# ── TOTP ページを待機 ───────────────────────────
|
||
state.message = "認証コード入力ページへ移動中..."
|
||
try:
|
||
await page.wait_for_selector(SEL_OTP, timeout=12_000)
|
||
await _screenshot(page)
|
||
except Exception:
|
||
await page.wait_for_load_state("domcontentloaded")
|
||
await _screenshot(page)
|
||
if await _is_logged_in(page):
|
||
await _save_cookies(context)
|
||
state._playwright = pw
|
||
state._browser = browser
|
||
state._context = context
|
||
state._page = page
|
||
state.status = EGovStatus.LOGGED_IN
|
||
state.message = "ログイン完了(MFA なし)"
|
||
return
|
||
await state.close_browser()
|
||
raise RuntimeError(
|
||
"認証コードページへの遷移に失敗しました。"
|
||
"GビズID またはパスワードをご確認ください。"
|
||
)
|
||
|
||
# ── ユーザーの MFA 入力を待つ ───────────────────
|
||
state.status = EGovStatus.WAITING_MFA
|
||
state.message = "GビズID アプリの認証コード(6桁)を入力してください"
|
||
|
||
try:
|
||
await asyncio.wait_for(state.mfa_event.wait(), timeout=MFA_TIMEOUT)
|
||
except asyncio.TimeoutError:
|
||
await state.close_browser()
|
||
state.status = EGovStatus.ERROR
|
||
state.message = f"タイムアウト: {MFA_TIMEOUT}秒以内に認証コードが入力されませんでした"
|
||
return
|
||
|
||
# ── MFA コードを送信 ────────────────────────────
|
||
mfa_code = state.mfa_code
|
||
state.message = "認証コードを送信中..."
|
||
await page.fill(SEL_OTP, mfa_code)
|
||
await page.locator(SEL_SUBMIT).first.click()
|
||
|
||
# ── ログイン完了を確認 ──────────────────────────
|
||
await page.wait_for_load_state("domcontentloaded", timeout=15_000)
|
||
try:
|
||
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||
except Exception:
|
||
pass
|
||
await _screenshot(page)
|
||
|
||
if await _is_logged_in(page):
|
||
await _save_cookies(context)
|
||
# ブラウザを閉じずに保持
|
||
state._playwright = pw
|
||
state._browser = browser
|
||
state._context = context
|
||
state._page = page
|
||
state.status = EGovStatus.LOGGED_IN
|
||
state.message = "ログイン完了"
|
||
else:
|
||
await state.close_browser()
|
||
state.status = EGovStatus.ERROR
|
||
state.message = "ログインに失敗しました(認証コードが間違っている可能性があります)"
|
||
|
||
except Exception as exc:
|
||
await state.close_browser()
|
||
state.status = EGovStatus.ERROR
|
||
state.message = f"エラー: {exc}"
|
||
|
||
|
||
# ── 電子送達ダウンロード ──────────────────────────────────────
|
||
|
||
DOWNLOADS_DIR = EGOV_DATA_DIR / "downloads"
|
||
|
||
SEL_DOWNLOAD_BTN = 'button:has-text("通知ファイルをダウンロード"), a:has-text("通知ファイルをダウンロード")'
|
||
|
||
|
||
async def run_download():
|
||
"""電子送達一覧から最初のファイルをダウンロードする(BackgroundTask)"""
|
||
try:
|
||
if state.status != EGovStatus.LOGGED_IN:
|
||
state.status = EGovStatus.ERROR
|
||
state.message = "ログインしてからダウンロードを実行してください"
|
||
return
|
||
|
||
if state._page is None:
|
||
state.status = EGovStatus.ERROR
|
||
state.message = "ブラウザセッションがありません。再ログインしてください"
|
||
return
|
||
|
||
state.status = EGovStatus.DOWNLOADING
|
||
DOWNLOADS_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
page = state._page
|
||
|
||
# ─────────────────────────────────────────────────────
|
||
# 診断ステージ1: goto 前のページ状態(ログイン直後のページを確認)
|
||
# ─────────────────────────────────────────────────────
|
||
try:
|
||
pre = await page.evaluate("""
|
||
() => {
|
||
const b = document.body ? document.body.innerText : '';
|
||
return {
|
||
url: location.href,
|
||
hasLogout: b.includes('ログアウト'),
|
||
hasMyPage: b.includes('マイページ'),
|
||
hasDelivery: b.includes('電子送達'),
|
||
bodyLen: b.length,
|
||
};
|
||
}
|
||
""")
|
||
state.message = (
|
||
f"[診断1/goto前] url={pre['url']} "
|
||
f"ログアウト={pre['hasLogout']} マイページ={pre['hasMyPage']} "
|
||
f"電子送達={pre['hasDelivery']} bodyLen={pre['bodyLen']}"
|
||
)
|
||
except Exception as e:
|
||
state.message = f"[診断1エラー] {e}"
|
||
pre = {"hasLogout": False}
|
||
await _screenshot(page)
|
||
|
||
# ── 電子送達一覧へ移動 ──────────────────────────
|
||
await page.goto(
|
||
"https://shinsei.e-gov.go.jp/top/message/delivery-list",
|
||
wait_until="domcontentloaded",
|
||
)
|
||
try:
|
||
await page.wait_for_load_state("networkidle", timeout=15_000)
|
||
except Exception:
|
||
pass
|
||
|
||
# ─────────────────────────────────────────────────────
|
||
# 診断ステージ2: delivery-list ページの詳細状態を収集
|
||
# ─────────────────────────────────────────────────────
|
||
post = await page.evaluate("""
|
||
() => {
|
||
const b = document.body ? document.body.innerText : '(empty)';
|
||
return {
|
||
url: location.href,
|
||
title: document.title,
|
||
hasLogout: b.includes('ログアウト'),
|
||
hasMyPage: b.includes('マイページ'),
|
||
hasDelivery: b.includes('電子送達'),
|
||
bodyLen: b.length,
|
||
bodyPreview: b.slice(0, 300).replace(/\\n+/g, '|'),
|
||
links: Array.from(document.querySelectorAll('a')).map(a => ({
|
||
t: a.textContent.trim().slice(0, 30),
|
||
h: a.href
|
||
})).slice(0, 20),
|
||
};
|
||
}
|
||
""")
|
||
await _screenshot(page)
|
||
state.message = (
|
||
f"[診断2/goto後] title={post['title']} url={post['url']} "
|
||
f"ログアウト={post['hasLogout']} マイページ={post['hasMyPage']} "
|
||
f"電子送達={post['hasDelivery']} bodyLen={post['bodyLen']} "
|
||
f"body={post['bodyPreview'][:200]}"
|
||
)
|
||
|
||
# 認証状態チェック
|
||
if not post['hasLogout'] and not post['hasMyPage']:
|
||
state.status = EGovStatus.ERROR
|
||
state.message = (
|
||
f"[未認証] delivery-listが未認証状態です。"
|
||
f" goto前: ログアウト={pre.get('hasLogout')} "
|
||
f" goto後: ログアウト={post['hasLogout']} マイページ={post['hasMyPage']}"
|
||
f" body={post['bodyPreview'][:150]}"
|
||
f" links={post['links'][:10]}"
|
||
)
|
||
return
|
||
|
||
current_url = post['url']
|
||
|
||
# ── 通知タイトル(【...】形式)が現れるまで待機 ──
|
||
state.message = "[診断2OK] delivery-listのアイテムを待機中..."
|
||
try:
|
||
await page.wait_for_function(
|
||
"() => Array.from(document.querySelectorAll('a'))"
|
||
".some(a => a.textContent.includes('【') && a.href && !a.href.startsWith('javascript'))",
|
||
timeout=15_000,
|
||
)
|
||
except Exception:
|
||
pass
|
||
|
||
await _screenshot(page)
|
||
|
||
# ── 最初のアイテムリンクをJSで取得 ─────────────
|
||
detail_href = await page.evaluate("""
|
||
() => {
|
||
const titleLinks = Array.from(document.querySelectorAll('a')).filter(a =>
|
||
a.textContent.trim().startsWith('【') &&
|
||
a.href && !a.href.startsWith('javascript')
|
||
);
|
||
if (titleLinks.length > 0) return titleLinks[0].href;
|
||
|
||
const detailLinks = Array.from(document.querySelectorAll('a[href]')).filter(a =>
|
||
a.href.includes('delivery-detail') || a.href.includes('delivery/detail')
|
||
);
|
||
if (detailLinks.length > 0) return detailLinks[0].href;
|
||
|
||
return null;
|
||
}
|
||
""")
|
||
|
||
if not detail_href:
|
||
all_info = await page.evaluate("""
|
||
() => {
|
||
const b = document.body ? document.body.innerText : '';
|
||
return {
|
||
hrefs: Array.from(document.querySelectorAll('a[href]')).map(a => a.href).slice(0,10),
|
||
bodyPreview: b.slice(0, 300).replace(/\\n+/g, '|'),
|
||
};
|
||
}
|
||
""")
|
||
state.status = EGovStatus.ERROR
|
||
state.message = (
|
||
f"電子送達アイテムなし。URL={current_url} "
|
||
f"body={all_info['bodyPreview'][:200]} "
|
||
f"links={all_info['hrefs']}"
|
||
)
|
||
await _screenshot(page)
|
||
return
|
||
|
||
# ── 詳細ページへ移動 ────────────────────────────
|
||
state.message = "詳細ページへ移動中..."
|
||
await page.goto(detail_href, wait_until="domcontentloaded")
|
||
try:
|
||
await page.wait_for_load_state("networkidle", timeout=10_000)
|
||
except Exception:
|
||
pass
|
||
await _screenshot(page)
|
||
|
||
# ── 通知ファイルをダウンロード ──────────────────
|
||
state.message = "ダウンロードボタンを探しています..."
|
||
try:
|
||
async with page.expect_download(timeout=60_000) as dl_info:
|
||
await page.locator(SEL_DOWNLOAD_BTN).first.click(timeout=15_000)
|
||
download = await dl_info.value
|
||
filename = download.suggested_filename or "egov_download"
|
||
save_path = DOWNLOADS_DIR / filename
|
||
await download.save_as(str(save_path))
|
||
file_size = save_path.stat().st_size
|
||
|
||
await _save_cookies(state._context)
|
||
state.last_download = {
|
||
"filename": filename,
|
||
"path": str(save_path),
|
||
"size": file_size,
|
||
}
|
||
state.status = EGovStatus.LOGGED_IN
|
||
state.message = f"ダウンロード完了: {filename} ({file_size:,} bytes)"
|
||
await _screenshot(page)
|
||
except Exception as e:
|
||
state.status = EGovStatus.ERROR
|
||
state.message = f"ダウンロードに失敗しました: {e}"
|
||
await _screenshot(page)
|
||
|
||
except Exception as exc:
|
||
state.status = EGovStatus.ERROR
|
||
state.message = f"エラー: {exc}"
|
||
|
||
|
||
# ── プライベートヘルパー ─────────────────────────────────────
|
||
|
||
async def _navigate_to_gbizid(page):
|
||
"""ログインページ → GビズID 選択まで自動遷移"""
|
||
# アプローチ1: /top/login へ直接移動
|
||
try:
|
||
await page.goto(
|
||
"https://shinsei.e-gov.go.jp/top/login",
|
||
wait_until="domcontentloaded",
|
||
)
|
||
# Angular レンダリング完了を待機
|
||
try:
|
||
await page.wait_for_load_state("networkidle", timeout=8_000)
|
||
except Exception:
|
||
pass
|
||
await page.wait_for_timeout(2000)
|
||
except Exception:
|
||
pass
|
||
|
||
# /top/login にたどり着けなかった場合: ヘッダーのログインリンクをクリック
|
||
if "login" not in page.url and "keycloak" not in page.url and "gbizid" not in page.url:
|
||
try:
|
||
await page.locator(SEL_LOGIN_LINK).first.click(timeout=8_000)
|
||
await page.wait_for_load_state("domcontentloaded")
|
||
await page.wait_for_timeout(3000)
|
||
except Exception:
|
||
pass
|
||
|
||
await _screenshot(page)
|
||
|
||
# すでに Keycloak/GビズID ページにいる場合はスキップ
|
||
if any(k in page.url for k in ("keycloak", "gbizid", "accounts.g-biz", "gbiz.go.jp")):
|
||
return
|
||
|
||
# GビズID ボタンをクリック(複数セレクタで試行)
|
||
gbizid_selectors = [
|
||
'a:has-text("GビズID")',
|
||
'button:has-text("GビズID")',
|
||
'img[alt*="GビズID"]',
|
||
'[alt*="GビズID"]',
|
||
'a[href*="gbizid"]',
|
||
'a[href*="keycloak"]',
|
||
'a[href*="gbiz"]',
|
||
'[class*="gbiz"]',
|
||
'[id*="gbiz"]',
|
||
]
|
||
clicked = False
|
||
for sel in gbizid_selectors:
|
||
try:
|
||
el = page.locator(sel).first
|
||
await el.wait_for(timeout=3_000, state="visible")
|
||
await el.click()
|
||
await page.wait_for_load_state("domcontentloaded")
|
||
clicked = True
|
||
break
|
||
except Exception:
|
||
continue
|
||
|
||
if not clicked:
|
||
# JS で全 a/button/[role=button] をスキャンして GビズID 関連を探す
|
||
try:
|
||
clicked_js = await page.evaluate("""
|
||
() => {
|
||
const all = Array.from(document.querySelectorAll('a, button, [role="button"]'));
|
||
for (const el of all) {
|
||
const t = (el.textContent || '').trim();
|
||
const h = el.getAttribute('href') || '';
|
||
const alts = Array.from(el.querySelectorAll('img'))
|
||
.map(img => img.alt || '').join(' ');
|
||
if (t.includes('GビズID') || t.includes('gBizID') || t.includes('Gビズ')
|
||
|| h.includes('gbizid') || h.includes('keycloak') || h.includes('gbiz')
|
||
|| alts.includes('GビズID') || alts.includes('ビズID')) {
|
||
el.click();
|
||
return el.tagName + ':' + t.slice(0, 30) + ':' + h.slice(0, 60);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
""")
|
||
if clicked_js:
|
||
await page.wait_for_load_state("domcontentloaded")
|
||
await page.wait_for_timeout(2000)
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def _is_logged_in(page) -> bool:
|
||
try:
|
||
content = await page.content()
|
||
return any(m in content for m in LOGGEDIN_MARKERS)
|
||
except Exception:
|
||
return False
|
||
|
||
|
||
async def _load_context(browser):
|
||
"""保存済み Cookie からセッションを復元"""
|
||
if COOKIES_PATH.exists():
|
||
try:
|
||
with open(COOKIES_PATH) as f:
|
||
storage = json.load(f)
|
||
return await browser.new_context(storage_state=storage)
|
||
except Exception:
|
||
pass
|
||
return await browser.new_context()
|
||
|
||
|
||
async def _save_cookies(context):
|
||
"""セッション Cookie をファイルに保存"""
|
||
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||
storage = await context.storage_state()
|
||
with open(COOKIES_PATH, "w") as f:
|
||
json.dump(storage, f)
|
||
|
||
|
||
async def _screenshot(page):
|
||
"""ページのスクリーンショットを base64 で state に保存"""
|
||
try:
|
||
data = await page.screenshot(type="jpeg", quality=55)
|
||
state.screenshot_b64 = base64.b64encode(data).decode()
|
||
except Exception:
|
||
pass
|