540 lines
21 KiB
Python
540 lines
21 KiB
Python
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)}
|