修正
This commit is contained in:
10
.env
10
.env
@@ -3,3 +3,13 @@ DB_PORT=55432
|
|||||||
DB_NAME=njts_acct
|
DB_NAME=njts_acct
|
||||||
DB_USER=njts_app
|
DB_USER=njts_app
|
||||||
DB_PASSWORD=njts_app2025
|
DB_PASSWORD=njts_app2025
|
||||||
|
|
||||||
|
# e-Gov 電子申請 API v2
|
||||||
|
# EGOV_ENV=sandbox → 検証環境 (https://account2.sbx.e-gov.go.jp)
|
||||||
|
# EGOV_ENV=production または未設定 → 本番環境
|
||||||
|
EGOV_ENV=sandbox
|
||||||
|
|
||||||
|
# 検証環境用APIキー
|
||||||
|
EGOV_SOFTWARE_ID=K26iIqTxFxvvXjLj
|
||||||
|
EGOV_API_KEY=3wRp2qmJQRYKdUrBxSGPB8U4O4dH0sEu
|
||||||
|
EGOV_REDIRECT_URI=http://192.168.0.61:18000/egov.html
|
||||||
|
|||||||
7
_pw_test.py
Normal file
7
_pw_test.py
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import subprocess, sys
|
||||||
|
result = subprocess.run(
|
||||||
|
["python", "-m", "playwright", "--version"],
|
||||||
|
capture_output=True, text=True
|
||||||
|
)
|
||||||
|
print("playwright version:", result.stdout.strip())
|
||||||
|
print("stderr:", result.stderr.strip()[:200] if result.stderr else "none")
|
||||||
0
backend/app/modules/egov/__init__.py
Normal file
0
backend/app/modules/egov/__init__.py
Normal file
235
backend/app/modules/egov/egov_api.py
Normal file
235
backend/app/modules/egov/egov_api.py
Normal 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()
|
||||||
372
backend/app/modules/egov/router.py
Normal file
372
backend/app/modules/egov/router.py
Normal 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}
|
||||||
517
backend/app/modules/egov/scraper.py
Normal file
517
backend/app/modules/egov/scraper.py
Normal 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
|
||||||
85
backend/app/modules/egov/state.py
Normal file
85
backend/app/modules/egov/state.py
Normal 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()
|
||||||
@@ -6,6 +6,7 @@ colorama==0.4.6
|
|||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
fastapi==0.124.4
|
fastapi==0.124.4
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
|
httpx==0.28.1
|
||||||
idna==3.11
|
idna==3.11
|
||||||
openpyxl==3.1.2
|
openpyxl==3.1.2
|
||||||
psycopg==3.3.2
|
psycopg==3.3.2
|
||||||
@@ -20,3 +21,4 @@ typing_extensions==4.15.0
|
|||||||
tzdata==2025.3
|
tzdata==2025.3
|
||||||
uvicorn==0.38.0
|
uvicorn==0.38.0
|
||||||
xlrd==2.0.1
|
xlrd==2.0.1
|
||||||
|
playwright==1.44.0
|
||||||
|
|||||||
51
check_openapi.py
Normal file
51
check_openapi.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
with open(r'docs/openapi_検証.json', encoding='utf-8') as f:
|
||||||
|
api = json.load(f)
|
||||||
|
|
||||||
|
def resolve_ref(ref):
|
||||||
|
parts = ref.lstrip('#/').split('/')
|
||||||
|
obj = api
|
||||||
|
for p in parts:
|
||||||
|
obj = obj[p]
|
||||||
|
return obj
|
||||||
|
|
||||||
|
def resolve_schema(schema, depth=0):
|
||||||
|
if depth > 3:
|
||||||
|
return schema
|
||||||
|
if '$ref' in schema:
|
||||||
|
return resolve_schema(resolve_ref(schema['$ref']), depth+1)
|
||||||
|
if schema.get('type') == 'array' and 'items' in schema:
|
||||||
|
schema = dict(schema)
|
||||||
|
schema['items'] = resolve_schema(schema['items'], depth+1)
|
||||||
|
if 'properties' in schema:
|
||||||
|
schema = dict(schema)
|
||||||
|
schema['properties'] = {k: resolve_schema(v, depth+1) for k, v in schema['properties'].items()}
|
||||||
|
return schema
|
||||||
|
|
||||||
|
# /post/lists の200レスポンス
|
||||||
|
resp = api['paths']['/post/lists']['get']['responses']['200']
|
||||||
|
schema = resolve_schema(resp['content']['application/json']['schema'])
|
||||||
|
print('=== GET /post/lists Response 200 ===')
|
||||||
|
print(json.dumps(schema, ensure_ascii=False, indent=2)[:3000])
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# /post/{post_id} の200レスポンス
|
||||||
|
resp2 = api['paths']['/post/{post_id}']['get']['responses']['200']
|
||||||
|
schema2 = resolve_schema(resp2['content']['application/json']['schema'])
|
||||||
|
print('=== GET /post/{post_id} Response 200 ===')
|
||||||
|
print(json.dumps(schema2, ensure_ascii=False, indent=2)[:4000])
|
||||||
|
|
||||||
|
print()
|
||||||
|
|
||||||
|
# serversの確認
|
||||||
|
print('=== Servers ===')
|
||||||
|
print(json.dumps(api.get('servers', []), ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
# componentsのparametersでよく使うものを確認
|
||||||
|
print()
|
||||||
|
print('=== Key Parameters ===')
|
||||||
|
for key in ['Authorization', 'date_from', 'date_to', 'limit_50', 'offset']:
|
||||||
|
param = api['components']['parameters'].get(key, {})
|
||||||
|
print(f'{key}: in={param.get("in")}, name={param.get("name")}, schema={param.get("schema")}')
|
||||||
21
docker-compose.nas.yml
Normal file
21
docker-compose.nas.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: njts-accounting-backend
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
command: uvicorn app.main:app --host 0.0.0.0 --port 8000
|
||||||
|
ports:
|
||||||
|
- "18000:8000"
|
||||||
|
env_file:
|
||||||
|
- .env
|
||||||
|
environment:
|
||||||
|
DB_HOST: 192.168.0.61
|
||||||
|
DB_PORT: 55432
|
||||||
|
DB_NAME: njts_acct
|
||||||
|
DB_USER: njts_app
|
||||||
|
DB_PASSWORD: njts_app2025
|
||||||
|
restart: unless-stopped
|
||||||
@@ -13,7 +13,7 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# Proxy all API requests to backend
|
# Proxy all API requests to backend
|
||||||
location ~ ^/(accounts|journals|ledger|trial-balance|opening-balances|general-ledger|payroll|users|month-locks|year-locks|cash|file-upload) {
|
location ~ ^/(accounts|journals|ledger|trial-balance|opening-balances|general-ledger|payroll|users|month-locks|year-locks|cash|file-upload|egov) {
|
||||||
proxy_pass http://backend:18080;
|
proxy_pass http://backend:18080;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|||||||
BIN
docs/07nen252 (1).xlsx
Normal file
BIN
docs/07nen252 (1).xlsx
Normal file
Binary file not shown.
BIN
docs/e-Gov 電子申請サービス 電子申請 API API 利用ガイド.pdf
Normal file
BIN
docs/e-Gov 電子申請サービス 電子申請 API API 利用ガイド.pdf
Normal file
Binary file not shown.
7384
docs/openapi.json
Normal file
7384
docs/openapi.json
Normal file
File diff suppressed because it is too large
Load Diff
7384
docs/openapi_検証.json
Normal file
7384
docs/openapi_検証.json
Normal file
File diff suppressed because it is too large
Load Diff
672
frontend/egov.html
Normal file
672
frontend/egov.html
Normal file
@@ -0,0 +1,672 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>e-Gov 電子送達 – NJTS</title>
|
||||||
|
<script src="js/auth.js"></script>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family:
|
||||||
|
system-ui,
|
||||||
|
-apple-system,
|
||||||
|
"Yu Gothic UI",
|
||||||
|
"メイリオ",
|
||||||
|
sans-serif;
|
||||||
|
background: #f5f6fa;
|
||||||
|
padding: 24px;
|
||||||
|
color: #212529;
|
||||||
|
}
|
||||||
|
header {
|
||||||
|
max-width: 660px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
header h1 {
|
||||||
|
font-size: 18px;
|
||||||
|
}
|
||||||
|
.back-link {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #0d6efd;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.back-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
max-width: 660px;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 10px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
padding: 28px 32px;
|
||||||
|
}
|
||||||
|
.card h2 {
|
||||||
|
font-size: 16px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.card .desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #6c757d;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ステータスバー */
|
||||||
|
.status-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.badge {
|
||||||
|
padding: 3px 10px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.idle {
|
||||||
|
background: #e9ecef;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.starting {
|
||||||
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
|
}
|
||||||
|
.waiting_mfa {
|
||||||
|
background: #cff4fc;
|
||||||
|
color: #055160;
|
||||||
|
}
|
||||||
|
.logged_in {
|
||||||
|
background: #d1e7dd;
|
||||||
|
color: #0a3622;
|
||||||
|
}
|
||||||
|
.downloading {
|
||||||
|
background: #cfe2ff;
|
||||||
|
color: #084298;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
background: #f8d7da;
|
||||||
|
color: #842029;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
display: inline-block;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-top-color: #0d6efd;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.7s linear infinite;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* フォーム */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
.form-group label {
|
||||||
|
display: block;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.form-group input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 12px;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.form-group input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: #86b7fe;
|
||||||
|
box-shadow: 0 0 0 3px rgba(13, 110, 253, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 10px 22px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #6f42c1;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #5a32a3;
|
||||||
|
}
|
||||||
|
.btn-secondary {
|
||||||
|
background: #6c757d;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: #5c636a;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* MFA */
|
||||||
|
.mfa-notice {
|
||||||
|
background: #e7f3ff;
|
||||||
|
border: 1px solid #b6d4fe;
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.mfa-input {
|
||||||
|
font-size: 30px;
|
||||||
|
letter-spacing: 12px;
|
||||||
|
text-align: center;
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
border: 2px solid #6f42c1;
|
||||||
|
border-radius: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-success {
|
||||||
|
background: #198754;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn-success:hover {
|
||||||
|
background: #146c43;
|
||||||
|
}
|
||||||
|
.btn-success:disabled {
|
||||||
|
background: #a3cfbb;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ダウンロード結果 */
|
||||||
|
.dl-result {
|
||||||
|
margin-top: 14px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #d1e7dd;
|
||||||
|
border: 1px solid #a3cfbb;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.dl-result a {
|
||||||
|
margin-left: auto;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #0a3622;
|
||||||
|
text-decoration: none;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #a3cfbb;
|
||||||
|
padding: 4px 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.dl-result a:hover {
|
||||||
|
background: #c2e0cc;
|
||||||
|
}
|
||||||
|
.screenshot-wrap summary {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #888;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.screenshot-wrap img {
|
||||||
|
max-width: 100%;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ログイン済みアクション */
|
||||||
|
.action-note {
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* API 認証 */
|
||||||
|
.btn-gbiz {
|
||||||
|
background: #0077cc;
|
||||||
|
color: #fff;
|
||||||
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 12px 22px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.btn-gbiz:hover {
|
||||||
|
background: #0055a5;
|
||||||
|
}
|
||||||
|
.method-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
.method-tab {
|
||||||
|
flex: 1;
|
||||||
|
padding: 8px;
|
||||||
|
border: 2px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
color: #6c757d;
|
||||||
|
}
|
||||||
|
.method-tab.active {
|
||||||
|
border-color: #6f42c1;
|
||||||
|
background: #f3eeff;
|
||||||
|
color: #6f42c1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>🏛 e-Gov 電子送達</h1>
|
||||||
|
<a class="back-link" href="index.html">← メニューに戻る</a>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ログインカード -->
|
||||||
|
<div class="card">
|
||||||
|
<h2>🔐 e-Gov ログイン(GビズID)</h2>
|
||||||
|
<p class="desc">GビズID プレミアムで e-Gov にログインします。</p>
|
||||||
|
|
||||||
|
<!-- ステータス -->
|
||||||
|
<div class="status-bar">
|
||||||
|
<span class="badge idle" id="badge">未ログイン</span>
|
||||||
|
<span class="spinner" id="spinner" style="display: none"></span>
|
||||||
|
<span id="msgText" style="color: #555"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 認証方式タブ -->
|
||||||
|
<div class="method-tabs" id="methodTabs">
|
||||||
|
<div class="method-tab active" id="tabApi" onclick="switchTab('api')">
|
||||||
|
🔑 API認証(推奨)
|
||||||
|
</div>
|
||||||
|
<div class="method-tab" id="tabBrowser" onclick="switchTab('browser')">
|
||||||
|
🌐 ブラウザ自動操作
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── API 認証セクション ─── -->
|
||||||
|
<div id="apiAuthSection">
|
||||||
|
<p
|
||||||
|
style="
|
||||||
|
font-size: 13px;
|
||||||
|
color: #555;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
line-height: 1.6;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
公式 e-Gov API を使用してGビズIDで認証します。<br />
|
||||||
|
ボタンを押すとGビズIDのログインページに移動します。認証後、自動でここに戻ります。
|
||||||
|
</p>
|
||||||
|
<button class="btn btn-gbiz" onclick="startApiAuth()">
|
||||||
|
GビズID でログイン(API方式)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ─── Playwright セクション ─── -->
|
||||||
|
<div id="loginSection" style="display: none">
|
||||||
|
<div class="form-group">
|
||||||
|
<label>GビズID(メールアドレス)</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
id="userId"
|
||||||
|
placeholder="info@company.co.jp"
|
||||||
|
autocomplete="username"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>パスワード</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
id="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
onkeydown="if (event.key === 'Enter') startLogin();"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="startLogin()">
|
||||||
|
ログイン開始
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ② MFA入力(WAITING_MFA 時のみ表示) -->
|
||||||
|
<div id="mfaSection" style="display: none">
|
||||||
|
<div class="mfa-notice">
|
||||||
|
📱 <strong>GビズID アプリ</strong>を開き、表示されている
|
||||||
|
<strong>6桁の認証コード</strong> を入力してください。<br />
|
||||||
|
<small
|
||||||
|
>※ コードは 30
|
||||||
|
秒ごとに更新されます。新しいコードで入力してください。</small
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>認証コード(6桁)</label>
|
||||||
|
<input
|
||||||
|
class="mfa-input"
|
||||||
|
type="text"
|
||||||
|
id="mfaCode"
|
||||||
|
maxlength="6"
|
||||||
|
inputmode="numeric"
|
||||||
|
pattern="[0-9]*"
|
||||||
|
placeholder="000000"
|
||||||
|
oninput="if (this.value.length === 6) submitMfa();"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-primary" onclick="submitMfa()">認証</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ③ ログイン済み -->
|
||||||
|
<div id="loggedSection" style="display: none">
|
||||||
|
<p class="action-note">
|
||||||
|
✅ e-Gov にログインしました。<br />
|
||||||
|
電子送達一覧から最初のファイルをダウンロードできます。
|
||||||
|
</p>
|
||||||
|
<div
|
||||||
|
style="display: flex; gap: 10px; flex-wrap: wrap; align-items: center"
|
||||||
|
>
|
||||||
|
<button class="btn btn-success" id="dlBtn" onclick="startDownload()">
|
||||||
|
📥 電子送達をダウンロード
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="doLogout()">
|
||||||
|
ログアウト
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="dlResult" style="display: none" class="dl-result">
|
||||||
|
<span id="dlMsg"></span>
|
||||||
|
<a id="dlLink" href="/egov/downloads/latest" target="_blank">保存</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- スクリーンショット(デバッグ) -->
|
||||||
|
<div class="screenshot-wrap" id="ssWrap" style="display: none">
|
||||||
|
<details>
|
||||||
|
<summary>ブラウザ画面を確認(デバッグ用)</summary>
|
||||||
|
<img id="ssImg" src="" alt="スクリーンショット" />
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let timer = null;
|
||||||
|
let currentTab = "api";
|
||||||
|
|
||||||
|
// ─── タブ切り替え ──────────────────────────────────────
|
||||||
|
function switchTab(tab) {
|
||||||
|
currentTab = tab;
|
||||||
|
document
|
||||||
|
.getElementById("tabApi")
|
||||||
|
.classList.toggle("active", tab === "api");
|
||||||
|
document
|
||||||
|
.getElementById("tabBrowser")
|
||||||
|
.classList.toggle("active", tab === "browser");
|
||||||
|
if (tab === "api") {
|
||||||
|
show("apiAuthSection");
|
||||||
|
hide("loginSection");
|
||||||
|
} else {
|
||||||
|
hide("apiAuthSection");
|
||||||
|
show("loginSection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── API 認証 ──────────────────────────────────────────
|
||||||
|
async function startApiAuth() {
|
||||||
|
const res = await fetch("/egov/auth-url");
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json();
|
||||||
|
alert(
|
||||||
|
e.detail ||
|
||||||
|
"認証URLの取得に失敗しました。\n/egov/config で資格情報を設定してください。",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { auth_url } = await res.json();
|
||||||
|
// 同じタブで遷移(リダイレクトバック受信のため)
|
||||||
|
location.href = auth_url;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── OAuth2 コールバック処理 ───────────────────────────
|
||||||
|
async function handleOAuthCallback(code, oauthState) {
|
||||||
|
setUI("starting", "e-Gov 認証コードを処理中...");
|
||||||
|
hide("apiAuthSection");
|
||||||
|
hide("loginSection");
|
||||||
|
hide("methodTabs");
|
||||||
|
try {
|
||||||
|
const res = await fetch("/egov/callback", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ code, state: oauthState }),
|
||||||
|
});
|
||||||
|
// URL からパラメータを削除
|
||||||
|
history.replaceState({}, "", "/egov.html");
|
||||||
|
if (res.ok) {
|
||||||
|
setUI("logged_in", "API認証完了");
|
||||||
|
show("loggedSection");
|
||||||
|
} else {
|
||||||
|
const e = await res.json();
|
||||||
|
setUI("error", "API認証エラー: " + (e.detail || "不明なエラー"));
|
||||||
|
show("methodTabs");
|
||||||
|
show("apiAuthSection");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
history.replaceState({}, "", "/egov.html");
|
||||||
|
setUI("error", "コールバックエラー: " + err.message);
|
||||||
|
show("methodTabs");
|
||||||
|
show("apiAuthSection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startLogin() {
|
||||||
|
const userId = document.getElementById("userId").value.trim();
|
||||||
|
const password = document.getElementById("password").value;
|
||||||
|
if (!userId || !password) {
|
||||||
|
alert("GビズID とパスワードを入力してください");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/egov/login-start", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ user_id: userId, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json();
|
||||||
|
alert(e.detail || "エラー");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submitMfa() {
|
||||||
|
const code = document.getElementById("mfaCode").value.trim();
|
||||||
|
if (code.length !== 6) {
|
||||||
|
alert("6桁のコードを入力してください");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("/egov/login-mfa", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ mfa_code: code }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json();
|
||||||
|
alert(e.detail || "エラー");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
hide("mfaSection");
|
||||||
|
setUI("starting", "認証コードを送信中...");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startDownload() {
|
||||||
|
document.getElementById("dlBtn").disabled = true;
|
||||||
|
document.getElementById("dlBtn").textContent = "⏳ ダウンロード中...";
|
||||||
|
hide("dlResult");
|
||||||
|
|
||||||
|
const res = await fetch("/egov/download-start", { method: "POST" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const e = await res.json();
|
||||||
|
alert(e.detail || "ダウンロード開始エラー");
|
||||||
|
document.getElementById("dlBtn").disabled = false;
|
||||||
|
document.getElementById("dlBtn").textContent =
|
||||||
|
"📥 電子送達をダウンロード";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doLogout() {
|
||||||
|
await fetch("/egov/logout", { method: "POST" });
|
||||||
|
stopPolling();
|
||||||
|
hide("loggedSection");
|
||||||
|
show("loginSection");
|
||||||
|
document.getElementById("mfaCode").value = "";
|
||||||
|
hide("dlResult");
|
||||||
|
setUI("idle", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const r = await fetch("/egov/status");
|
||||||
|
apply(await r.json());
|
||||||
|
} catch (_) {}
|
||||||
|
}, 1500);
|
||||||
|
}
|
||||||
|
function stopPolling() {
|
||||||
|
if (timer) {
|
||||||
|
clearInterval(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function apply(d) {
|
||||||
|
setUI(d.status, d.message);
|
||||||
|
|
||||||
|
if (d.screenshot) {
|
||||||
|
document.getElementById("ssImg").src =
|
||||||
|
"data:image/jpeg;base64," + d.screenshot;
|
||||||
|
show("ssWrap");
|
||||||
|
}
|
||||||
|
|
||||||
|
// API トークンが有効 or ログイン済み
|
||||||
|
if (d.token_valid || d.status === "logged_in") {
|
||||||
|
hide("methodTabs");
|
||||||
|
hide("apiAuthSection");
|
||||||
|
hide("loginSection");
|
||||||
|
hide("mfaSection");
|
||||||
|
show("loggedSection");
|
||||||
|
if (d.last_download) {
|
||||||
|
const dlMsg = document.getElementById("dlMsg");
|
||||||
|
dlMsg.textContent = `✅ ${d.last_download.filename} (${(d.last_download.size / 1024).toFixed(1)} KB)`;
|
||||||
|
show("dlResult");
|
||||||
|
document.getElementById("dlBtn").disabled = false;
|
||||||
|
document.getElementById("dlBtn").textContent =
|
||||||
|
"📥 電子送達をダウンロード";
|
||||||
|
stopPolling();
|
||||||
|
} else if (
|
||||||
|
d.status === "logged_in" &&
|
||||||
|
!["downloading"].includes(d.status)
|
||||||
|
) {
|
||||||
|
stopPolling();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (d.status === "waiting_mfa") {
|
||||||
|
hide("loginSection");
|
||||||
|
hide("apiAuthSection");
|
||||||
|
show("mfaSection");
|
||||||
|
setTimeout(() => document.getElementById("mfaCode").focus(), 100);
|
||||||
|
} else if (d.status === "error") {
|
||||||
|
stopPolling();
|
||||||
|
if (currentTab === "api") {
|
||||||
|
show("apiAuthSection");
|
||||||
|
hide("loginSection");
|
||||||
|
} else {
|
||||||
|
show("loginSection");
|
||||||
|
hide("apiAuthSection");
|
||||||
|
}
|
||||||
|
hide("mfaSection");
|
||||||
|
hide("loggedSection");
|
||||||
|
document.getElementById("dlBtn").disabled = false;
|
||||||
|
document.getElementById("dlBtn").textContent =
|
||||||
|
"📥 電子送達をダウンロード";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUI(status, msg) {
|
||||||
|
const labels = {
|
||||||
|
idle: "未ログイン",
|
||||||
|
starting: "ログイン中",
|
||||||
|
waiting_mfa: "認証待ち",
|
||||||
|
logged_in: "ログイン済み",
|
||||||
|
downloading: "DL中",
|
||||||
|
error: "エラー",
|
||||||
|
};
|
||||||
|
const badge = document.getElementById("badge");
|
||||||
|
badge.textContent = labels[status] || status;
|
||||||
|
badge.className = "badge " + status;
|
||||||
|
document.getElementById("msgText").textContent = msg || "";
|
||||||
|
document.getElementById("spinner").style.display = [
|
||||||
|
"starting",
|
||||||
|
"waiting_mfa",
|
||||||
|
"downloading",
|
||||||
|
].includes(status)
|
||||||
|
? "inline-block"
|
||||||
|
: "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(id) {
|
||||||
|
document.getElementById(id).style.display = "";
|
||||||
|
}
|
||||||
|
function hide(id) {
|
||||||
|
document.getElementById(id).style.display = "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初期状態取得
|
||||||
|
(async () => {
|
||||||
|
// OAuth2 コールバック検出
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const code = params.get("code");
|
||||||
|
const oauthState = params.get("state");
|
||||||
|
if (code) {
|
||||||
|
await handleOAuthCallback(code, oauthState || "");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通常ロード: ステータス確認
|
||||||
|
try {
|
||||||
|
const r = await fetch("/egov/status");
|
||||||
|
const d = await r.json();
|
||||||
|
apply(d);
|
||||||
|
if (!["idle", "logged_in", "error"].includes(d.status))
|
||||||
|
startPolling();
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -118,6 +118,19 @@
|
|||||||
<p>給与・賞与の計算や従業員管理を行います。</p>
|
<p>給与・賞与の計算や従業員管理を行います。</p>
|
||||||
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
|
<p><a class="btn" href="payroll.html">給与システムを開く</a></p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="card">
|
||||||
|
<h2>🏛 e-Gov 電子送達</h2>
|
||||||
|
<p>
|
||||||
|
年金事務所からの電子送達書類(社会保険通知など)を取得・管理します。<br />
|
||||||
|
GビズIDでe-Govにログインし、毎月の電子通知CSVを確認できます。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="egov.html" style="background: #6f42c1"
|
||||||
|
>e-Gov 連携を開く</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
23
nas_restart.py
Normal file
23
nas_restart.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""NASのDockerコンテナをパスワード認証で再起動するスクリプト"""
|
||||||
|
import paramiko
|
||||||
|
|
||||||
|
HOST = "192.168.0.61"
|
||||||
|
USER = "root"
|
||||||
|
PASS = "59911784"
|
||||||
|
CMD = "cd /volume1/docker/njts-accounting && docker compose restart backend"
|
||||||
|
|
||||||
|
client = paramiko.SSHClient()
|
||||||
|
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
|
||||||
|
client.connect(HOST, username=USER, password=PASS, timeout=30)
|
||||||
|
|
||||||
|
print(f"接続成功: {HOST}")
|
||||||
|
stdin, stdout, stderr = client.exec_command(CMD, timeout=60)
|
||||||
|
|
||||||
|
out = stdout.read().decode()
|
||||||
|
err = stderr.read().decode()
|
||||||
|
code = stdout.channel.recv_exit_status()
|
||||||
|
|
||||||
|
if out: print("stdout:", out)
|
||||||
|
if err: print("stderr:", err)
|
||||||
|
print("exit code:", code)
|
||||||
|
client.close()
|
||||||
Reference in New Issue
Block a user