This commit is contained in:
admin
2026-05-20 17:45:09 +09:00
parent 63ac6bc99a
commit da3aef25ce
19 changed files with 17286 additions and 407 deletions

View File

View File

@@ -0,0 +1,235 @@
"""
e-Gov 電子申請 API v2 クライアント
OAuth2 + PKCE フローによる認証と電子送達ファイルのダウンロード
"""
import hashlib
import base64
import secrets
import os
import json
import time
from pathlib import Path
from typing import Optional
import httpx
# ── エンドポイント定数 ─────────────────────────────────────────
# EGOV_ENV=sandbox で検証環境、省略または production で本番環境を使用
_ENV = os.environ.get("EGOV_ENV", "production").lower()
if _ENV == "sandbox":
AUTH_BASE = "https://account2.sbx.e-gov.go.jp/auth"
API_BASE = "https://api2.sbx.e-gov.go.jp/shinsei/v2"
else:
AUTH_BASE = "https://account.e-gov.go.jp/auth"
API_BASE = "https://api.e-gov.go.jp/shinsei/v2"
AUTH_URL = f"{AUTH_BASE}/auth"
TOKEN_URL = f"{AUTH_BASE}/token"
LOGOUT_URL = f"{AUTH_BASE}/logout"
EGOV_DATA_DIR = Path("/app/egov_data")
TOKENS_PATH = EGOV_DATA_DIR / "tokens.json"
CONFIG_PATH = EGOV_DATA_DIR / "api_config.json"
# ── 資格情報 ──────────────────────────────────────────────────
def load_api_config() -> dict:
"""API設定を config ファイルから読み込む(環境変数でも上書き可)"""
cfg = {}
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH) as f:
cfg = json.load(f)
except Exception:
pass
# 環境変数が優先
client_id = os.environ.get("EGOV_SOFTWARE_ID") or cfg.get("software_id", "")
client_secret = os.environ.get("EGOV_API_KEY") or cfg.get("api_key", "")
redirect_uri = os.environ.get("EGOV_REDIRECT_URI") or cfg.get("redirect_uri",
"http://192.168.0.61:18000/egov.html")
return {
"client_id": client_id,
"client_secret": client_secret,
"redirect_uri": redirect_uri,
}
def save_api_config(software_id: str, api_key: str,
redirect_uri: str = "http://192.168.0.61:18000/egov.html"):
"""API設定をファイルに保存"""
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(CONFIG_PATH, "w") as f:
json.dump({"software_id": software_id,
"api_key": api_key,
"redirect_uri": redirect_uri}, f)
# ── トークン永続化 ────────────────────────────────────────────
def load_tokens() -> dict:
if TOKENS_PATH.exists():
try:
with open(TOKENS_PATH) as f:
return json.load(f)
except Exception:
pass
return {}
def save_tokens(tokens: dict):
EGOV_DATA_DIR.mkdir(parents=True, exist_ok=True)
with open(TOKENS_PATH, "w") as f:
json.dump(tokens, f)
def delete_tokens():
if TOKENS_PATH.exists():
TOKENS_PATH.unlink()
# ── PKCE ─────────────────────────────────────────────────────
def generate_pkce() -> tuple[str, str]:
"""code_verifier と code_challenge (S256) を生成"""
verifier = secrets.token_urlsafe(48) # 64文字 (43-128の範囲内)
digest = hashlib.sha256(verifier.encode()).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()
return verifier, challenge
# ── OAuth2 フロー ─────────────────────────────────────────────
def build_auth_url(client_id: str, redirect_uri: str,
challenge: str, state: str) -> str:
"""OAuth2 認可URL を生成"""
from urllib.parse import urlencode, quote
params = {
"client_id": client_id,
"response_type": "code",
"scope": "openid offline_access",
"redirect_uri": redirect_uri,
"state": state,
"code_challenge": challenge,
"code_challenge_method": "S256",
}
# quote_via=quote を使うことでスペースを + ではなく %20 にエンコードする
return f"{AUTH_URL}?" + urlencode(params, quote_via=quote)
async def exchange_code(code: str, client_id: str, client_secret: str,
redirect_uri: str, verifier: str) -> dict:
"""認可コード → アクセストークン・リフレッシュトークン取得"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={
"grant_type": "authorization_code",
"code": code,
"redirect_uri": redirect_uri,
"code_verifier": verifier,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def refresh_tokens_request(refresh_token: str, client_id: str,
client_secret: str) -> dict:
"""リフレッシュトークンでアクセストークン再取得"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
resp = await client.post(
TOKEN_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def do_logout(refresh_token: str, client_id: str, client_secret: str):
"""ログアウト(トークン無効化)"""
cred = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
async with httpx.AsyncClient() as client:
try:
await client.post(
LOGOUT_URL,
headers={
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": f"Basic {cred}",
},
data={"refresh_token": refresh_token},
timeout=15,
)
except Exception:
pass
# ── 電子送達 API ──────────────────────────────────────────────
async def get_post_list(access_token: str, date_from: str, date_to: str,
limit: int = 50) -> dict:
"""
電子送達一覧取得
GET /post/lists?date_from=YYYY-MM-DD&date_to=YYYY-MM-DD&limit=N&offset=1
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{API_BASE}/post/lists",
headers={"Authorization": f"Bearer {access_token}"},
params={"date_from": date_from, "date_to": date_to,
"limit": limit, "offset": 1},
timeout=30,
)
resp.raise_for_status()
return resp.json()
async def get_post_file(access_token: str, post_id: str) -> dict:
"""
電子送達取得(通知文書ファイルを base64 で返す)
GET /post/{post_id}
Returns: {notice_data_name, file_data (base64), file_name_list}
"""
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{API_BASE}/post/{post_id}",
headers={"Authorization": f"Bearer {access_token}"},
timeout=60,
)
resp.raise_for_status()
return resp.json()
async def mark_post_done(access_token: str, post_id: str) -> dict:
"""
電子送達取得完了登録
POST /post {"post_id": "..."}
"""
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{API_BASE}/post",
headers={
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json",
},
json={"post_id": post_id},
timeout=30,
)
resp.raise_for_status()
return resp.json()

View File

@@ -0,0 +1,372 @@
import base64
import secrets
import time
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, HTTPException, BackgroundTasks
from fastapi.responses import FileResponse
from pydantic import BaseModel
from app.modules.egov.state import state, EGovStatus
from app.modules.egov.scraper import run_login, run_download
import app.modules.egov.egov_api as egov_api
router = APIRouter(prefix="/egov", tags=["e-Gov"])
class LoginRequest(BaseModel):
user_id: str
password: str
class MfaRequest(BaseModel):
mfa_code: str
class CallbackRequest(BaseModel):
code: str
state: str
class ConfigRequest(BaseModel):
software_id: str
api_key: str
redirect_uri: str = "http://192.168.0.61:18000/egov.html"
@router.get("/status")
def get_status():
import os
return {
"status": state.status,
"message": state.message,
"screenshot": state.screenshot_b64,
"last_download": state.last_download,
"token_valid": state.is_token_valid(),
"environment": os.environ.get("EGOV_ENV", "production"),
}
@router.post("/login-start")
async def login_start(req: LoginRequest, background_tasks: BackgroundTasks):
if state.status in (EGovStatus.STARTING, EGovStatus.WAITING_MFA):
raise HTTPException(status_code=409, detail="ログイン処理が既に実行中です")
await state.close_browser()
state.reset()
background_tasks.add_task(run_login, req.user_id, req.password)
return {"message": "ログイン処理を開始しました"}
@router.post("/login-mfa")
async def login_mfa(req: MfaRequest):
if state.status != EGovStatus.WAITING_MFA:
raise HTTPException(status_code=409, detail="MFA 入力待ち状態ではありません")
if not req.mfa_code.isdigit() or len(req.mfa_code) != 6:
raise HTTPException(status_code=400, detail="認証コードは 6 桁の数字です")
state.mfa_code = req.mfa_code
state.mfa_event.set()
return {"message": "認証コードを送信しました"}
@router.post("/logout")
async def logout():
# API トークンがあればサーバー側でも無効化
if state.refresh_token:
try:
cfg = egov_api.load_api_config()
await egov_api.do_logout(
state.refresh_token,
cfg["client_id"],
cfg["client_secret"],
)
except Exception:
pass
egov_api.delete_tokens()
await state.close_browser()
state.reset()
cookies = Path("/app/egov_data/cookies.json")
if cookies.exists():
cookies.unlink()
return {"message": "ログアウトしました"}
@router.post("/download-start")
async def download_start(background_tasks: BackgroundTasks):
if state.status == EGovStatus.DOWNLOADING:
raise HTTPException(status_code=409, detail="ダウンロードが既に実行中です")
# API トークンが有効なら API 方式で実行
if state.is_token_valid() or state.refresh_token:
background_tasks.add_task(_api_download)
return {"message": "ダウンロードを開始しました (API 方式)"}
# 保存済みトークンを確認
saved = egov_api.load_tokens()
if saved.get("access_token"):
state.set_tokens(saved)
if state.is_token_valid() or saved.get("refresh_token"):
state.refresh_token = saved.get("refresh_token")
background_tasks.add_task(_api_download)
return {"message": "ダウンロードを開始しました (API 方式・保存トークン)"}
# Playwright セッションがあるなら旧方式
if state.status == EGovStatus.LOGGED_IN and state._page is not None:
background_tasks.add_task(run_download)
return {"message": "ダウンロードを開始しました (Playwright 方式)"}
raise HTTPException(
status_code=401,
detail="認証が必要です。/egov/auth-url でGビズID認証を行ってください。"
)
async def _api_download():
"""e-Gov API v2 を使って電子送達ファイルをダウンロード"""
state.status = EGovStatus.DOWNLOADING
state.message = "e-Gov API で電子送達を確認中..."
try:
cfg = egov_api.load_api_config()
if not cfg["client_id"] or not cfg["client_secret"]:
raise RuntimeError("e-Gov API の資格情報が設定されていません。/egov/config で設定してください。")
# トークン更新(期限切れの場合)
if not state.is_token_valid() and state.refresh_token:
state.message = "アクセストークンを更新中..."
token_resp = await egov_api.refresh_tokens_request(
state.refresh_token,
cfg["client_id"],
cfg["client_secret"],
)
state.set_tokens(token_resp)
egov_api.save_tokens({
"access_token": state.access_token,
"refresh_token": state.refresh_token,
"expires_at": state.token_expires_at,
})
# 電子送達一覧を取得(直近 90 日間)
date_to = datetime.now().strftime("%Y-%m-%d")
date_from = (datetime.now() - timedelta(days=90)).strftime("%Y-%m-%d")
state.message = f"電子送達一覧を取得中 ({date_from} {date_to})..."
list_resp = await egov_api.get_post_list(state.access_token, date_from, date_to)
# OpenAPI仕様: レスポンスは results.post_list に配列
results = list_resp.get("results", {})
post_list = results.get("post_list", [])
# post_list は電子送達ヘッダー一覧。各要素に notice_data_list があり
# そこに個々の post_id が含まれる
# フラットなリストに変換
posts = []
for item in post_list:
for nd in item.get("notice_data_list", []):
posts.append({
"post_id": nd.get("post_id", ""),
"notice_data_name": nd.get("notice_data_name", ""),
"notice_issue_date": nd.get("notice_issue_date", ""),
"download_expired_date": nd.get("download_expired_date", ""),
"download_date": nd.get("download_date"),
"title": item.get("title", ""),
"issuer_organization": item.get("issuer_organization", ""),
})
if not posts:
state.status = EGovStatus.LOGGED_IN
state.message = f"電子送達はありません ({date_from} {date_to})"
return
# 未取得のものdownload_date が空)を優先
pending = [p for p in posts if not p.get("download_date")]
targets = pending if pending else posts
egov_data = Path("/app/egov_data")
egov_data.mkdir(parents=True, exist_ok=True)
downloaded = []
for post in targets:
post_id = post.get("post_id", "")
if not post_id:
continue
state.message = f"通知ファイルを取得中 (post_id={post_id})..."
file_resp = await egov_api.get_post_file(state.access_token, post_id)
# OpenAPI仕様: レスポンスは results.file_data / results.notice_data_name
file_results = file_resp.get("results", {})
file_data_b64 = file_results.get("file_data", "")
file_name_list = file_results.get("file_name_list", [])
first_name = file_name_list[0].get("file_name") if file_name_list else None
filename = (file_results.get("notice_data_name")
or first_name
or f"egov_{post_id}.zip")
if not file_data_b64:
continue
import base64 as b64mod
file_bytes = b64mod.b64decode(file_data_b64)
save_path = egov_data / filename
save_path.write_bytes(file_bytes)
# 取得完了登録
try:
await egov_api.mark_post_done(state.access_token, post_id)
except Exception:
pass
downloaded.append({"filename": filename, "path": str(save_path),
"size": len(file_bytes), "post_id": post_id})
break # 1件ずつ
if downloaded:
state.last_download = downloaded[0]
state.status = EGovStatus.LOGGED_IN
state.message = (f"ダウンロード完了: {downloaded[0]['filename']} "
f"({downloaded[0]['size']:,} bytes) "
f"/ 全 {len(posts)}")
else:
state.status = EGovStatus.LOGGED_IN
state.message = "ダウンロード可能なファイルがありませんでした"
except Exception as e:
state.status = EGovStatus.ERROR
state.message = f"API ダウンロードエラー: {e}"
# ── OAuth2 / e-Gov API エンドポイント ──────────────────────────
@router.post("/config")
async def save_config(req: ConfigRequest):
"""e-Gov API 資格情報を保存"""
egov_api.save_api_config(req.software_id, req.api_key, req.redirect_uri)
return {"message": "設定を保存しました"}
@router.get("/auth-url")
async def get_auth_url():
"""OAuth2 + PKCE 認可 URL を生成して返す"""
cfg = egov_api.load_api_config()
if not cfg["client_id"] or not cfg["client_secret"]:
raise HTTPException(
status_code=400,
detail="e-Gov API の資格情報が未設定です。先に /egov/config を呼び出してください。"
)
verifier, challenge = egov_api.generate_pkce()
oauth_state = secrets.token_urlsafe(16)
state._pkce_verifier = verifier
state._oauth_state = oauth_state
auth_url = egov_api.build_auth_url(
cfg["client_id"],
cfg["redirect_uri"],
challenge,
oauth_state,
)
return {"auth_url": auth_url, "state": oauth_state}
@router.post("/callback")
async def oauth_callback(req: CallbackRequest):
"""
e-Gov 認可コードを受け取りアクセストークンへ交換する。
egov.html の JavaScript から呼び出される。
"""
if not state._pkce_verifier:
raise HTTPException(status_code=400, detail="PKCE セッションが見つかりません。再度 /egov/auth-url から開始してください。")
if req.state != state._oauth_state:
raise HTTPException(status_code=400, detail="state パラメータが一致しませんCSRF防止")
cfg = egov_api.load_api_config()
try:
token_resp = await egov_api.exchange_code(
req.code,
cfg["client_id"],
cfg["client_secret"],
cfg["redirect_uri"],
state._pkce_verifier,
)
except Exception as e:
raise HTTPException(status_code=502, detail=f"トークン取得失敗: {e}")
state.set_tokens(token_resp)
state._pkce_verifier = None
state._oauth_state = None
state.status = EGovStatus.LOGGED_IN
state.message = "e-Gov API 認証完了"
# ファイルに永続化
egov_api.save_tokens({
"access_token": state.access_token,
"refresh_token": state.refresh_token,
"expires_at": state.token_expires_at,
})
return {"message": "認証完了しました", "status": "logged_in"}
@router.get("/downloads/latest")
async def download_latest():
"""最後にダウンロードしたファイルを返す"""
if not state.last_download:
raise HTTPException(status_code=404, detail="ダウンロード済みファイルがありません")
from pathlib import Path
path = Path(state.last_download["path"])
if not path.exists():
raise HTTPException(status_code=404, detail="ファイルが見つかりません")
return FileResponse(
path=str(path),
filename=state.last_download["filename"],
media_type="application/octet-stream",
)
@router.get("/debug-page")
async def debug_page():
"""現在のブラウザページの詳細診断情報(デバッグ用)"""
if state._page is None:
return {"error": "ブラウザセッションなし", "status": state.status}
try:
page = state._page
diag = 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, 600).replace(/\\n+/g, ' | '),
links: Array.from(document.querySelectorAll('a')).map(a => ({
text: a.textContent.trim().slice(0, 50),
href: a.href,
imgAlts: Array.from(a.querySelectorAll('img')).map(img => img.alt).join(','),
})).slice(0, 30),
buttons: Array.from(document.querySelectorAll('button, input[type="submit"], input[type="button"], [role="button"]')).map(el => ({
tag: el.tagName,
text: (el.textContent || el.value || '').trim().slice(0, 50),
id: el.id,
cls: el.className.slice(0, 60),
})).slice(0, 20),
imgs: Array.from(document.querySelectorAll('img[alt]')).map(img => ({
alt: img.alt,
src: img.src.slice(0, 80),
})).slice(0, 20),
inputs: Array.from(document.querySelectorAll('input, select')).map(el => ({
type: el.type,
name: el.name,
id: el.id,
})).slice(0, 20),
};
}
""")
return {"browser_state": state.status, **diag}
except Exception as e:
return {"error": str(e), "status": state.status}

View File

@@ -0,0 +1,517 @@
"""
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

View File

@@ -0,0 +1,85 @@
import asyncio
import time
from enum import Enum
from typing import Optional, Any
class EGovStatus(str, Enum):
IDLE = "idle"
STARTING = "starting"
WAITING_MFA = "waiting_mfa"
LOGGED_IN = "logged_in"
DOWNLOADING = "downloading"
ERROR = "error"
class EGovState:
def __init__(self):
self.status: EGovStatus = EGovStatus.IDLE
self.message: str = ""
self.mfa_event: asyncio.Event = asyncio.Event()
self.mfa_code: Optional[str] = None
self.screenshot_b64: Optional[str] = None
self.last_download: Optional[dict] = None # {filename, path, size}
# ブラウザセッションPlaywright 方式, 後方互換用)
self._playwright: Optional[Any] = None
self._browser: Optional[Any] = None
self._context: Optional[Any] = None
self._page: Optional[Any] = None
# ── OAuth2 / e-Gov API 方式 ──────────────────────────────
self.access_token: Optional[str] = None
self.refresh_token: Optional[str] = None
self.token_expires_at: Optional[float] = None # unix timestamp
self._pkce_verifier: Optional[str] = None
self._oauth_state: Optional[str] = None
def is_token_valid(self) -> bool:
"""アクセストークンが有効かどうかを返す60秒マージン"""
if not self.access_token:
return False
if self.token_expires_at and time.time() >= self.token_expires_at - 60:
return False
return True
def set_tokens(self, token_response: dict):
"""トークンレスポンスをステートにセット"""
self.access_token = token_response.get("access_token")
self.refresh_token = token_response.get("refresh_token")
expires_in = token_response.get("expires_in", 300)
self.token_expires_at = time.time() + int(expires_in)
def reset(self):
self.status = EGovStatus.IDLE
self.message = ""
self.mfa_event = asyncio.Event()
self.mfa_code = None
self.screenshot_b64 = None
self.last_download = None
self.access_token = None
self.refresh_token = None
self.token_expires_at = None
self._pkce_verifier = None
self._oauth_state = None
async def close_browser(self):
"""ブラウザを閉じてリソースを解放"""
try:
if self._browser:
await self._browser.close()
except Exception:
pass
try:
if self._playwright:
await self._playwright.stop()
except Exception:
pass
self._playwright = None
self._browser = None
self._context = None
self._page = None
# モジュールレベルのシングルトン
state = EGovState()

View File

@@ -6,6 +6,7 @@ colorama==0.4.6
email-validator==2.3.0
fastapi==0.124.4
h11==0.16.0
httpx==0.28.1
idna==3.11
openpyxl==3.1.2
psycopg==3.3.2
@@ -20,3 +21,4 @@ typing_extensions==4.15.0
tzdata==2025.3
uvicorn==0.38.0
xlrd==2.0.1
playwright==1.44.0