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 ApplyRequest(BaseModel): proc_id: str # 16文字の手続識別子 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("/config-info") async def get_config_info(): """現在の設定情報(APIキーは非表示)を返す""" import os cfg = egov_api.load_api_config() return { "software_id": cfg.get("client_id", ""), "redirect_uri": cfg.get("redirect_uri", ""), "environment": os.environ.get("EGOV_ENV", "production"), } @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} # ── 申請書作成・一覧 エンドポイント ──────────────────────────── async def _ensure_valid_token() -> str: """有効なアクセストークンを返す(期限切れ時は自動更新)""" 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 資格情報が未設定です") # メモリが空の場合はファイルから expires_at を正しく復元 if not state.access_token: saved = egov_api.load_tokens() if saved.get("access_token"): state.access_token = saved["access_token"] state.refresh_token = saved.get("refresh_token") state.token_expires_at = saved.get("expires_at") # float タイムスタンプをそのまま復元 # トークンが期限切れ → リフレッシュを試みる if not state.is_token_valid() and state.refresh_token: try: 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, }) except Exception as e: state.access_token = None raise HTTPException( status_code=401, detail=f"トークン更新失敗: {e}。再度 GビズIDログイン が必要です。" ) if not state.access_token: raise HTTPException(status_code=401, detail="認証が必要です。GビズID でログインしてください。") if not state.is_token_valid(): raise HTTPException( status_code=401, detail="アクセストークンが期限切れです。再度 GビズIDログイン してください。" ) return state.access_token @router.post("/test-apply") async def test_apply(req: ApplyRequest): """ テスト申請書作成:スケルトン(ひな形)を取得してそのまま申請送信する。 到達番号(arrive_id)を返す。 """ import re if not re.fullmatch(r"[A-Za-z0-9]{16}", req.proc_id): raise HTTPException(status_code=400, detail="proc_id は半角英数字16文字です") access_token = await _ensure_valid_token() # 1. スケルトン取得 try: skel_resp = await egov_api.get_procedure_skeleton(access_token, req.proc_id) except Exception as e: raise HTTPException(status_code=502, detail=f"スケルトン取得失敗: {e}") results = skel_resp.get("results", {}) file_data_b64 = results.get("file_data", "") if not file_data_b64: raise HTTPException(status_code=502, detail="スケルトンデータが空です") # 2. 申請送信(スケルトンをそのまま送信) # 電子申請手続 → POST /apply(トライアルモード) # 電子送達手続 → POST /post-apply(トライアル非対応のため自動フォールバック) # 電子送達手続の場合は kousei.xml に必須フィールドを埋めてから送信する file_name = f"{req.proc_id}.zip" apply_resp = None api_type = "電子申請" try: apply_resp = await egov_api.submit_apply( access_token, req.proc_id, file_name, file_data_b64, trial=True ) except RuntimeError as e: err_str = str(e) if "対象手続ではありません" in err_str or "post-apply" in err_str.lower(): # 電子送達利用申込み手続 → kousei.xml 修正 → /post-apply api_type = "電子送達利用申込み" # 公式手続一覧 egov_applapi_testproclist.xlsx より正しい手続名称 proc_name = "APIテスト用手続(電子送達関係手続)(通)0001/APIテスト用手続(電子送達関係手続)(通)0001" # スケルトン kousei.xml に必須フィールドを埋める try: modified_file_data = egov_api.modify_skeleton_for_post_apply( file_data_b64=file_data_b64, proc_id=req.proc_id, proc_name=proc_name, uketsuke_kikan_id="100900", # 電子送達関係手続の受付行政機関ID shinsei_shubetsu="新規申請", shimei="テスト タロウ", shimei_furigana="テスト タロウ", yubin="1300022", jusho="東京都墨田区江東橋4丁目31番10号", ) except Exception as emx: raise HTTPException(status_code=502, detail=f"スケルトン修正失敗: {emx}") try: apply_resp = await egov_api.submit_post_apply( access_token, req.proc_id, file_name, modified_file_data ) except Exception as e2: raise HTTPException(status_code=502, detail=f"電子送達利用申込み失敗: {e2}") else: raise HTTPException(status_code=502, detail=f"申請送信失敗: {err_str}") except Exception as e: raise HTTPException(status_code=502, detail=f"申請送信失敗: {e}") res = apply_resp.get("results", {}) return { "arrive_id": res.get("arrive_id", ""), "arrive_date": res.get("arrive_date", ""), "proc_name": res.get("proc_name", ""), "proc_id": req.proc_id, "api_type": api_type, "raw": res, } @router.get("/apply-list") async def apply_list(): """申請案件一覧取得""" access_token = await _ensure_valid_token() try: resp = await egov_api.get_apply_list(access_token) except Exception as e: raise HTTPException(status_code=502, detail=f"申請案件一覧取得失敗: {e}") results = resp.get("results", {}) apply_list_data = results.get("apply_list", []) items = [] for item in apply_list_data: items.append({ "arrive_id": item.get("arrive_id", ""), "arrive_date": item.get("arrive_date", ""), "proc_id": item.get("proc_id", ""), "proc_name": item.get("proc_name", ""), "status": item.get("status", ""), "sub_status": item.get("sub_status", ""), }) return {"apply_list": items, "total": len(items)}