Files
njts-accounting-core/backend/app/modules/egov/scraper.py
2026-05-20 17:45:09 +09:00

518 lines
22 KiB
Python
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.
"""
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