bizgate-mcp-server 0.3.7 → 0.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bizgate-cache.d.ts +35 -0
- package/dist/bizgate-cache.js +166 -0
- package/dist/index.js +249 -56
- package/dist/jpx-cache.d.ts +26 -0
- package/dist/jpx-cache.js +210 -0
- package/dist/seed-cache.d.ts +28 -0
- package/dist/seed-cache.js +161 -0
- package/package.json +4 -1
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface CachedCompany {
|
|
2
|
+
corporate_number: string;
|
|
3
|
+
name: string;
|
|
4
|
+
revenue: string | null;
|
|
5
|
+
industry: string | null;
|
|
6
|
+
emp: string | null;
|
|
7
|
+
prefecture: string | null;
|
|
8
|
+
address: string | null;
|
|
9
|
+
tel: string | null;
|
|
10
|
+
hpurl: string | null;
|
|
11
|
+
pub: string | null;
|
|
12
|
+
shihon: string | null;
|
|
13
|
+
ceo: string | null;
|
|
14
|
+
}
|
|
15
|
+
/** BizGate API の結果を SQLite に永続キャッシュ(ローカル + R2共有) */
|
|
16
|
+
export declare class BizGateResultCache {
|
|
17
|
+
private db;
|
|
18
|
+
private sharedCacheUrl;
|
|
19
|
+
constructor(sharedCacheUrl?: string);
|
|
20
|
+
/** R2から共有キャッシュをダウンロードしてローカルにマージ */
|
|
21
|
+
syncSharedCache(): Promise<void>;
|
|
22
|
+
/** キャッシュから企業情報を取得 */
|
|
23
|
+
get(compno: string): CachedCompany | null;
|
|
24
|
+
/** 企業情報をキャッシュに保存 */
|
|
25
|
+
set(data: CachedCompany): void;
|
|
26
|
+
/** 複数の法人番号を一括チェック → キャッシュにある/ないを分離 */
|
|
27
|
+
partition(compnos: string[]): {
|
|
28
|
+
cached: CachedCompany[];
|
|
29
|
+
uncached: string[];
|
|
30
|
+
};
|
|
31
|
+
/** キャッシュ件数 */
|
|
32
|
+
count(): number;
|
|
33
|
+
/** 新規キャッシュをWorkerにアップロード(バックグラウンド) */
|
|
34
|
+
uploadToWorker(entries: CachedCompany[]): void;
|
|
35
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
const CACHE_DIR = join(homedir(), ".bizgate", "cache");
|
|
6
|
+
const DB_PATH = join(CACHE_DIR, "bizgate-results.db");
|
|
7
|
+
// SHARED_DB_PATH は JSONL 方式に移行したため不要
|
|
8
|
+
const SHARED_META_PATH = join(CACHE_DIR, "shared-meta.json");
|
|
9
|
+
const SHARED_TTL_DAYS = 1; // 共有キャッシュは毎日チェック
|
|
10
|
+
/** BizGate API の結果を SQLite に永続キャッシュ(ローカル + R2共有) */
|
|
11
|
+
export class BizGateResultCache {
|
|
12
|
+
db;
|
|
13
|
+
sharedCacheUrl;
|
|
14
|
+
constructor(sharedCacheUrl) {
|
|
15
|
+
this.sharedCacheUrl = sharedCacheUrl ?? "";
|
|
16
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
17
|
+
this.db = new Database(DB_PATH);
|
|
18
|
+
this.db.pragma("journal_mode = WAL");
|
|
19
|
+
this.db.exec(`
|
|
20
|
+
CREATE TABLE IF NOT EXISTS company_cache (
|
|
21
|
+
corporate_number TEXT PRIMARY KEY,
|
|
22
|
+
name TEXT,
|
|
23
|
+
revenue TEXT,
|
|
24
|
+
industry TEXT,
|
|
25
|
+
emp TEXT,
|
|
26
|
+
prefecture TEXT,
|
|
27
|
+
address TEXT,
|
|
28
|
+
tel TEXT,
|
|
29
|
+
hpurl TEXT,
|
|
30
|
+
pub TEXT,
|
|
31
|
+
shihon TEXT,
|
|
32
|
+
ceo TEXT,
|
|
33
|
+
cached_at TEXT NOT NULL
|
|
34
|
+
);
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
/** R2から共有キャッシュをダウンロードしてローカルにマージ */
|
|
38
|
+
async syncSharedCache() {
|
|
39
|
+
if (!this.sharedCacheUrl)
|
|
40
|
+
return;
|
|
41
|
+
// TTLチェック
|
|
42
|
+
if (existsSync(SHARED_META_PATH)) {
|
|
43
|
+
try {
|
|
44
|
+
const meta = JSON.parse(readFileSync(SHARED_META_PATH, "utf-8"));
|
|
45
|
+
const lastSync = new Date(meta.lastSync);
|
|
46
|
+
const daysDiff = (Date.now() - lastSync.getTime()) / (1000 * 60 * 60 * 24);
|
|
47
|
+
if (daysDiff < SHARED_TTL_DAYS) {
|
|
48
|
+
console.error(`Shared cache is fresh (${Math.round(daysDiff * 24)}h ago), skipping sync`);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
catch { /* proceed with sync */ }
|
|
53
|
+
}
|
|
54
|
+
console.error("Downloading shared BizGate cache from R2...");
|
|
55
|
+
try {
|
|
56
|
+
const res = await fetch(this.sharedCacheUrl);
|
|
57
|
+
if (!res.ok || !res.body) {
|
|
58
|
+
console.error(`Shared cache download failed: HTTP ${res.status}`);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
// Worker/R2 から JSONL をダウンロードしてローカルにマージ
|
|
62
|
+
const text = await res.text();
|
|
63
|
+
const entries = text
|
|
64
|
+
.split("\n")
|
|
65
|
+
.filter((l) => l.trim())
|
|
66
|
+
.map((l) => {
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(l);
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
.filter((e) => e !== null);
|
|
75
|
+
if (entries.length > 0) {
|
|
76
|
+
const upsert = this.db.prepare(`
|
|
77
|
+
INSERT OR IGNORE INTO company_cache
|
|
78
|
+
(corporate_number, name, revenue, industry, emp, prefecture, address, tel, hpurl, pub, shihon, ceo, cached_at)
|
|
79
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
80
|
+
`);
|
|
81
|
+
const batchUpsert = this.db.transaction((items) => {
|
|
82
|
+
for (const r of items) {
|
|
83
|
+
upsert.run(r.corporate_number, r.name, r.revenue, r.industry, r.emp, r.prefecture, r.address, r.tel, r.hpurl, r.pub, r.shihon, r.ceo, r.cached_at);
|
|
84
|
+
}
|
|
85
|
+
});
|
|
86
|
+
batchUpsert(entries);
|
|
87
|
+
console.error(`Shared cache merged: ${entries.length} companies`);
|
|
88
|
+
}
|
|
89
|
+
// メタ更新
|
|
90
|
+
writeFileSync(SHARED_META_PATH, JSON.stringify({ lastSync: new Date().toISOString() }), "utf-8");
|
|
91
|
+
}
|
|
92
|
+
catch (e) {
|
|
93
|
+
console.error(`Shared cache sync error: ${e instanceof Error ? e.message : e}`);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/** キャッシュから企業情報を取得 */
|
|
97
|
+
get(compno) {
|
|
98
|
+
const row = this.db.prepare("SELECT * FROM company_cache WHERE corporate_number = ?").get(compno);
|
|
99
|
+
if (!row)
|
|
100
|
+
return null;
|
|
101
|
+
return {
|
|
102
|
+
corporate_number: row.corporate_number,
|
|
103
|
+
name: row.name,
|
|
104
|
+
revenue: row.revenue,
|
|
105
|
+
industry: row.industry,
|
|
106
|
+
emp: row.emp,
|
|
107
|
+
prefecture: row.prefecture,
|
|
108
|
+
address: row.address,
|
|
109
|
+
tel: row.tel,
|
|
110
|
+
hpurl: row.hpurl,
|
|
111
|
+
pub: row.pub,
|
|
112
|
+
shihon: row.shihon,
|
|
113
|
+
ceo: row.ceo,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** 企業情報をキャッシュに保存 */
|
|
117
|
+
set(data) {
|
|
118
|
+
this.db.prepare(`
|
|
119
|
+
INSERT OR REPLACE INTO company_cache
|
|
120
|
+
(corporate_number, name, revenue, industry, emp, prefecture, address, tel, hpurl, pub, shihon, ceo, cached_at)
|
|
121
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
122
|
+
`).run(data.corporate_number, data.name, data.revenue, data.industry, data.emp, data.prefecture, data.address, data.tel, data.hpurl, data.pub, data.shihon, data.ceo, new Date().toISOString());
|
|
123
|
+
}
|
|
124
|
+
/** 複数の法人番号を一括チェック → キャッシュにある/ないを分離 */
|
|
125
|
+
partition(compnos) {
|
|
126
|
+
const cached = [];
|
|
127
|
+
const uncached = [];
|
|
128
|
+
for (const compno of compnos) {
|
|
129
|
+
const hit = this.get(compno);
|
|
130
|
+
if (hit) {
|
|
131
|
+
cached.push(hit);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
uncached.push(compno);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { cached, uncached };
|
|
138
|
+
}
|
|
139
|
+
/** キャッシュ件数 */
|
|
140
|
+
count() {
|
|
141
|
+
const row = this.db.prepare("SELECT COUNT(*) as cnt FROM company_cache").get();
|
|
142
|
+
return row.cnt;
|
|
143
|
+
}
|
|
144
|
+
/** 新規キャッシュをWorkerにアップロード(バックグラウンド) */
|
|
145
|
+
uploadToWorker(entries) {
|
|
146
|
+
if (!this.sharedCacheUrl || entries.length === 0)
|
|
147
|
+
return;
|
|
148
|
+
// Worker URL を推測 (download URL → upload URL)
|
|
149
|
+
const workerUrl = process.env.CACHE_WORKER_URL ?? "https://bizgate-cache-worker.a-adachi.workers.dev";
|
|
150
|
+
if (!workerUrl)
|
|
151
|
+
return;
|
|
152
|
+
const payload = entries.map((e) => ({
|
|
153
|
+
...e,
|
|
154
|
+
cached_at: new Date().toISOString(),
|
|
155
|
+
}));
|
|
156
|
+
// 非同期で送信(失敗しても無視)
|
|
157
|
+
fetch(`${workerUrl}/upload`, {
|
|
158
|
+
method: "POST",
|
|
159
|
+
headers: {
|
|
160
|
+
"Content-Type": "application/json",
|
|
161
|
+
"Authorization": `Bearer ${process.env.CACHE_WORKER_TOKEN ?? ""}`,
|
|
162
|
+
},
|
|
163
|
+
body: JSON.stringify(payload),
|
|
164
|
+
}).catch(() => { });
|
|
165
|
+
}
|
|
166
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -11,6 +11,9 @@ import { z } from "zod";
|
|
|
11
11
|
import { BizGateClient, first as f } from "./bizgate-client.js";
|
|
12
12
|
import { BizGateApiError, DEPARTMENT_CATEGORIES } from "./types.js";
|
|
13
13
|
import { UsageTracker } from "./usage-tracker.js";
|
|
14
|
+
import { SeedCache } from "./seed-cache.js";
|
|
15
|
+
import { BizGateResultCache } from "./bizgate-cache.js";
|
|
16
|
+
import { JpxCache } from "./jpx-cache.js";
|
|
14
17
|
import { homedir } from "node:os";
|
|
15
18
|
import { join, dirname } from "node:path";
|
|
16
19
|
import { readFileSync } from "node:fs";
|
|
@@ -37,7 +40,8 @@ if (authMode === "ip" && !process.env.BIZGATE_APP) {
|
|
|
37
40
|
process.exit(1);
|
|
38
41
|
}
|
|
39
42
|
const dailyLimit = Number(process.env.BIZGATE_DAILY_LIMIT ?? "200");
|
|
40
|
-
const
|
|
43
|
+
const seedCsvUrl = process.env.SEED_CSV_URL ?? "https://pub-3952dc5d8b37475196bab871e4e93bc1.r2.dev/latest/seed-list.csv.gz";
|
|
44
|
+
const sharedCacheUrl = process.env.SHARED_CACHE_URL ?? "https://bizgate-cache-worker.a-adachi.workers.dev/download";
|
|
41
45
|
const usageFile = process.env.BIZGATE_USAGE_FILE ??
|
|
42
46
|
join(homedir(), ".bizgate-mcp-usage.json");
|
|
43
47
|
const baseUrl = process.env.BIZGATE_BASE_URL ??
|
|
@@ -58,6 +62,9 @@ const config = {
|
|
|
58
62
|
};
|
|
59
63
|
const usageTracker = new UsageTracker(usageFile, dailyLimit);
|
|
60
64
|
const client = new BizGateClient(config, usageTracker);
|
|
65
|
+
const seedCache = new SeedCache(seedCsvUrl);
|
|
66
|
+
const resultCache = new BizGateResultCache(sharedCacheUrl);
|
|
67
|
+
const jpxCache = new JpxCache(process.env.JPX_DATA_URL);
|
|
61
68
|
// ---------- MCPサーバー ----------
|
|
62
69
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
63
70
|
const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
|
|
@@ -421,17 +428,8 @@ server.tool("bizgate__usage_status", "本日のBizGate API残り利用回数を
|
|
|
421
428
|
],
|
|
422
429
|
};
|
|
423
430
|
});
|
|
424
|
-
// ---------- Tool 7:
|
|
431
|
+
// ---------- Tool 7: プロスペクトリスト(ローカルSQLite) ----------
|
|
425
432
|
const MAX_API_CALLS_HARD_LIMIT = 150;
|
|
426
|
-
async function fetchSeeds(params) {
|
|
427
|
-
const url = new URL("/search", seedApiUrl);
|
|
428
|
-
for (const [k, v] of Object.entries(params))
|
|
429
|
-
url.searchParams.set(k, v);
|
|
430
|
-
const res = await fetch(url);
|
|
431
|
-
if (!res.ok)
|
|
432
|
-
throw new Error(`seed-api エラー: HTTP ${res.status}`);
|
|
433
|
-
return (await res.json());
|
|
434
|
-
}
|
|
435
433
|
/** 売上カテゴリ文字列から数値を抽出して最低ラインと比較 */
|
|
436
434
|
function revenueMatchesMin(revenue, minLabel) {
|
|
437
435
|
if (!revenue)
|
|
@@ -462,7 +460,7 @@ function axisToKeywords(axis) {
|
|
|
462
460
|
const keywords = axes.flatMap((a) => (keywordMap[a] ?? a).split(","));
|
|
463
461
|
return [...new Set(keywords)].join(",");
|
|
464
462
|
}
|
|
465
|
-
server.tool("bizgate__prospect_list", "シードリスト(国税庁572
|
|
463
|
+
server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法人、ローカルSQLiteキャッシュ)から条件に合う企業を抽出し、BizGate APIで部署・キーマンまで照会してプロスペクトリストを作成する。1回の実行で約40 APIコールを消費(1日約5回実行可能)。初回はSEED_CSV_URLからデータをダウンロード。", {
|
|
466
464
|
pref: z.string().optional().describe("都道府県(例: 東京都)"),
|
|
467
465
|
city: z.string().optional().describe("市区町村名(例: 千代田区)"),
|
|
468
466
|
industry: z.string().optional().describe("BizGate業種分類キーワード(例: 製造業, サービス, IT)"),
|
|
@@ -471,9 +469,9 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
|
|
|
471
469
|
proposal_axis: z.union([z.string(), z.array(z.string())]).optional().describe("提案軸(DOMO / 営業DX/CRM)。部署検索のキーワードに変換される"),
|
|
472
470
|
count: z.number().optional().describe("取得したい企業数(デフォルト: 10、最大: 30)"),
|
|
473
471
|
}, async ({ pref, city, industry, revenue_min, employee_500plus, proposal_axis, count: rawCount }) => {
|
|
474
|
-
if (!
|
|
472
|
+
if (!seedCache.isReady()) {
|
|
475
473
|
return {
|
|
476
|
-
content: [{ type: "text", text: "エラー:
|
|
474
|
+
content: [{ type: "text", text: "エラー: シードリストが利用できません。SEED_CSV_URL を設定し、Claude Code を再起動してください。" }],
|
|
477
475
|
};
|
|
478
476
|
}
|
|
479
477
|
const count = Math.min(rawCount ?? 10, 30);
|
|
@@ -483,45 +481,93 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
|
|
|
483
481
|
content: [{ type: "text", text: `エラー: 残りAPI回数が${remaining}回です。明日以降にお試しください。${usageFooter()}` }],
|
|
484
482
|
};
|
|
485
483
|
}
|
|
486
|
-
// ---- Step 1:
|
|
487
|
-
const
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
seedParams.pref = pref;
|
|
494
|
-
if (city)
|
|
495
|
-
seedParams.city = city;
|
|
496
|
-
let seeds;
|
|
497
|
-
let seedTotal;
|
|
498
|
-
try {
|
|
499
|
-
const result = await fetchSeeds(seedParams);
|
|
500
|
-
seeds = result.results;
|
|
501
|
-
seedTotal = result.total;
|
|
502
|
-
}
|
|
503
|
-
catch (e) {
|
|
504
|
-
return {
|
|
505
|
-
content: [{ type: "text", text: `seed-api に接続できません(${seedApiUrl}): ${e instanceof Error ? e.message : "不明なエラー"}` }],
|
|
506
|
-
};
|
|
507
|
-
}
|
|
484
|
+
// ---- Step 1: ローカルSQLiteから候補取得 (API 0回) ----
|
|
485
|
+
const { total: seedTotal, results: seeds } = seedCache.search({
|
|
486
|
+
pref,
|
|
487
|
+
city,
|
|
488
|
+
limit: 200,
|
|
489
|
+
shuffle: true,
|
|
490
|
+
});
|
|
508
491
|
if (seeds.length === 0) {
|
|
509
492
|
return {
|
|
510
493
|
content: [{ type: "text", text: `条件に合う企業がシードリストに見つかりませんでした。${usageFooter()}` }],
|
|
511
494
|
};
|
|
512
495
|
}
|
|
513
|
-
// ---- Step 2: BizGate企業検索 → 売上/業種/従業員フィルタ ----
|
|
496
|
+
// ---- Step 2: キャッシュ確認 → BizGate企業検索 → 売上/業種/従業員フィルタ ----
|
|
514
497
|
const verified = [];
|
|
498
|
+
const newCacheEntries = [];
|
|
515
499
|
let apiCalls = 0;
|
|
500
|
+
let cacheHits = 0;
|
|
516
501
|
const batchSize = 5;
|
|
517
|
-
const targetVerified = Math.ceil(count * 1.5);
|
|
502
|
+
const targetVerified = Math.ceil(count * 1.5);
|
|
503
|
+
// フィルタ関数(キャッシュ・API結果の両方で使う)
|
|
504
|
+
function matchesFilters(revenue, industryVal, empVal) {
|
|
505
|
+
if (revenue_min && !revenueMatchesMin(revenue ?? undefined, revenue_min))
|
|
506
|
+
return false;
|
|
507
|
+
if (industry) {
|
|
508
|
+
const ind = industryVal ?? "";
|
|
509
|
+
if (!industry.split(",").some((k) => ind.includes(k.trim())))
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
if (employee_500plus && empVal) {
|
|
513
|
+
const empCat = Number(empVal.match(/^(\d+)\./)?.[1] ?? 0);
|
|
514
|
+
if (empCat < 6)
|
|
515
|
+
return false;
|
|
516
|
+
}
|
|
517
|
+
return true;
|
|
518
|
+
}
|
|
518
519
|
for (let i = 0; i < seeds.length && verified.length < targetVerified && apiCalls < MAX_API_CALLS_HARD_LIMIT; i += batchSize) {
|
|
519
520
|
const batch = seeds.slice(i, i + batchSize);
|
|
520
521
|
const results = await Promise.all(batch.map(async (seed) => {
|
|
522
|
+
// キャッシュ確認
|
|
523
|
+
const cached = resultCache.get(seed.corporate_number);
|
|
524
|
+
if (cached) {
|
|
525
|
+
cacheHits++;
|
|
526
|
+
if (!matchesFilters(cached.revenue, cached.industry, cached.emp)) {
|
|
527
|
+
return { seed, doc: null };
|
|
528
|
+
}
|
|
529
|
+
// キャッシュヒット → BizGateDocとして組み立て(API消費なし)
|
|
530
|
+
const fakeDoc = {
|
|
531
|
+
compno: cached.corporate_number,
|
|
532
|
+
shogo: [cached.name ?? ""],
|
|
533
|
+
revenue: cached.revenue ?? undefined,
|
|
534
|
+
gyoshu_facet: cached.industry ? [cached.industry] : undefined,
|
|
535
|
+
emp: cached.emp ?? undefined,
|
|
536
|
+
add: cached.address ? [cached.address] : undefined,
|
|
537
|
+
pref: cached.prefecture ? [cached.prefecture] : undefined,
|
|
538
|
+
tel: cached.tel ? [cached.tel] : undefined,
|
|
539
|
+
hpurl: cached.hpurl ? [cached.hpurl] : undefined,
|
|
540
|
+
pub: cached.pub ?? undefined,
|
|
541
|
+
shihon: cached.shihon ?? undefined,
|
|
542
|
+
ceo: cached.ceo ? [cached.ceo] : undefined,
|
|
543
|
+
};
|
|
544
|
+
return { seed, doc: fakeDoc };
|
|
545
|
+
}
|
|
546
|
+
// キャッシュなし → API呼び出し
|
|
521
547
|
try {
|
|
522
548
|
const { docs } = await client.searchCompany({ compno: seed.corporate_number });
|
|
523
549
|
apiCalls++;
|
|
524
|
-
|
|
550
|
+
const doc = docs[0] ?? null;
|
|
551
|
+
// 結果をキャッシュに保存(マッチしなくてもキャッシュ → 次回API不要)
|
|
552
|
+
if (doc) {
|
|
553
|
+
const cached = {
|
|
554
|
+
corporate_number: seed.corporate_number,
|
|
555
|
+
name: f(doc.shogo),
|
|
556
|
+
revenue: doc.revenue ?? null,
|
|
557
|
+
industry: f(doc.gyoshu_facet) || null,
|
|
558
|
+
emp: doc.emp ?? null,
|
|
559
|
+
prefecture: f(doc.pref) || null,
|
|
560
|
+
address: f(doc.add) || null,
|
|
561
|
+
tel: f(doc.tel) || null,
|
|
562
|
+
hpurl: f(doc.hpurl) || null,
|
|
563
|
+
pub: doc.pub ?? null,
|
|
564
|
+
shihon: doc.shihon ?? null,
|
|
565
|
+
ceo: f(doc.ceo) || null,
|
|
566
|
+
};
|
|
567
|
+
resultCache.set(cached);
|
|
568
|
+
newCacheEntries.push(cached);
|
|
569
|
+
}
|
|
570
|
+
return { seed, doc };
|
|
525
571
|
}
|
|
526
572
|
catch {
|
|
527
573
|
apiCalls++;
|
|
@@ -531,22 +577,8 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
|
|
|
531
577
|
for (const { seed, doc } of results) {
|
|
532
578
|
if (!doc)
|
|
533
579
|
continue;
|
|
534
|
-
if (
|
|
580
|
+
if (!matchesFilters(doc.revenue, f(doc.gyoshu_facet), doc.emp))
|
|
535
581
|
continue;
|
|
536
|
-
if (industry) {
|
|
537
|
-
const docIndustry = f(doc.gyoshu_facet);
|
|
538
|
-
if (!industry.split(",").some((k) => docIndustry.includes(k.trim())))
|
|
539
|
-
continue;
|
|
540
|
-
}
|
|
541
|
-
if (employee_500plus && doc.emp) {
|
|
542
|
-
const empMatch = doc.emp.match(/(\d+)/);
|
|
543
|
-
if (empMatch) {
|
|
544
|
-
// emp例: "6. 500-1000人未満" → カテゴリ6以上
|
|
545
|
-
const empCat = Number(doc.emp.match(/^(\d+)\./)?.[1] ?? 0);
|
|
546
|
-
if (empCat < 6)
|
|
547
|
-
continue; // カテゴリ6 = 500-1000人
|
|
548
|
-
}
|
|
549
|
-
}
|
|
550
582
|
verified.push({ doc, seed });
|
|
551
583
|
}
|
|
552
584
|
}
|
|
@@ -554,7 +586,7 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
|
|
|
554
586
|
return {
|
|
555
587
|
content: [{
|
|
556
588
|
type: "text",
|
|
557
|
-
text: `条件に合う企業が見つかりませんでした。\n(シード候補: ${seeds.length}社 / API照会: ${apiCalls}
|
|
589
|
+
text: `条件に合う企業が見つかりませんでした。\n(シード候補: ${seeds.length}社 / API照会: ${apiCalls}回 / キャッシュヒット: ${cacheHits}件)${usageFooter()}`,
|
|
558
590
|
}],
|
|
559
591
|
};
|
|
560
592
|
}
|
|
@@ -639,13 +671,174 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
|
|
|
639
671
|
return `| ${i + 1} | ${p.name} | ${p.department} | ${keymanStr} | ${p.phone} | ${p.approach_note} |`;
|
|
640
672
|
})
|
|
641
673
|
.join("\n");
|
|
642
|
-
|
|
674
|
+
// 新規キャッシュをWorkerに非同期アップロード
|
|
675
|
+
if (newCacheEntries.length > 0) {
|
|
676
|
+
resultCache.uploadToWorker(newCacheEntries);
|
|
677
|
+
}
|
|
678
|
+
const stats = `\n\n---\nシード: ${seedTotal}社中${seeds.length}社取得 / API照会: ${apiCalls}回 / キャッシュヒット: ${cacheHits}件 / 新規キャッシュ: ${newCacheEntries.length}件 / マッチ: ${prospects.length}社 / キャッシュ総数: ${resultCache.count()}件`;
|
|
643
679
|
return {
|
|
644
680
|
content: [{ type: "text", text: header + table + stats + usageFooter() }],
|
|
645
681
|
};
|
|
646
682
|
});
|
|
683
|
+
// ---------- Tool 8: 上場企業×部署横断検索 ----------
|
|
684
|
+
const ENTERPRISE_MAX_API = 120;
|
|
685
|
+
server.tool("bizgate__enterprise_search", "上場企業(JPX約3,800社)から条件に合う企業を絞り込み、部署名キーワードで横断検索する。企業名を知らなくても「データ部門がある売上1000億以上のプライム企業」のような条件検索が可能。市場区分・業種・売上でフィルタし、BizGate APIで部署を照会する。API消費: 企業検証N回 + 部署検索M回。", {
|
|
686
|
+
dept_keyword: z.string().describe("部署名キーワード(例: データ, DX, 営業推進)。カンマ区切りで複数可"),
|
|
687
|
+
market: z.string().optional().describe("市場区分(プライム / スタンダード / グロース)"),
|
|
688
|
+
industry: z.string().optional().describe("33業種分類キーワード(例: 情報・通信業, 電気機器, サービス業)"),
|
|
689
|
+
revenue_min: z.string().optional().describe("最低売上(50億以上 / 300億以上 / 1000億以上)"),
|
|
690
|
+
name_keyword: z.string().optional().describe("企業名キーワード(例: ソニー)"),
|
|
691
|
+
count: z.number().optional().describe("取得したい企業数(デフォルト: 10、最大: 30)"),
|
|
692
|
+
}, async ({ dept_keyword, market, industry, revenue_min, name_keyword, count: rawCount }) => {
|
|
693
|
+
if (!jpxCache.isReady()) {
|
|
694
|
+
return {
|
|
695
|
+
content: [{
|
|
696
|
+
type: "text",
|
|
697
|
+
text: "エラー: 上場企業データベースが利用できません。サーバーを再起動してください。",
|
|
698
|
+
}],
|
|
699
|
+
};
|
|
700
|
+
}
|
|
701
|
+
const count = Math.min(rawCount ?? 10, 30);
|
|
702
|
+
const remaining = usageTracker.remaining();
|
|
703
|
+
if (remaining < 10) {
|
|
704
|
+
return {
|
|
705
|
+
content: [{
|
|
706
|
+
type: "text",
|
|
707
|
+
text: `エラー: 残りAPI回数が${remaining}回です。明日以降にお試しください。${usageFooter()}`,
|
|
708
|
+
}],
|
|
709
|
+
};
|
|
710
|
+
}
|
|
711
|
+
// ---- Step 1: ローカルDBから上場企業を検索 (API 0回) ----
|
|
712
|
+
const { total: jpxTotal, results: candidates } = jpxCache.search({
|
|
713
|
+
market,
|
|
714
|
+
industry,
|
|
715
|
+
nameKeyword: name_keyword,
|
|
716
|
+
limit: 200,
|
|
717
|
+
});
|
|
718
|
+
if (candidates.length === 0) {
|
|
719
|
+
return {
|
|
720
|
+
content: [{
|
|
721
|
+
type: "text",
|
|
722
|
+
text: `条件に合う上場企業が見つかりませんでした。\n(検索条件: 市場=${market ?? "全て"}, 業種=${industry ?? "全て"})${usageFooter()}`,
|
|
723
|
+
}],
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
let verified = [];
|
|
727
|
+
let apiCalls = 0;
|
|
728
|
+
const batchSize = 5;
|
|
729
|
+
if (revenue_min) {
|
|
730
|
+
// 売上検証が必要 → BizGate APIで確認
|
|
731
|
+
const targetVerified = Math.ceil(count * 2);
|
|
732
|
+
for (let i = 0; i < candidates.length &&
|
|
733
|
+
verified.length < targetVerified &&
|
|
734
|
+
apiCalls < ENTERPRISE_MAX_API; i += batchSize) {
|
|
735
|
+
const batch = candidates.slice(i, i + batchSize);
|
|
736
|
+
const results = await Promise.all(batch.map(async (listed) => {
|
|
737
|
+
try {
|
|
738
|
+
const searchParams = listed.corporate_number
|
|
739
|
+
? { compno: listed.corporate_number }
|
|
740
|
+
: { shogo: listed.name };
|
|
741
|
+
const { docs } = await client.searchCompany(searchParams);
|
|
742
|
+
apiCalls++;
|
|
743
|
+
return { listed, doc: docs[0] ?? null };
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
apiCalls++;
|
|
747
|
+
return { listed, doc: null };
|
|
748
|
+
}
|
|
749
|
+
}));
|
|
750
|
+
for (const { listed, doc } of results) {
|
|
751
|
+
if (!doc)
|
|
752
|
+
continue;
|
|
753
|
+
if (!revenueMatchesMin(doc.revenue, revenue_min))
|
|
754
|
+
continue;
|
|
755
|
+
verified.push({ listed, doc });
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
else {
|
|
760
|
+
// 売上検証不要 → 全候補をそのまま使用(APIはStep3で使う)
|
|
761
|
+
verified = candidates.map((listed) => ({
|
|
762
|
+
listed,
|
|
763
|
+
doc: {}, // ダミー、部署検索でcompnoを使うため
|
|
764
|
+
}));
|
|
765
|
+
}
|
|
766
|
+
if (verified.length === 0) {
|
|
767
|
+
return {
|
|
768
|
+
content: [{
|
|
769
|
+
type: "text",
|
|
770
|
+
text: `売上条件(${revenue_min})に合う企業が見つかりませんでした。\n(候補: ${candidates.length}社 / API照会: ${apiCalls}回)${usageFooter()}`,
|
|
771
|
+
}],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const results = [];
|
|
775
|
+
for (const { listed, doc } of verified.slice(0, count * 2)) {
|
|
776
|
+
if (results.length >= count || apiCalls >= ENTERPRISE_MAX_API)
|
|
777
|
+
break;
|
|
778
|
+
try {
|
|
779
|
+
const deptSearchParams = listed.corporate_number
|
|
780
|
+
? { compno: listed.corporate_number, bKwd: dept_keyword, bKOpr: "0" }
|
|
781
|
+
: { shogo: listed.name, bKwd: dept_keyword, bKOpr: "0" };
|
|
782
|
+
const { docs: deptDocs } = await client.searchDepartments(deptSearchParams);
|
|
783
|
+
apiCalls++;
|
|
784
|
+
if (deptDocs.length > 0) {
|
|
785
|
+
const deptNames = deptDocs
|
|
786
|
+
.slice(0, 5)
|
|
787
|
+
.map((d) => f(d.bumon) || "(不明)")
|
|
788
|
+
.filter((n) => n !== "(不明)");
|
|
789
|
+
const phone = deptDocs.find((d) => f(d.tel))
|
|
790
|
+
? f(deptDocs.find((d) => f(d.tel)).tel)
|
|
791
|
+
: "-";
|
|
792
|
+
results.push({
|
|
793
|
+
name: listed.name,
|
|
794
|
+
market: listed.market,
|
|
795
|
+
industry: listed.industry_33,
|
|
796
|
+
revenue: doc.revenue ?? "-",
|
|
797
|
+
departments: deptNames.length > 0 ? deptNames : [f(deptDocs[0].bumon) || "-"],
|
|
798
|
+
phone,
|
|
799
|
+
corporate_number: listed.corporate_number ?? "-",
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
apiCalls++;
|
|
805
|
+
// 部署データなし(504)等 → スキップ
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
if (results.length === 0) {
|
|
809
|
+
return {
|
|
810
|
+
content: [{
|
|
811
|
+
type: "text",
|
|
812
|
+
text: `「${dept_keyword}」を含む部署が見つかりませんでした。\n(候補: ${verified.length}社 / API照会: ${apiCalls}回)${usageFooter()}`,
|
|
813
|
+
}],
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
// ---- Step 4: 結果フォーマット ----
|
|
817
|
+
const header = `## 上場企業 × 部署横断検索(${results.length}社)\n\n`;
|
|
818
|
+
const filterInfo = `検索条件: 部署キーワード=「${dept_keyword}」` +
|
|
819
|
+
(market ? ` / 市場=${market}` : "") +
|
|
820
|
+
(industry ? ` / 業種=${industry}` : "") +
|
|
821
|
+
(revenue_min ? ` / 売上=${revenue_min}` : "") +
|
|
822
|
+
"\n\n";
|
|
823
|
+
const table = "| # | 会社名 | 市場 | 業種 | 売上 | マッチ部署 | 電話 |\n" +
|
|
824
|
+
"|---|--------|------|------|------|-----------|------|\n" +
|
|
825
|
+
results
|
|
826
|
+
.map((r, i) => {
|
|
827
|
+
const deptStr = r.departments.slice(0, 3).join(", ");
|
|
828
|
+
return `| ${i + 1} | ${r.name} | ${r.market} | ${r.industry} | ${r.revenue} | ${deptStr} | ${r.phone} |`;
|
|
829
|
+
})
|
|
830
|
+
.join("\n");
|
|
831
|
+
const stats = `\n\n---\n上場企業候補: ${jpxTotal}社 / API照会: ${apiCalls}回 / マッチ: ${results.length}社`;
|
|
832
|
+
return {
|
|
833
|
+
content: [{ type: "text", text: header + filterInfo + table + stats + usageFooter() }],
|
|
834
|
+
};
|
|
835
|
+
});
|
|
647
836
|
// ---------- 起動 ----------
|
|
648
837
|
async function main() {
|
|
838
|
+
// シードキャッシュ + 共有BizGateキャッシュを非同期で初期化
|
|
839
|
+
seedCache.init().catch((e) => console.error("Seed cache init error:", e));
|
|
840
|
+
resultCache.syncSharedCache().catch((e) => console.error("Shared cache sync error:", e));
|
|
841
|
+
jpxCache.init().catch((e) => console.error("JPX cache init error:", e));
|
|
649
842
|
const transport = new StdioServerTransport();
|
|
650
843
|
await server.connect(transport);
|
|
651
844
|
console.error("BizGate MCP Server running on stdio");
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface ListedCompany {
|
|
2
|
+
securities_code: string;
|
|
3
|
+
name: string;
|
|
4
|
+
market: string;
|
|
5
|
+
industry_33: string;
|
|
6
|
+
corporate_number: string | null;
|
|
7
|
+
}
|
|
8
|
+
export declare class JpxCache {
|
|
9
|
+
private db;
|
|
10
|
+
private ready;
|
|
11
|
+
private jpxUrl;
|
|
12
|
+
constructor(jpxUrl?: string);
|
|
13
|
+
init(): Promise<void>;
|
|
14
|
+
isReady(): boolean;
|
|
15
|
+
search(params: {
|
|
16
|
+
market?: string;
|
|
17
|
+
industry?: string;
|
|
18
|
+
nameKeyword?: string;
|
|
19
|
+
limit?: number;
|
|
20
|
+
}): {
|
|
21
|
+
total: number;
|
|
22
|
+
results: ListedCompany[];
|
|
23
|
+
};
|
|
24
|
+
private checkNeedsUpdate;
|
|
25
|
+
private downloadAndImport;
|
|
26
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import * as XLSX from "xlsx";
|
|
6
|
+
const CACHE_DIR = join(homedir(), ".bizgate", "cache");
|
|
7
|
+
const DB_PATH = join(CACHE_DIR, "seed.db");
|
|
8
|
+
const JPX_META_PATH = join(CACHE_DIR, "jpx-meta.json");
|
|
9
|
+
const TTL_DAYS = 30;
|
|
10
|
+
const DEFAULT_JPX_URL = "https://www.jpx.co.jp/markets/statistics-equities/misc/tvdivq0000001vg2-att/data_j.xls";
|
|
11
|
+
/** 企業名を正規化(株式会社等を除去) */
|
|
12
|
+
function normalizeName(name) {
|
|
13
|
+
return name
|
|
14
|
+
.replace(/[\s\u3000]+/g, "") // 全角・半角スペース除去
|
|
15
|
+
.replace(/^株式会社|株式会社$/g, "")
|
|
16
|
+
.replace(/^\(株\)|\(株\)$/g, "")
|
|
17
|
+
.replace(/^(株)|(株)$/g, "")
|
|
18
|
+
.replace(/^有限会社|有限会社$/g, "")
|
|
19
|
+
.replace(/^合同会社|合同会社$/g, "")
|
|
20
|
+
.trim();
|
|
21
|
+
}
|
|
22
|
+
export class JpxCache {
|
|
23
|
+
db = null;
|
|
24
|
+
ready = false;
|
|
25
|
+
jpxUrl;
|
|
26
|
+
constructor(jpxUrl) {
|
|
27
|
+
this.jpxUrl = jpxUrl || DEFAULT_JPX_URL;
|
|
28
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
29
|
+
}
|
|
30
|
+
async init() {
|
|
31
|
+
const needsUpdate = this.checkNeedsUpdate();
|
|
32
|
+
if (needsUpdate) {
|
|
33
|
+
try {
|
|
34
|
+
await this.downloadAndImport();
|
|
35
|
+
}
|
|
36
|
+
catch (e) {
|
|
37
|
+
console.error(`JPX data update failed: ${e instanceof Error ? e.message : e}`);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (existsSync(DB_PATH)) {
|
|
41
|
+
this.db = new Database(DB_PATH, { readonly: true });
|
|
42
|
+
// テーブル存在チェック
|
|
43
|
+
const tableExists = this.db
|
|
44
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='listed_companies'")
|
|
45
|
+
.get();
|
|
46
|
+
if (tableExists) {
|
|
47
|
+
this.ready = true;
|
|
48
|
+
const count = this.db
|
|
49
|
+
.prepare("SELECT COUNT(*) as cnt FROM listed_companies")
|
|
50
|
+
.get();
|
|
51
|
+
const matched = this.db
|
|
52
|
+
.prepare("SELECT COUNT(*) as cnt FROM listed_companies WHERE corporate_number IS NOT NULL")
|
|
53
|
+
.get();
|
|
54
|
+
console.error(`JPX DB loaded: ${count.cnt} listed companies (${matched.cnt} matched with seed)`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
isReady() {
|
|
59
|
+
return this.ready && this.db !== null;
|
|
60
|
+
}
|
|
61
|
+
search(params) {
|
|
62
|
+
if (!this.db)
|
|
63
|
+
return { total: 0, results: [] };
|
|
64
|
+
const conditions = ["1=1"];
|
|
65
|
+
const binds = {};
|
|
66
|
+
if (params.market) {
|
|
67
|
+
conditions.push("market = @market");
|
|
68
|
+
binds.market = params.market;
|
|
69
|
+
}
|
|
70
|
+
if (params.industry) {
|
|
71
|
+
conditions.push("industry_33 LIKE @industry");
|
|
72
|
+
binds.industry = `%${params.industry}%`;
|
|
73
|
+
}
|
|
74
|
+
if (params.nameKeyword) {
|
|
75
|
+
conditions.push("(name LIKE @nameKw OR name_normalized LIKE @nameKw)");
|
|
76
|
+
binds.nameKw = `%${params.nameKeyword}%`;
|
|
77
|
+
}
|
|
78
|
+
const where = conditions.join(" AND ");
|
|
79
|
+
const countRow = this.db
|
|
80
|
+
.prepare(`SELECT COUNT(*) as cnt FROM listed_companies WHERE ${where}`)
|
|
81
|
+
.get(binds);
|
|
82
|
+
const limit = Math.min(params.limit ?? 100, 500);
|
|
83
|
+
const rows = this.db
|
|
84
|
+
.prepare(`SELECT securities_code, name, market, industry_33, corporate_number
|
|
85
|
+
FROM listed_companies WHERE ${where} ORDER BY name LIMIT @limit`)
|
|
86
|
+
.all({ ...binds, limit });
|
|
87
|
+
return { total: countRow.cnt, results: rows };
|
|
88
|
+
}
|
|
89
|
+
checkNeedsUpdate() {
|
|
90
|
+
if (!existsSync(DB_PATH))
|
|
91
|
+
return true;
|
|
92
|
+
if (!existsSync(JPX_META_PATH))
|
|
93
|
+
return true;
|
|
94
|
+
try {
|
|
95
|
+
const meta = JSON.parse(readFileSync(JPX_META_PATH, "utf-8"));
|
|
96
|
+
const lastUpdated = new Date(meta.lastUpdated);
|
|
97
|
+
const daysDiff = (Date.now() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24);
|
|
98
|
+
return daysDiff >= TTL_DAYS;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
async downloadAndImport() {
|
|
105
|
+
console.error("Downloading JPX listed companies data...");
|
|
106
|
+
const res = await fetch(this.jpxUrl);
|
|
107
|
+
if (!res.ok)
|
|
108
|
+
throw new Error(`JPX download failed: HTTP ${res.status}`);
|
|
109
|
+
const buffer = await res.arrayBuffer();
|
|
110
|
+
const workbook = XLSX.read(buffer, { type: "array" });
|
|
111
|
+
const sheet = workbook.Sheets[workbook.SheetNames[0]];
|
|
112
|
+
const rows = XLSX.utils.sheet_to_json(sheet, {
|
|
113
|
+
defval: "",
|
|
114
|
+
});
|
|
115
|
+
if (rows.length === 0)
|
|
116
|
+
throw new Error("JPX Excel file is empty");
|
|
117
|
+
console.error(`Parsed ${rows.length} rows from JPX Excel`);
|
|
118
|
+
// カラム名検出(日本語カラム名)
|
|
119
|
+
const sampleRow = rows[0];
|
|
120
|
+
const colKeys = Object.keys(sampleRow);
|
|
121
|
+
// カラムマッピング(部分一致で検索)
|
|
122
|
+
const codeCol = colKeys.find((k) => k.includes("コード")) ?? colKeys[0];
|
|
123
|
+
const nameCol = colKeys.find((k) => k.includes("銘柄名")) ?? colKeys[1];
|
|
124
|
+
const marketCol = colKeys.find((k) => k.includes("市場・商品区分")) ?? colKeys[2];
|
|
125
|
+
const industry33Col = colKeys.find((k) => k.includes("33業種")) ?? colKeys[4];
|
|
126
|
+
console.error(`Column mapping: code=${codeCol}, name=${nameCol}, market=${marketCol}, industry=${industry33Col}`);
|
|
127
|
+
// seed.db を読み書きモードで開く
|
|
128
|
+
const db = new Database(DB_PATH);
|
|
129
|
+
db.pragma("journal_mode = WAL");
|
|
130
|
+
db.exec(`
|
|
131
|
+
DROP TABLE IF EXISTS listed_companies;
|
|
132
|
+
CREATE TABLE listed_companies (
|
|
133
|
+
securities_code TEXT PRIMARY KEY,
|
|
134
|
+
name TEXT NOT NULL,
|
|
135
|
+
name_normalized TEXT NOT NULL,
|
|
136
|
+
market TEXT,
|
|
137
|
+
industry_33 TEXT,
|
|
138
|
+
corporate_number TEXT
|
|
139
|
+
);
|
|
140
|
+
`);
|
|
141
|
+
const insert = db.prepare(`INSERT OR IGNORE INTO listed_companies
|
|
142
|
+
(securities_code, name, name_normalized, market, industry_33)
|
|
143
|
+
VALUES (?, ?, ?, ?, ?)`);
|
|
144
|
+
// 株式のみ(ETF・ETN等を除外)
|
|
145
|
+
const stockRows = rows.filter((r) => {
|
|
146
|
+
const market = String(r[marketCol] ?? "");
|
|
147
|
+
return (market.includes("プライム") ||
|
|
148
|
+
market.includes("スタンダード") ||
|
|
149
|
+
market.includes("グロース"));
|
|
150
|
+
});
|
|
151
|
+
const batchInsert = db.transaction((batch) => {
|
|
152
|
+
for (const row of batch) {
|
|
153
|
+
const code = String(row[codeCol] ?? "").trim();
|
|
154
|
+
const name = String(row[nameCol] ?? "").trim();
|
|
155
|
+
const market = String(row[marketCol] ?? "").trim();
|
|
156
|
+
const industry = String(row[industry33Col] ?? "").trim();
|
|
157
|
+
if (!code || !name)
|
|
158
|
+
continue;
|
|
159
|
+
// 市場区分を正規化("プライム(内国株式)" → "プライム")
|
|
160
|
+
let marketNorm = market;
|
|
161
|
+
if (market.includes("プライム"))
|
|
162
|
+
marketNorm = "プライム";
|
|
163
|
+
else if (market.includes("スタンダード"))
|
|
164
|
+
marketNorm = "スタンダード";
|
|
165
|
+
else if (market.includes("グロース"))
|
|
166
|
+
marketNorm = "グロース";
|
|
167
|
+
insert.run(code, name, normalizeName(name), marketNorm, industry);
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
batchInsert(stockRows);
|
|
171
|
+
console.error(`Imported ${stockRows.length} listed stocks`);
|
|
172
|
+
// seed_companiesとのマッチング(企業名正規化で突合)
|
|
173
|
+
const hasSeedTable = db
|
|
174
|
+
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='seed_companies'")
|
|
175
|
+
.get();
|
|
176
|
+
if (hasSeedTable) {
|
|
177
|
+
console.error("Matching with seed_companies...");
|
|
178
|
+
const listed = db
|
|
179
|
+
.prepare("SELECT securities_code, name, name_normalized FROM listed_companies")
|
|
180
|
+
.all();
|
|
181
|
+
const updateCorp = db.prepare("UPDATE listed_companies SET corporate_number = ? WHERE securities_code = ?");
|
|
182
|
+
const findSeed = db.prepare("SELECT corporate_number FROM seed_companies WHERE name LIKE ? LIMIT 1");
|
|
183
|
+
let matched = 0;
|
|
184
|
+
const matchBatch = db.transaction((items) => {
|
|
185
|
+
for (const item of items) {
|
|
186
|
+
// 正規化名で部分一致検索
|
|
187
|
+
const seed = findSeed.get(`%${item.name_normalized}%`);
|
|
188
|
+
if (seed) {
|
|
189
|
+
updateCorp.run(seed.corporate_number, item.securities_code);
|
|
190
|
+
matched++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
matchBatch(listed);
|
|
195
|
+
console.error(`Matched ${matched}/${listed.length} companies with seed data`);
|
|
196
|
+
}
|
|
197
|
+
// インデックス作成
|
|
198
|
+
db.exec(`
|
|
199
|
+
CREATE INDEX IF NOT EXISTS idx_listed_market ON listed_companies(market);
|
|
200
|
+
CREATE INDEX IF NOT EXISTS idx_listed_industry ON listed_companies(industry_33);
|
|
201
|
+
CREATE INDEX IF NOT EXISTS idx_listed_corpnum ON listed_companies(corporate_number);
|
|
202
|
+
CREATE INDEX IF NOT EXISTS idx_listed_name_norm ON listed_companies(name_normalized);
|
|
203
|
+
`);
|
|
204
|
+
db.close();
|
|
205
|
+
// メタ情報保存
|
|
206
|
+
const meta = { lastUpdated: new Date().toISOString() };
|
|
207
|
+
writeFileSync(JPX_META_PATH, JSON.stringify(meta), "utf-8");
|
|
208
|
+
console.error("JPX import complete");
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface SeedCompany {
|
|
2
|
+
corporate_number: string;
|
|
3
|
+
name: string;
|
|
4
|
+
prefecture: string;
|
|
5
|
+
city: string;
|
|
6
|
+
}
|
|
7
|
+
export declare class SeedCache {
|
|
8
|
+
private db;
|
|
9
|
+
private seedUrl;
|
|
10
|
+
private ready;
|
|
11
|
+
constructor(seedUrl: string);
|
|
12
|
+
/** Initialize: check cache, download if needed, open DB */
|
|
13
|
+
init(): Promise<void>;
|
|
14
|
+
isReady(): boolean;
|
|
15
|
+
/** Search seed companies */
|
|
16
|
+
search(params: {
|
|
17
|
+
pref?: string;
|
|
18
|
+
city?: string;
|
|
19
|
+
keyword?: string;
|
|
20
|
+
limit?: number;
|
|
21
|
+
shuffle?: boolean;
|
|
22
|
+
}): {
|
|
23
|
+
total: number;
|
|
24
|
+
results: SeedCompany[];
|
|
25
|
+
};
|
|
26
|
+
private checkNeedsUpdate;
|
|
27
|
+
private downloadAndImport;
|
|
28
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync, existsSync, readFileSync, writeFileSync, createWriteStream } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { createGunzip } from "node:zlib";
|
|
6
|
+
import { pipeline } from "node:stream/promises";
|
|
7
|
+
import { Readable } from "node:stream";
|
|
8
|
+
const CACHE_DIR = join(homedir(), ".bizgate", "cache");
|
|
9
|
+
const DB_PATH = join(CACHE_DIR, "seed.db");
|
|
10
|
+
const META_PATH = join(CACHE_DIR, "meta.json");
|
|
11
|
+
const CSV_PATH = join(CACHE_DIR, "seed.csv");
|
|
12
|
+
const TTL_DAYS = 7;
|
|
13
|
+
export class SeedCache {
|
|
14
|
+
db = null;
|
|
15
|
+
seedUrl;
|
|
16
|
+
ready = false;
|
|
17
|
+
constructor(seedUrl) {
|
|
18
|
+
this.seedUrl = seedUrl;
|
|
19
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
/** Initialize: check cache, download if needed, open DB */
|
|
22
|
+
async init() {
|
|
23
|
+
if (!this.seedUrl) {
|
|
24
|
+
console.error("SEED_CSV_URL is not set, prospect_list will be unavailable");
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const needsUpdate = await this.checkNeedsUpdate();
|
|
28
|
+
if (needsUpdate) {
|
|
29
|
+
try {
|
|
30
|
+
await this.downloadAndImport();
|
|
31
|
+
}
|
|
32
|
+
catch (e) {
|
|
33
|
+
console.error(`Seed update failed: ${e instanceof Error ? e.message : e}`);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
if (existsSync(DB_PATH)) {
|
|
37
|
+
this.db = new Database(DB_PATH, { readonly: true });
|
|
38
|
+
this.ready = true;
|
|
39
|
+
const count = this.db.prepare("SELECT COUNT(*) as cnt FROM seed_companies").get();
|
|
40
|
+
console.error(`Seed DB loaded: ${count.cnt} companies`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
isReady() {
|
|
44
|
+
return this.ready && this.db !== null;
|
|
45
|
+
}
|
|
46
|
+
/** Search seed companies */
|
|
47
|
+
search(params) {
|
|
48
|
+
if (!this.db)
|
|
49
|
+
return { total: 0, results: [] };
|
|
50
|
+
const conditions = ["1=1"];
|
|
51
|
+
const binds = {};
|
|
52
|
+
if (params.pref) {
|
|
53
|
+
conditions.push("prefecture = @pref");
|
|
54
|
+
binds.pref = params.pref;
|
|
55
|
+
}
|
|
56
|
+
if (params.city) {
|
|
57
|
+
conditions.push("city = @city");
|
|
58
|
+
binds.city = params.city;
|
|
59
|
+
}
|
|
60
|
+
if (params.keyword) {
|
|
61
|
+
conditions.push("name LIKE @keyword");
|
|
62
|
+
binds.keyword = `%${params.keyword}%`;
|
|
63
|
+
}
|
|
64
|
+
const where = conditions.join(" AND ");
|
|
65
|
+
const countRow = this.db.prepare(`SELECT COUNT(*) as cnt FROM seed_companies WHERE ${where}`).get(binds);
|
|
66
|
+
const total = countRow.cnt;
|
|
67
|
+
const limit = Math.min(params.limit ?? 200, 500);
|
|
68
|
+
const orderBy = params.shuffle !== false ? "RANDOM()" : "rowid";
|
|
69
|
+
const rows = this.db.prepare(`SELECT corporate_number, name, prefecture, city FROM seed_companies WHERE ${where} ORDER BY ${orderBy} LIMIT @limit`).all({ ...binds, limit });
|
|
70
|
+
return { total, results: rows };
|
|
71
|
+
}
|
|
72
|
+
async checkNeedsUpdate() {
|
|
73
|
+
if (!existsSync(DB_PATH))
|
|
74
|
+
return true;
|
|
75
|
+
if (!existsSync(META_PATH))
|
|
76
|
+
return true;
|
|
77
|
+
try {
|
|
78
|
+
const meta = JSON.parse(readFileSync(META_PATH, "utf-8"));
|
|
79
|
+
const lastUpdated = new Date(meta.lastUpdated);
|
|
80
|
+
const now = new Date();
|
|
81
|
+
const daysDiff = (now.getTime() - lastUpdated.getTime()) / (1000 * 60 * 60 * 24);
|
|
82
|
+
if (daysDiff < TTL_DAYS)
|
|
83
|
+
return false;
|
|
84
|
+
const res = await fetch(this.seedUrl, { method: "HEAD" });
|
|
85
|
+
const remoteEtag = res.headers.get("etag") ?? "";
|
|
86
|
+
return remoteEtag !== meta.etag;
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
async downloadAndImport() {
|
|
93
|
+
console.error("Downloading seed CSV...");
|
|
94
|
+
const res = await fetch(this.seedUrl);
|
|
95
|
+
if (!res.ok || !res.body)
|
|
96
|
+
throw new Error(`Download failed: HTTP ${res.status}`);
|
|
97
|
+
const etag = res.headers.get("etag") ?? "";
|
|
98
|
+
const gunzip = createGunzip();
|
|
99
|
+
const outStream = createWriteStream(CSV_PATH);
|
|
100
|
+
await pipeline(Readable.fromWeb(res.body), gunzip, outStream);
|
|
101
|
+
console.error("Importing to SQLite...");
|
|
102
|
+
if (existsSync(DB_PATH)) {
|
|
103
|
+
try {
|
|
104
|
+
const oldDb = new Database(DB_PATH);
|
|
105
|
+
oldDb.close();
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore */ }
|
|
108
|
+
}
|
|
109
|
+
const db = new Database(DB_PATH);
|
|
110
|
+
db.pragma("journal_mode = WAL");
|
|
111
|
+
db.exec(`
|
|
112
|
+
DROP TABLE IF EXISTS seed_companies;
|
|
113
|
+
CREATE TABLE seed_companies (
|
|
114
|
+
corporate_number TEXT PRIMARY KEY,
|
|
115
|
+
name TEXT NOT NULL,
|
|
116
|
+
prefecture TEXT,
|
|
117
|
+
city TEXT
|
|
118
|
+
);
|
|
119
|
+
`);
|
|
120
|
+
const insert = db.prepare("INSERT OR IGNORE INTO seed_companies (corporate_number, name, prefecture, city) VALUES (?, ?, ?, ?)");
|
|
121
|
+
const csv = readFileSync(CSV_PATH, "utf-8");
|
|
122
|
+
const lines = csv.split("\n");
|
|
123
|
+
const batchInsert = db.transaction((rows) => {
|
|
124
|
+
for (const row of rows) {
|
|
125
|
+
if (row.length >= 4) {
|
|
126
|
+
insert.run(row[0], row[1], row[2], row[3]);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
let batch = [];
|
|
131
|
+
let imported = 0;
|
|
132
|
+
for (let i = 1; i < lines.length; i++) {
|
|
133
|
+
const line = lines[i].trim();
|
|
134
|
+
if (!line)
|
|
135
|
+
continue;
|
|
136
|
+
const cols = line.split(",").map((c) => c.replace(/^"|"$/g, ""));
|
|
137
|
+
batch.push(cols);
|
|
138
|
+
if (batch.length >= 10000) {
|
|
139
|
+
batchInsert(batch);
|
|
140
|
+
imported += batch.length;
|
|
141
|
+
batch = [];
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
if (batch.length > 0) {
|
|
145
|
+
batchInsert(batch);
|
|
146
|
+
imported += batch.length;
|
|
147
|
+
}
|
|
148
|
+
db.exec(`
|
|
149
|
+
CREATE INDEX IF NOT EXISTS idx_prefecture ON seed_companies (prefecture);
|
|
150
|
+
CREATE INDEX IF NOT EXISTS idx_city ON seed_companies (city);
|
|
151
|
+
CREATE INDEX IF NOT EXISTS idx_name ON seed_companies (name);
|
|
152
|
+
`);
|
|
153
|
+
db.close();
|
|
154
|
+
const meta = {
|
|
155
|
+
etag,
|
|
156
|
+
lastUpdated: new Date().toISOString(),
|
|
157
|
+
};
|
|
158
|
+
writeFileSync(META_PATH, JSON.stringify(meta), "utf-8");
|
|
159
|
+
console.error(`Seed import complete: ${imported} companies`);
|
|
160
|
+
}
|
|
161
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bizgate-mcp-server",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.9",
|
|
4
4
|
"description": "BizGate APIとClaudeを連携するMCPサーバー",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -17,9 +17,12 @@
|
|
|
17
17
|
},
|
|
18
18
|
"dependencies": {
|
|
19
19
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
20
|
+
"better-sqlite3": "^12.9.0",
|
|
21
|
+
"xlsx": "^0.18.5",
|
|
20
22
|
"zod": "^3.24.0"
|
|
21
23
|
},
|
|
22
24
|
"devDependencies": {
|
|
25
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
23
26
|
"@types/node": "^22.0.0",
|
|
24
27
|
"typescript": "^5.8.3"
|
|
25
28
|
}
|