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

@@ -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}