bizgate-mcp-server 0.3.7 → 0.3.8

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/index.js CHANGED
@@ -11,6 +11,8 @@ 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 { JpxCache } from "./jpx-cache.js";
14
16
  import { homedir } from "node:os";
15
17
  import { join, dirname } from "node:path";
16
18
  import { readFileSync } from "node:fs";
@@ -37,7 +39,7 @@ if (authMode === "ip" && !process.env.BIZGATE_APP) {
37
39
  process.exit(1);
38
40
  }
39
41
  const dailyLimit = Number(process.env.BIZGATE_DAILY_LIMIT ?? "200");
40
- const seedApiUrl = process.env.SEED_API_URL ?? "http://192.168.40.94:3456";
42
+ const seedCsvUrl = process.env.SEED_CSV_URL ?? "https://pub-3952dc5d8b37475196bab871e4e93bc1.r2.dev/latest/seed-list.csv.gz";
41
43
  const usageFile = process.env.BIZGATE_USAGE_FILE ??
42
44
  join(homedir(), ".bizgate-mcp-usage.json");
43
45
  const baseUrl = process.env.BIZGATE_BASE_URL ??
@@ -58,6 +60,8 @@ const config = {
58
60
  };
59
61
  const usageTracker = new UsageTracker(usageFile, dailyLimit);
60
62
  const client = new BizGateClient(config, usageTracker);
63
+ const seedCache = new SeedCache(seedCsvUrl);
64
+ const jpxCache = new JpxCache(process.env.JPX_DATA_URL);
61
65
  // ---------- MCPサーバー ----------
62
66
  const __dirname = dirname(fileURLToPath(import.meta.url));
63
67
  const pkg = JSON.parse(readFileSync(join(__dirname, "..", "package.json"), "utf-8"));
@@ -421,17 +425,8 @@ server.tool("bizgate__usage_status", "本日のBizGate API残り利用回数を
421
425
  ],
422
426
  };
423
427
  });
424
- // ---------- Tool 7: プロスペクトリスト(シードAPI連携) ----------
428
+ // ---------- Tool 7: プロスペクトリスト(ローカルSQLite) ----------
425
429
  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
430
  /** 売上カテゴリ文字列から数値を抽出して最低ラインと比較 */
436
431
  function revenueMatchesMin(revenue, minLabel) {
437
432
  if (!revenue)
@@ -462,7 +457,7 @@ function axisToKeywords(axis) {
462
457
  const keywords = axes.flatMap((a) => (keywordMap[a] ?? a).split(","));
463
458
  return [...new Set(keywords)].join(",");
464
459
  }
465
- server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法人)から条件に合う企業を抽出し、BizGate APIで部署・キーマンまで照会してプロスペクトリストを作成する。1回の実行で約40 APIコールを消費(1日約5回実行可能)。SEED_API_URLが必要。", {
460
+ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法人、ローカルSQLiteキャッシュ)から条件に合う企業を抽出し、BizGate APIで部署・キーマンまで照会してプロスペクトリストを作成する。1回の実行で約40 APIコールを消費(1日約5回実行可能)。初回はSEED_CSV_URLからデータをダウンロード。", {
466
461
  pref: z.string().optional().describe("都道府県(例: 東京都)"),
467
462
  city: z.string().optional().describe("市区町村名(例: 千代田区)"),
468
463
  industry: z.string().optional().describe("BizGate業種分類キーワード(例: 製造業, サービス, IT)"),
@@ -471,9 +466,9 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
471
466
  proposal_axis: z.union([z.string(), z.array(z.string())]).optional().describe("提案軸(DOMO / 営業DX/CRM)。部署検索のキーワードに変換される"),
472
467
  count: z.number().optional().describe("取得したい企業数(デフォルト: 10、最大: 30)"),
473
468
  }, async ({ pref, city, industry, revenue_min, employee_500plus, proposal_axis, count: rawCount }) => {
474
- if (!seedApiUrl) {
469
+ if (!seedCache.isReady()) {
475
470
  return {
476
- content: [{ type: "text", text: "エラー: SEED_API_URL が設定されていません。管理者に連絡してください。" }],
471
+ content: [{ type: "text", text: "エラー: シードリストが利用できません。SEED_CSV_URL を設定し、Claude Code を再起動してください。" }],
477
472
  };
478
473
  }
479
474
  const count = Math.min(rawCount ?? 10, 30);
@@ -483,28 +478,13 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
483
478
  content: [{ type: "text", text: `エラー: 残りAPI回数が${remaining}回です。明日以降にお試しください。${usageFooter()}` }],
484
479
  };
485
480
  }
486
- // ---- Step 1: シードAPIから候補取得 (API 0回) ----
487
- const seedParams = {
488
- limit: "200",
489
- shuffle: "true",
490
- type: "株式会社",
491
- };
492
- if (pref)
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
- }
481
+ // ---- Step 1: ローカルSQLiteから候補取得 (API 0回) ----
482
+ const { total: seedTotal, results: seeds } = seedCache.search({
483
+ pref,
484
+ city,
485
+ limit: 200,
486
+ shuffle: true,
487
+ });
508
488
  if (seeds.length === 0) {
509
489
  return {
510
490
  content: [{ type: "text", text: `条件に合う企業がシードリストに見つかりませんでした。${usageFooter()}` }],
@@ -644,8 +624,164 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法
644
624
  content: [{ type: "text", text: header + table + stats + usageFooter() }],
645
625
  };
646
626
  });
627
+ // ---------- Tool 8: 上場企業×部署横断検索 ----------
628
+ const ENTERPRISE_MAX_API = 120;
629
+ server.tool("bizgate__enterprise_search", "上場企業(JPX約3,800社)から条件に合う企業を絞り込み、部署名キーワードで横断検索する。企業名を知らなくても「データ部門がある売上1000億以上のプライム企業」のような条件検索が可能。市場区分・業種・売上でフィルタし、BizGate APIで部署を照会する。API消費: 企業検証N回 + 部署検索M回。", {
630
+ dept_keyword: z.string().describe("部署名キーワード(例: データ, DX, 営業推進)。カンマ区切りで複数可"),
631
+ market: z.string().optional().describe("市場区分(プライム / スタンダード / グロース)"),
632
+ industry: z.string().optional().describe("33業種分類キーワード(例: 情報・通信業, 電気機器, サービス業)"),
633
+ revenue_min: z.string().optional().describe("最低売上(50億以上 / 300億以上 / 1000億以上)"),
634
+ name_keyword: z.string().optional().describe("企業名キーワード(例: ソニー)"),
635
+ count: z.number().optional().describe("取得したい企業数(デフォルト: 10、最大: 30)"),
636
+ }, async ({ dept_keyword, market, industry, revenue_min, name_keyword, count: rawCount }) => {
637
+ if (!jpxCache.isReady()) {
638
+ return {
639
+ content: [{
640
+ type: "text",
641
+ text: "エラー: 上場企業データベースが利用できません。サーバーを再起動してください。",
642
+ }],
643
+ };
644
+ }
645
+ const count = Math.min(rawCount ?? 10, 30);
646
+ const remaining = usageTracker.remaining();
647
+ if (remaining < 10) {
648
+ return {
649
+ content: [{
650
+ type: "text",
651
+ text: `エラー: 残りAPI回数が${remaining}回です。明日以降にお試しください。${usageFooter()}`,
652
+ }],
653
+ };
654
+ }
655
+ // ---- Step 1: ローカルDBから上場企業を検索 (API 0回) ----
656
+ const { total: jpxTotal, results: candidates } = jpxCache.search({
657
+ market,
658
+ industry,
659
+ nameKeyword: name_keyword,
660
+ limit: 200,
661
+ });
662
+ if (candidates.length === 0) {
663
+ return {
664
+ content: [{
665
+ type: "text",
666
+ text: `条件に合う上場企業が見つかりませんでした。\n(検索条件: 市場=${market ?? "全て"}, 業種=${industry ?? "全て"})${usageFooter()}`,
667
+ }],
668
+ };
669
+ }
670
+ let verified = [];
671
+ let apiCalls = 0;
672
+ const batchSize = 5;
673
+ if (revenue_min) {
674
+ // 売上検証が必要 → BizGate APIで確認
675
+ const targetVerified = Math.ceil(count * 2);
676
+ for (let i = 0; i < candidates.length &&
677
+ verified.length < targetVerified &&
678
+ apiCalls < ENTERPRISE_MAX_API; i += batchSize) {
679
+ const batch = candidates.slice(i, i + batchSize);
680
+ const results = await Promise.all(batch.map(async (listed) => {
681
+ try {
682
+ const searchParams = listed.corporate_number
683
+ ? { compno: listed.corporate_number }
684
+ : { shogo: listed.name };
685
+ const { docs } = await client.searchCompany(searchParams);
686
+ apiCalls++;
687
+ return { listed, doc: docs[0] ?? null };
688
+ }
689
+ catch {
690
+ apiCalls++;
691
+ return { listed, doc: null };
692
+ }
693
+ }));
694
+ for (const { listed, doc } of results) {
695
+ if (!doc)
696
+ continue;
697
+ if (!revenueMatchesMin(doc.revenue, revenue_min))
698
+ continue;
699
+ verified.push({ listed, doc });
700
+ }
701
+ }
702
+ }
703
+ else {
704
+ // 売上検証不要 → 全候補をそのまま使用(APIはStep3で使う)
705
+ verified = candidates.map((listed) => ({
706
+ listed,
707
+ doc: {}, // ダミー、部署検索でcompnoを使うため
708
+ }));
709
+ }
710
+ if (verified.length === 0) {
711
+ return {
712
+ content: [{
713
+ type: "text",
714
+ text: `売上条件(${revenue_min})に合う企業が見つかりませんでした。\n(候補: ${candidates.length}社 / API照会: ${apiCalls}回)${usageFooter()}`,
715
+ }],
716
+ };
717
+ }
718
+ const results = [];
719
+ for (const { listed, doc } of verified.slice(0, count * 2)) {
720
+ if (results.length >= count || apiCalls >= ENTERPRISE_MAX_API)
721
+ break;
722
+ try {
723
+ const deptSearchParams = listed.corporate_number
724
+ ? { compno: listed.corporate_number, bKwd: dept_keyword, bKOpr: "0" }
725
+ : { shogo: listed.name, bKwd: dept_keyword, bKOpr: "0" };
726
+ const { docs: deptDocs } = await client.searchDepartments(deptSearchParams);
727
+ apiCalls++;
728
+ if (deptDocs.length > 0) {
729
+ const deptNames = deptDocs
730
+ .slice(0, 5)
731
+ .map((d) => f(d.bumon) || "(不明)")
732
+ .filter((n) => n !== "(不明)");
733
+ const phone = deptDocs.find((d) => f(d.tel))
734
+ ? f(deptDocs.find((d) => f(d.tel)).tel)
735
+ : "-";
736
+ results.push({
737
+ name: listed.name,
738
+ market: listed.market,
739
+ industry: listed.industry_33,
740
+ revenue: doc.revenue ?? "-",
741
+ departments: deptNames.length > 0 ? deptNames : [f(deptDocs[0].bumon) || "-"],
742
+ phone,
743
+ corporate_number: listed.corporate_number ?? "-",
744
+ });
745
+ }
746
+ }
747
+ catch {
748
+ apiCalls++;
749
+ // 部署データなし(504)等 → スキップ
750
+ }
751
+ }
752
+ if (results.length === 0) {
753
+ return {
754
+ content: [{
755
+ type: "text",
756
+ text: `「${dept_keyword}」を含む部署が見つかりませんでした。\n(候補: ${verified.length}社 / API照会: ${apiCalls}回)${usageFooter()}`,
757
+ }],
758
+ };
759
+ }
760
+ // ---- Step 4: 結果フォーマット ----
761
+ const header = `## 上場企業 × 部署横断検索(${results.length}社)\n\n`;
762
+ const filterInfo = `検索条件: 部署キーワード=「${dept_keyword}」` +
763
+ (market ? ` / 市場=${market}` : "") +
764
+ (industry ? ` / 業種=${industry}` : "") +
765
+ (revenue_min ? ` / 売上=${revenue_min}` : "") +
766
+ "\n\n";
767
+ const table = "| # | 会社名 | 市場 | 業種 | 売上 | マッチ部署 | 電話 |\n" +
768
+ "|---|--------|------|------|------|-----------|------|\n" +
769
+ results
770
+ .map((r, i) => {
771
+ const deptStr = r.departments.slice(0, 3).join(", ");
772
+ return `| ${i + 1} | ${r.name} | ${r.market} | ${r.industry} | ${r.revenue} | ${deptStr} | ${r.phone} |`;
773
+ })
774
+ .join("\n");
775
+ const stats = `\n\n---\n上場企業候補: ${jpxTotal}社 / API照会: ${apiCalls}回 / マッチ: ${results.length}社`;
776
+ return {
777
+ content: [{ type: "text", text: header + filterInfo + table + stats + usageFooter() }],
778
+ };
779
+ });
647
780
  // ---------- 起動 ----------
648
781
  async function main() {
782
+ // シードキャッシュを非同期で初期化(起動をブロックしない)
783
+ seedCache.init().catch((e) => console.error("Seed cache init error:", e));
784
+ jpxCache.init().catch((e) => console.error("JPX cache init error:", e));
649
785
  const transport = new StdioServerTransport();
650
786
  await server.connect(transport);
651
787
  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.7",
3
+ "version": "0.3.8",
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
  }