""" 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