遊んでいるゲームの公式APIを叩いて、自分専用のダッシュボードを作ってみたい。そう思って最初の壁になるのが OAuth 認証だ。リダイレクト、トークン、リフレッシュ。ここで詰まって投げ出す人は多い。このガイドは、EVE Online の SSO でログインし、市場・資産・戦闘・採掘・スキルまでを一画面に集約した「New Eden Intelligence Hub」を、ESI API の取得からコードまで、最後まで動く形で公開する。
目次
このガイドで作るもの
EVE の公式 SSO(OAuth2)でログインし、ESI API(EVE Swagger Interface)から自分のキャラクター情報を取得して可視化する Web アプリ。バックエンド不要のクライアントサイドSPAで動く(PKCE を使うので client secret を置かない)。ログイン人数の時系列分析だけ、軽量な Python Flask を足す。
全13機能を1つのアプリに統合する: ①SSO認証 ②ダッシュボードUI ③マーケットブラウザ ④ルート分析 ⑤UI刷新 ⑥ログイン人数分析(現在は廃止) ⑦キルメール ⑧採掘統計 ⑨採掘UI ⑩ウォレット ⑪脅威度チェッカー ⑫資産価値評価 ⑬スキルキュー。本ガイドで「動く基盤」を作り、各機能は同じ基盤の上に積む。
ESI API の取得(EVE Developers Portal)
まず EVE Developers Portal で自分のアプリを登録する。手順は4つ。
- CREATE NEW APPLICATION でアプリを作成。
- Connection Type =
Authentication & API Accessを選び、必要な スコープ(後述)を追加。 - Callback URL に、アプリを配置するURL(開発中は
http://localhost:8000/等)を完全一致で登録。 - 発行された Client ID を控える。PKCEを使うので Secret Key は不要(クライアントサイドに秘密を置かない)。
// 登録した値(PKCEなので secret は持たない)
const CLIENT_ID = 'あなたのClient ID'; // developers.eveonline.com で取得
const CALLBACK = location.origin + location.pathname; // ポータルの Callback URL と完全一致させる
const AUTH_URL = 'https://login.eveonline.com/v2/oauth/authorize/';
const TOKEN_URL = 'https://login.eveonline.com/v2/oauth/token';
// 使う機能に対応するスコープだけを列挙(例)
const SCOPES = [
'esi-skills.read_skills.v1', 'esi-skills.read_skillqueue.v1',
'esi-wallet.read_character_wallet.v1', 'esi-assets.read_assets.v1',
'esi-killmails.read_killmails.v1', 'esi-industry.read_character_mining.v1',
'esi-location.read_location.v1', 'esi-markets.read_character_orders.v1'
].join(' ');

設計の起点:PKCE で「secret を置かない」クライアントSSO
クライアントサイドSPAに client secret は置けない(誰でも見られる)。そこで PKCE(Proof Key for Code Exchange)を使う。ランダムな code_verifier から code_challenge(SHA-256) を作って認可サーバへ送り、トークン交換時に元の verifier を提示する。これで secret 無しに安全に認可できる。認証処理は画面に直書きせず独立モジュールにし、アクセストークンの自動リフレッシュまで内包するのが肝だ。
基盤①:SSO ログイン(PKCE)
// code_verifier→code_challenge(SHA-256) を作り、authorize へリダイレクト
async function login() {
const verifier = randomString(128);
const challenge = await sha256url(verifier);
const state = randomString(32);
sessionStorage.setItem('verifier', verifier);
sessionStorage.setItem('state', state);
const p = new URLSearchParams({
response_type: 'code', client_id: CLIENT_ID, redirect_uri: CALLBACK,
scope: SCOPES, code_challenge: challenge, code_challenge_method: 'S256', state
});
location.href = AUTH_URL + '?' + p.toString();
}
function randomString(len) {
const a = new Uint8Array(len); crypto.getRandomValues(a);
return Array.from(a, b => b.toString(36).padStart(2, '0')).join('').slice(0, len);
}
async function sha256url(str) {
const buf = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(str));
return btoa(String.fromCharCode(...new Uint8Array(buf)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
基盤②:トークン交換と自動リフレッシュ
認可後 ?code=... で戻ってくるので、code_verifier を添えてトークンを交換する。access_token は JWT なので、payload からキャラクターIDと名前が取り出せる。
// authorize から戻ったら token を交換し、JWT からキャラ情報を得る
async function handleCallback(code, state) {
if (state !== sessionStorage.getItem('state')) throw new Error('state mismatch');
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code', code, client_id: CLIENT_ID,
code_verifier: sessionStorage.getItem('verifier'), redirect_uri: CALLBACK
})
});
const t = await res.json(); // { access_token, refresh_token, expires_in }
const payload = JSON.parse(atob(t.access_token.split('.')[1])); // JWT payload
return {
id: parseInt(payload.sub.split(':')[2]), // 'CHARACTER:EVE:123' → 123
name: payload.name,
accessToken: t.access_token, refreshToken: t.refresh_token,
expiresAt: Date.now() + t.expires_in * 1000
};
}
async function refreshToken(char) {
const res = await fetch(TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token', refresh_token: char.refreshToken, client_id: CLIENT_ID
})
});
const d = await res.json();
Object.assign(char, {
accessToken: d.access_token, refreshToken: d.refresh_token,
expiresAt: Date.now() + d.expires_in * 1000
});
return char.accessToken;
}
// 期限の60秒前なら自動リフレッシュしてから返す
async function getValidToken(char) {
return (Date.now() >= char.expiresAt - 60000) ? refreshToken(char) : char.accessToken;
}
基盤③:ESI クライアント(キャッシュ・ページネーション)
ESI は https://esi.evetech.net/latest。datasource=tranquility(本番サーバ)を付け、認証付きは Authorization: Bearer を載せる。ESIにはエンドポイントごとにキャッシュ期限があるので、クライアント側でも軽くキャッシュして無駄打ちを減らす。
const ESI = 'https://esi.evetech.net/latest';
const _cache = new Map();
async function esi(path, { token, params, ttl = 300000 } = {}) {
const url = new URL(ESI + path);
url.searchParams.set('datasource', 'tranquility');
url.searchParams.set('language', 'ja');
if (params) for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const key = (token ? 'a:' : '') + url;
const hit = _cache.get(key);
if (hit && Date.now() - hit.t < ttl) return hit.d; // キャッシュ命中
const headers = { Accept: 'application/json', 'Accept-Language': 'ja' };
if (token) headers.Authorization = 'Bearer ' + token;
const res = await fetch(url, { headers });
if (!res.ok) throw new Error('ESI ' + res.status + ': ' + await res.text());
const data = await res.json();
_cache.set(key, { d: data, t: Date.now() });
return data;
}
// 大量データは x-pages ヘッダのページ数だけ集約する
async function esiAllPages(path, opts = {}) {
const all = []; let page = 1;
while (true) {
const url = new URL(ESI + path);
url.searchParams.set('datasource', 'tranquility');
url.searchParams.set('page', page);
const headers = { Accept: 'application/json' };
if (opts.token) headers.Authorization = 'Bearer ' + opts.token;
const res = await fetch(url, { headers });
if (res.status === 404) break;
const data = await res.json();
if (!data.length) break;
all.push(...data);
if (page >= parseInt(res.headers.get('x-pages') || '1')) break;
page++;
}
return all;
}
// 画像は別CDN(認証不要)
const portrait = (id, s = 128) => `https://images.evetech.net/characters/${id}/portrait/?size=${s}`;
const typeIcon = (id, s = 64) => `https://images.evetech.net/types/${id}/icon/?size=${s}`;
ここまでで基盤は完成。あとは「getValidToken で得たトークンを esi() に渡してエンドポイントを叩く」だけで、13機能すべてが同じ形で実装できる。

13機能とエンドポイント対応
各機能は下表のESIエンドポイント(+必要スコープ)に対応する。認証付きは基盤②③の getValidToken+esi() で取得し、整形して表示するだけだ。
| # | 機能 | データ源 / ESI | スコープ |
|---|---|---|---|
| 1 | SSO認証 | login.eveonline.com OAuth2 (PKCE) | — |
| 2 | ダッシュボードUI | SPAシェル(ハッシュルーティング) | — |
| 3 | マーケットブラウザ | /markets/{region}/orders/・/markets/prices/ |
公開 |
| 4 | ルート分析 | /route/{o}/{d}/?flag=shortest|secure|insecure |
公開 |
| 5 | プレミアムUI | CSS/演出(API無し) | — |
| 6 | ログイン人数分析 (現在は廃止) | 自前Flask /api/history+SQLite |
— |
| 7 | キルメール | /characters/{id}/killmails/recent/+zKillboard |
read_killmails |
| 8 | 採掘統計 | /characters/{id}/mining/ |
read_character_mining |
| 9 | 採掘UI | ⑧の可視化 | — |
| 10 | ウォレット | /characters/{id}/wallet/・/wallet/journal/ |
read_character_wallet |
| 11 | 脅威度チェッカー | zKillboard統計+ESI → 重み付けスコア | 公開 |
| 12 | 資産価値評価 | /characters/{id}/assets/+/markets/prices/+IndexedDB |
read_assets |
| 13 | スキルキュー | /characters/{id}/skillqueue/・/skills/ |
read_skillqueue |
認証付き取得は「トークンを渡すだけ」(⑩⑬⑧ 等)
const token = await getValidToken(activeChar);
const id = activeChar.id;
const skills = await esi(`/characters/${id}/skills/`, { token, ttl: 60000 });
const queue = await esi(`/characters/${id}/skillqueue/`, { token, ttl: 60000 }); // ⑬
const wallet = await esi(`/characters/${id}/wallet/`, { token, ttl: 0 }); // ⑩ 残高
const journal= await esiAllPages(`/characters/${id}/wallet/journal/`, { token }); // ⑩ 明細
const mining = await esiAllPages(`/characters/${id}/mining/`, { token }); // ⑧ 採掘ログ
③マーケット・④ルート(公開エンドポイント)
// ③ 指定リージョンの売買注文(全ページ)と参考価格
const orders = await esiAllPages(`/markets/${regionId}/orders/`, { params: { type_id: typeId, order_type: 'all' } });
const prices = await esi('/markets/prices/', { ttl: 3600000 });
// ④ flag=secure(ハイセク優先) / insecure(ローセク優先=危険を可視化) / shortest
const route = await esi(`/route/${origin}/${destination}/`, { params: { flag: 'secure' } });
// route は通過するソーラーシステムIDの配列
⑦キルメール(ESI+zKillboard を統合)
ESI は直近のキルメールID/ハッシュを返す。被害額などの付加情報は zKillboard API が持つので統合する。
async function getKillmails(charId, token) {
const esiKm = await esiAllPages(`/characters/${charId}/killmails/recent/`, { token }).catch(() => []);
let zkb = [];
const r = await fetch(`https://zkillboard.com/api/characterID/${charId}/`);
if (r.ok) zkb = (await r.json()).map(k => ({ killmail_id: k.killmail_id, killmail_hash: k.zkb.hash }));
// IDで重複排除し新しい順に
const uniq = new Map([...esiKm, ...zkb].map(k => [Number(k.killmail_id), k]));
return [...uniq.values()].sort((a, b) => b.killmail_id - a.killmail_id);
}
// 個別詳細は公開エンドポイント(id + hash)
const detail = await esi(`/killmails/${killId}/${killHash}/`, { ttl: 86400000 });
⑪脅威度チェッカー(多次元スコアリング)
ローカルチャットの見知らぬパイロットが「狩人」か「獲物」か。zKillboard 統計(危険比率・ソロ率など)と ESI を組み合わせ、8指標を重み付けして 0–100 のスコアにする。
// 8指標を 0-100 に正規化し、重み付けで合算
const w = { danger:0.20, kd:0.15, isk:0.15, solo:0.10, activity:0.15, space:0.10, sec:0.10, volume:0.05 };
let threat = Math.round(
s_danger*w.danger + s_kd*w.kd + s_isk*w.isk + s_solo*w.solo +
s_activity*w.activity + s_space*w.space + s_sec*w.sec + s_volume*w.volume
);
threat = Math.max(0, Math.min(100, threat));
// 長期間 非アクティブなら減衰させる(古い戦績で過大評価しない)
if (daysSinceLastActivity > 180) threat = Math.round(threat * 0.3);
else if (daysSinceLastActivity > 90) threat = Math.round(threat * 0.6);
⑫資産価値評価(参考価格 × IndexedDB キャッシュ)
資産は数千件になる。/markets/prices/ の参考価格を掛けて総額を出す。繰り返し参照する重いデータ(価格・名前)は IndexedDB にキャッシュして毎回APIを叩かない。
// 総推定価値 = Σ(数量 × 参考価格)
const prices = await esi('/markets/prices/', { ttl: 3600000 });
const priceMap = new Map(prices.map(p => [p.type_id, p.average_price || p.adjusted_price || 0]));
const assets = await esiAllPages(`/characters/${id}/assets/`, { token });
const totalIsk = assets.reduce((sum, a) => sum + (priceMap.get(a.type_id) || 0) * a.quantity, 0);
// 重い参照データは IndexedDB に保存(key-value)
class IDBCache {
constructor(){ this.name='eve_cache'; this.store='kv'; this.db=null; }
init(){
if (this.db) return Promise.resolve(this.db);
return new Promise((resolve, reject) => {
const req = indexedDB.open(this.name, 1);
req.onupgradeneeded = e => {
const db = e.target.result;
if (!db.objectStoreNames.contains(this.store)) db.createObjectStore(this.store);
};
req.onsuccess = e => resolve(this.db = e.target.result);
req.onerror = e => reject(e.target.error);
});
}
async get(key){ const db = await this.init();
return new Promise(res => { const r = db.transaction(this.store).objectStore(this.store).get(key); r.onsuccess = () => res(r.result); }); }
async set(key, val){ const db = await this.init();
return new Promise(res => { const r = db.transaction(this.store,'readwrite').objectStore(this.store).put(val, key); r.onsuccess = () => res(); }); }
}
⑥ログイン人数分析(参考 / 現在は廃止)
※この「ログイン人数の推移」取得機能は現在は廃止しています。以下は当時の構成の参考記録だ。時系列で貯めて分析する部分はフロントでは扱いにくいため、定期収集して SQLite に貯め、軽量な Flask が返していた(フロントは別オリジンなので CORS を許可)。
# server/app.py — 蓄積した時系列を返すだけの軽量API
from flask import Flask, jsonify, request
from flask_cors import CORS
import db
app = Flask(__name__)
CORS(app) # フロント(別オリジン)からの取得を許可
@app.route('/api/history')
def history():
days = request.args.get('days', default=7, type=int)
return jsonify(db.get_history(days)) # 別プロセスで /universe/system_kills 等を定期収集→SQLite
if __name__ == '__main__':
db.init_db()
app.run(port=5000, debug=True)
つまずきと対策
- Callback URL 不一致:ポータル登録値とアプリの実URLが1文字でも違うと認可が弾かれる。
location.origin + location.pathnameを完全一致で登録する。 - 403 / スコープ不足:エンドポイントに対応するスコープを認可時に要求していないと 403。使う機能のスコープだけを
SCOPESに入れる。 - トークン期限:access_token は約20分で失効。期限手前で
refresh_tokenから自動更新する(基盤②)。 - ESIキャッシュ:各エンドポイントにキャッシュ期限がある。直後の更新は反映されないことがあるので、残高など即時性が要る所だけ
ttl:0。 - zKillboard 礼儀:User-Agent を名乗り、過度な連打を避ける(公開コミュニティAPI)。
使用技術と順守メモ
- EVE Online / ESI は CCP hf. の著作物・商標。本ツールは非公式の third-party 開発物であり、CCP の承認・提携を受けていない。ESI / EVE SSO の利用は CCP の Developer License Agreement・利用規約の範囲で行い、Client ID は各自が EVE Developers Portal で登録する。
- © CCP hf. All rights reserved. “EVE”, “EVE Online”, “CCP”, and all related logos and images are trademarks or registered trademarks of CCP hf.
- zKillboard はコミュニティ運営の公開API。利用規約に従い、User-Agent の明示と節度ある呼び出しを守る。
- フロントは依存ゼロのバニラJS、分析バックエンドは Flask(BSD系)/ flask-cors。いずれも各ライセンスを順守。
開発の軌跡 — 基盤から13機能までの積み上げ(全13ステップ)
ここまでが「動く基盤」の作り方だ。では実際、その基盤の上に13の機能をどう積んだのか。認証だけの空っぽの画面から、市場・戦闘・採掘・資産までを一画面に束ねるまで。約1か月の開発を、ステップごとの実装ポイントと当時の画面で振り返る。自分のアプリをどう育てるかの地図として読んでほしい。
-
Step 01 | プロジェクト始動と SSO 認証 — 最初の壁の OAuth2 を、PKCE で client secret を置かずに突破。認証は
js/sso-auth.jsとして独立させ、トークンの自動リフレッシュまで内包した。「自分のキャラだけが表示される」手応えを、開発の初日に作りにいった。
Step 01:SSO 認証フロー -
Step 02 | ダッシュボードのレイアウト設計 — 機能が増える前に、サイドバー+コンテンツエリアという土台をハッシュルーティングで固めた。後から13機能を差し込んでも崩れない骨格を先に用意する。

Step 02:ダッシュボード基盤 -
Step 03 | マーケットブラウザ — EVE 経済の中心。数百万件に及ぶ注文を、
x-pagesヘッダを見たページネーションで取りこぼしなく集約し、スムーズに表示させた。
Step 03:マーケットブラウザ -
Step 04 | リージョン越えのルート分析 —
/route/をsecure / insecure / shortestで切り替え、最短経路だけでなく道中に潜む危険までを可視化した。
Step 04:ルート分析 -
Step 05 | プレミアム UI への刷新 — 「一目で驚く」体験を狙い、視覚的フィードバックとパーソナライズを軸に画面を作り直した。

Step 05:UI 刷新 -
Step 06 | ログイン人数分析(現在は廃止) — 同時接続数を「蓄積して分析」したくなり、
recorder.pyが1時間ごとに ESI を叩いて SQLite に貯め、Flask が返し、Chart.js で描いた。※この機能は現在は廃止している。
Step 06:時系列分析(廃止済み) -
Step 07 | キルメールのインテリジェンス化 — ESI が返す ID/ハッシュに、被害額を持つ zKillboard を統合。ID で重複を排除し、新しい順に整えた。

Step 07:キルメール統合 -
Step 08 | 採掘統計と ISK 表示の統一 — 採掘ログに統計分析を足し、アプリ全域の ISK 表示を省略なしのフルフォーマットに揃えた。数字の見え方を統一するだけで信頼感が変わる。

Step 08:採掘統計 -
Step 09 | 採掘 UI の最適化 — データ量が増えて一覧性が落ちてきたため、情報を整理し、16:9 に最適化したポップアップグラフで視認性を取り戻した。

Step 09:採掘 UI 改善 -
Step 10 | ウォレット深化と完全日本語化 — 取引明細をフィルタで瞬時に絞れるようにし、アイテム名・場所名まで完全に日本語化。「さらに表示」で重い一覧のパフォーマンスも確保した。

Step 10:ウォレット/日本語化 -
Step 11 | 多次元脅威度チェッカー — ローカルに現れた見知らぬパイロットは「狩人」か「獲物」か。zKillboard 統計と ESI の8指標を重み付けして 0–100 のスコアにし、長期の非アクティブは減衰させて過大評価を防いだ。
Step 11:脅威度チェッカー -
Step 12 | 資産価値の自動評価 — 数千件の資産に
/markets/prices/の参考価格を掛けて総 ISK を算出。価格・名前など重い参照は IndexedDB にキャッシュし、毎回 API を叩かない設計にした。 -
Step 13 | スキルキュー最適化と日本語マッピング修復 — 中枢のスキル育成状況を見やすく作り直し、水面下に潜んでいた日本語自動マッピングのデータ不整合を完全に修復した。
13機能はどれも、前半で作った「PKCE 認証 → 自動リフレッシュ → キャッシュ付き ESI クライアント」という同じ基盤の上に乗っている。順番に眺めると、個人開発でも「基盤を一度作れば、あとは同じ形で機能を積むだけ」だと分かるはずだ。
まとめ — 基盤さえ作れば13機能は同じ形で積める
肝は3つ。① PKCEで secret を置かないクライアントSSO、② 期限手前の自動リフレッシュ、③ Bearer+キャッシュ+ページネーションのESIクライアント。この基盤の上では、どの機能も「対応エンドポイントを esi() で叩いて整形・表示」に収束する。公式APIを叩く個人開発の足がかりにしてほしい。
この制作を、あなたも自分のゲームや好きな API で再現してみてほしい。土台にしたサーバ・ドメイン・AIツール・参考書はこちら。
この制作で使ったもの PR
🖥 サーバ/ドメイン
🤖 使ったAIツール(公式)
📚 技術書(Amazon)




