bizgate-mcp-server 0.3.6 → 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.BIZGATE_SEED_API_URL ?? "";
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,94 +425,81 @@ server.tool("bizgate__usage_status", "本日のBizGate API残り利用回数を
421
425
  ],
422
426
  };
423
427
  });
424
- // ---------- Tool 7: プロスペクトリスト(シードAPI連携) ----------
425
- const REVENUE_CATEGORIES = {
426
- 1: "1. 1000万未満",
427
- 2: "2. 1000万-3000万未満",
428
- 3: "3. 3000万-1億未満",
429
- 4: "4. 1億-5億未満",
430
- 5: "5. 5億-10億未満",
431
- 6: "6. 10億-20億未満",
432
- 7: "7. 20億-100億未満",
433
- 8: "8. 100億以上",
434
- };
428
+ // ---------- Tool 7: プロスペクトリスト(ローカルSQLite) ----------
435
429
  const MAX_API_CALLS_HARD_LIMIT = 150;
436
- async function fetchSeeds(params) {
437
- const url = new URL("/search", seedApiUrl);
438
- for (const [k, v] of Object.entries(params))
439
- url.searchParams.set(k, v);
440
- const res = await fetch(url);
441
- if (!res.ok)
442
- throw new Error(`seed-api エラー: HTTP ${res.status}`);
443
- return (await res.json());
444
- }
445
- function revenueMatchesMin(revenue, minCategory) {
430
+ /** 売上カテゴリ文字列から数値を抽出して最低ラインと比較 */
431
+ function revenueMatchesMin(revenue, minLabel) {
446
432
  if (!revenue)
447
433
  return false;
434
+ // revenue例: "7. 20億-100億未満", minLabel例: "50億以上"
435
+ const thresholds = {
436
+ "50億以上": 7, // カテゴリ7(20億-100億) 以上
437
+ "300億以上": 8, // カテゴリ8(100億以上)
438
+ "1000億以上": 8, // カテゴリ8(100億以上)
439
+ };
440
+ const minCat = thresholds[minLabel];
441
+ if (!minCat)
442
+ return true; // 不明なラベルはフィルタしない
448
443
  const match = revenue.match(/^(\d+)\./);
449
444
  if (!match)
450
445
  return false;
451
- return Number(match[1]) >= minCategory;
446
+ return Number(match[1]) >= minCat;
447
+ }
448
+ /** proposal_axis からアプローチキーワードを生成 */
449
+ function axisToKeywords(axis) {
450
+ const axes = Array.isArray(axis) ? axis : [axis];
451
+ const keywordMap = {
452
+ "DOMO": "経営企画,DX,システム,情報,データ,分析",
453
+ "営業DX/CRM": "営業,マーケティング,販売,顧客,CRM,販促",
454
+ "営業DX": "営業,マーケティング,販売,顧客,CRM,販促",
455
+ "CRM": "営業,マーケティング,顧客,CRM",
456
+ };
457
+ const keywords = axes.flatMap((a) => (keywordMap[a] ?? a).split(","));
458
+ return [...new Set(keywords)].join(",");
452
459
  }
453
- server.tool("bizgate__prospect_list", "シードリスト(国税庁法人データ)から条件に合う企業を抽出し、BizGate APIで詳細を照会してプロスペクトリストを作成する。BIZGATE_SEED_API_URLの設定が必要。", {
460
+ server.tool("bizgate__prospect_list", "シードリスト(国税庁572万法人、ローカルSQLiteキャッシュ)から条件に合う企業を抽出し、BizGate APIで部署・キーマンまで照会してプロスペクトリストを作成する。1回の実行で約40 APIコールを消費(1日約5回実行可能)。初回はSEED_CSV_URLからデータをダウンロード。", {
454
461
  pref: z.string().optional().describe("都道府県(例: 東京都)"),
455
- keyword: z.string().optional().describe("会社名キーワード(部分一致)"),
456
- industry: z.string().optional().describe("業種キーワード(例: IT,製造)。BizGateの業種情報でフィルタリング"),
457
- minRevenue: z.number().optional().describe("最低売上カテゴリ番号(1=1000万未満, 2=1000万-3000万, 3=3000万-1億, 4=1億-5億, 5=5億-10億, 6=10億-20億, 7=20億-100億, 8=100億以上)"),
458
- targetCount: z.number().optional().describe("取得したい企業数(デフォルト: 10、最大: 20)"),
459
- maxApiCalls: z.number().optional().describe("このツール1回で使うAPI上限(デフォルト: 50、最大: 150)"),
460
- }, async ({ pref, keyword, industry, minRevenue, targetCount: rawTarget, maxApiCalls: rawMax }) => {
461
- if (!seedApiUrl) {
462
+ city: z.string().optional().describe("市区町村名(例: 千代田区)"),
463
+ industry: z.string().optional().describe("BizGate業種分類キーワード(例: 製造業, サービス, IT"),
464
+ revenue_min: z.string().optional().describe("最低売上(50億以上 / 300億以上 / 1000億以上)"),
465
+ employee_500plus: z.boolean().optional().describe("従業員500人以上のみ"),
466
+ proposal_axis: z.union([z.string(), z.array(z.string())]).optional().describe("提案軸(DOMO / 営業DX/CRM)。部署検索のキーワードに変換される"),
467
+ count: z.number().optional().describe("取得したい企業数(デフォルト: 10、最大: 30)"),
468
+ }, async ({ pref, city, industry, revenue_min, employee_500plus, proposal_axis, count: rawCount }) => {
469
+ if (!seedCache.isReady()) {
462
470
  return {
463
- content: [{ type: "text", text: "エラー: BIZGATE_SEED_API_URL が設定されていません。管理者に連絡してください。" }],
471
+ content: [{ type: "text", text: "エラー: シードリストが利用できません。SEED_CSV_URL を設定し、Claude Code を再起動してください。" }],
464
472
  };
465
473
  }
466
- const targetCount = Math.min(rawTarget ?? 10, 20);
467
- const apiLimit = Math.min(rawMax ?? 50, MAX_API_CALLS_HARD_LIMIT);
474
+ const count = Math.min(rawCount ?? 10, 30);
468
475
  const remaining = usageTracker.remaining();
469
- const effectiveLimit = Math.min(apiLimit, remaining);
470
- if (effectiveLimit < 5) {
471
- return {
472
- content: [{ type: "text", text: `エラー: 残りAPI回数が${remaining}回しかありません。明日以降にお試しください。${usageFooter()}` }],
473
- };
474
- }
475
- // Step 1: シードAPIから候補取得
476
- const seedParams = {
477
- limit: String(targetCount * 5),
478
- shuffle: "true",
479
- };
480
- if (pref)
481
- seedParams.pref = pref;
482
- if (keyword)
483
- seedParams.keyword = keyword;
484
- let seeds;
485
- let seedTotal;
486
- try {
487
- const result = await fetchSeeds(seedParams);
488
- seeds = result.results;
489
- seedTotal = result.total;
490
- }
491
- catch (e) {
476
+ if (remaining < 10) {
492
477
  return {
493
- content: [{ type: "text", text: `シードAPIへの接続に失敗しました: ${e instanceof Error ? e.message : "不明なエラー"}` }],
478
+ content: [{ type: "text", text: `エラー: 残りAPI回数が${remaining}回です。明日以降にお試しください。${usageFooter()}` }],
494
479
  };
495
480
  }
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
+ });
496
488
  if (seeds.length === 0) {
497
489
  return {
498
490
  content: [{ type: "text", text: `条件に合う企業がシードリストに見つかりませんでした。${usageFooter()}` }],
499
491
  };
500
492
  }
501
- // Step 2: BizGate APIで詳細照会 + フィルタリング
502
- const matched = [];
493
+ // ---- Step 2: BizGate企業検索 売上/業種/従業員フィルタ ----
494
+ const verified = [];
503
495
  let apiCalls = 0;
504
496
  const batchSize = 5;
505
- for (let i = 0; i < seeds.length && matched.length < targetCount && apiCalls < effectiveLimit; i += batchSize) {
506
- const batch = seeds.slice(i, Math.min(i + batchSize, seeds.length));
507
- const remaining2 = effectiveLimit - apiCalls;
508
- const actualBatch = batch.slice(0, remaining2);
509
- const results = await Promise.all(actualBatch.map(async (seed) => {
497
+ const targetVerified = Math.ceil(count * 1.5); // 部署/キーマンで脱落分を見越す
498
+ for (let i = 0; i < seeds.length && verified.length < targetVerified && apiCalls < MAX_API_CALLS_HARD_LIMIT; i += batchSize) {
499
+ const batch = seeds.slice(i, i + batchSize);
500
+ const results = await Promise.all(batch.map(async (seed) => {
510
501
  try {
511
- const { docs } = await client.searchCompany({ compno: seed.compno });
502
+ const { docs } = await client.searchCompany({ compno: seed.corporate_number });
512
503
  apiCalls++;
513
504
  return { seed, doc: docs[0] ?? null };
514
505
  }
@@ -520,43 +511,277 @@ server.tool("bizgate__prospect_list", "シードリスト(国税庁法人デ
520
511
  for (const { seed, doc } of results) {
521
512
  if (!doc)
522
513
  continue;
523
- // 売上フィルタ
524
- if (minRevenue && !revenueMatchesMin(doc.revenue, minRevenue))
514
+ if (revenue_min && !revenueMatchesMin(doc.revenue, revenue_min))
525
515
  continue;
526
- // 業種フィルタ
527
516
  if (industry) {
528
517
  const docIndustry = f(doc.gyoshu_facet);
529
- const keywords = industry.split(",").map((k) => k.trim());
530
- if (!keywords.some((k) => docIndustry.includes(k)))
518
+ if (!industry.split(",").some((k) => docIndustry.includes(k.trim())))
531
519
  continue;
532
520
  }
533
- matched.push({ doc, seed });
521
+ if (employee_500plus && doc.emp) {
522
+ const empMatch = doc.emp.match(/(\d+)/);
523
+ if (empMatch) {
524
+ // emp例: "6. 500-1000人未満" → カテゴリ6以上
525
+ const empCat = Number(doc.emp.match(/^(\d+)\./)?.[1] ?? 0);
526
+ if (empCat < 6)
527
+ continue; // カテゴリ6 = 500-1000人
528
+ }
529
+ }
530
+ verified.push({ doc, seed });
534
531
  }
535
532
  }
536
- // Step 3: 結果フォーマット
537
- if (matched.length === 0) {
533
+ if (verified.length === 0) {
538
534
  return {
539
535
  content: [{
540
536
  type: "text",
541
- text: `条件に合う企業が見つかりませんでした。\n(シード候補: ${seeds.length}社 / API照会: ${apiCalls}回 / シード全体: ${seedTotal}社)${usageFooter()}`,
537
+ text: `条件に合う企業が見つかりませんでした。\n(シード候補: ${seeds.length}社 / API照会: ${apiCalls}回)${usageFooter()}`,
542
538
  }],
543
539
  };
544
540
  }
545
- const header = `## プロスペクトリスト(${matched.length}社)\n\n`;
546
- const table = `| # | 会社名 | 業種 | 売上 | 従業員 | 所在地 | HP |\n|---|--------|------|------|--------|--------|----|\n` +
547
- matched
548
- .map((m, i) => {
549
- const d = m.doc;
550
- return `| ${i + 1} | ${f(d.shogo)} | ${f(d.gyoshu_facet)} | ${d.revenue ?? "-"} | ${d.emp ?? "-"} | ${f(d.add)} | ${f(d.hpurl) || "-"} |`;
541
+ // ---- Step 3: 部署 + キーマン検索 ----
542
+ const deptKeywords = proposal_axis ? axisToKeywords(proposal_axis) : "";
543
+ const prospects = [];
544
+ for (const { doc, seed } of verified.slice(0, count)) {
545
+ const companyName = f(doc.shogo);
546
+ let department = "-";
547
+ let phone = f(doc.tel) || "-";
548
+ let keyman;
549
+ // 部署検索
550
+ if (deptKeywords) {
551
+ try {
552
+ const { docs: deptDocs } = await client.searchDepartments({
553
+ shogo: companyName, bKwd: deptKeywords, bKOpr: "0",
554
+ });
555
+ apiCalls++;
556
+ if (deptDocs.length > 0) {
557
+ department = f(deptDocs[0].bumon) || "-";
558
+ if (f(deptDocs[0].tel))
559
+ phone = f(deptDocs[0].tel);
560
+ }
561
+ }
562
+ catch {
563
+ apiCalls++;
564
+ }
565
+ }
566
+ // キーマン検索
567
+ if (skeyKeyman && skeyKeymanName) {
568
+ try {
569
+ const { docs: keyDocs } = await client.searchKeymanWithNames({
570
+ shogo: companyName, bKwd: deptKeywords || undefined, limit: 1,
571
+ });
572
+ apiCalls += 2; // 一覧1回 + 詳細1回
573
+ if (keyDocs.length > 0) {
574
+ keyman = {
575
+ name: f(keyDocs[0].ceo) || "(不明)",
576
+ title: f(keyDocs[0].bumon) || "(不明)",
577
+ };
578
+ if (f(keyDocs[0].tel))
579
+ phone = f(keyDocs[0].tel);
580
+ }
581
+ }
582
+ catch {
583
+ apiCalls += 1;
584
+ }
585
+ }
586
+ // アプローチノート生成
587
+ const axes = proposal_axis
588
+ ? (Array.isArray(proposal_axis) ? proposal_axis : [proposal_axis])
589
+ : [];
590
+ const axisLabel = axes.join("・") || "汎用";
591
+ const approachNote = `${axisLabel}の導入提案。${f(doc.gyoshu_facet) || ""}${doc.emp ? `、${doc.emp}` : ""}。`;
592
+ prospects.push({
593
+ name: companyName,
594
+ prefecture: seed.prefecture,
595
+ city: seed.city,
596
+ corporate_number: seed.corporate_number,
597
+ industry: f(doc.gyoshu_facet) || "-",
598
+ revenue: doc.revenue ?? "-",
599
+ department,
600
+ keyman,
601
+ phone,
602
+ approach_note: approachNote,
603
+ });
604
+ if (apiCalls >= MAX_API_CALLS_HARD_LIMIT)
605
+ break;
606
+ }
607
+ // ---- Step 4: 結果フォーマット ----
608
+ // キーマンあり > 部署あり > 会社のみ でソート
609
+ prospects.sort((a, b) => {
610
+ const scoreA = (a.keyman ? 2 : 0) + (a.department !== "-" ? 1 : 0);
611
+ const scoreB = (b.keyman ? 2 : 0) + (b.department !== "-" ? 1 : 0);
612
+ return scoreB - scoreA;
613
+ });
614
+ const header = `## プロスペクトリスト(${prospects.length}社)\n\n`;
615
+ const table = `| # | 会社名 | 部署 | キーマン | 電話 | アプローチ角度 |\n|---|--------|------|---------|------|---------------|\n` +
616
+ prospects
617
+ .map((p, i) => {
618
+ const keymanStr = p.keyman ? `${p.keyman.name}(${p.keyman.title})` : "-";
619
+ return `| ${i + 1} | ${p.name} | ${p.department} | ${keymanStr} | ${p.phone} | ${p.approach_note} |`;
551
620
  })
552
621
  .join("\n");
553
- const stats = `\n\n---\nシード候補: ${seeds.length}社(全${seedTotal}社中) / API照会: ${apiCalls}回 / マッチ: ${matched.length}社`;
622
+ const stats = `\n\n---\nシード: ${seedTotal}社中${seeds.length}社取得 / API照会: ${apiCalls}回 / マッチ: ${prospects.length}社`;
554
623
  return {
555
624
  content: [{ type: "text", text: header + table + stats + usageFooter() }],
556
625
  };
557
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
+ });
558
780
  // ---------- 起動 ----------
559
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));
560
785
  const transport = new StdioServerTransport();
561
786
  await server.connect(transport);
562
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.6",
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
  }