hojokin-navi

majiayu000's avatarfrom majiayu000

補助金ナビ開発スキル。JグランツAPI連携、Supabase操作、補助金データ処理のベストプラクティス。使用タイミング: (1) JグランツAPIからデータ取得 (2) 補助金データのDB保存・検索 (3) HTMLフィールドの処理 (4) Slack通知実装

⭐5stars🔀1forks📁View on GitHub🕐Updated Jan 11, 2026

When & Why to Use This Skill

This Claude skill provides a comprehensive development framework for building subsidy navigation systems, specifically optimized for the J-Grants API and Supabase. It streamlines the entire lifecycle of subsidy data management—from fetching and sanitizing complex government data to implementing automated Slack notifications and scheduled synchronization—helping developers build robust business support tools efficiently.

Use Cases

  • Automating the retrieval of Japanese government subsidy information using the J-Grants API with pre-defined TypeScript types and endpoints.
  • Building a searchable subsidy database in Supabase with advanced filtering for industries, regions, and application deadlines.
  • Implementing automated Slack alerts to notify businesses or consultants immediately when new relevant subsidies are published.
  • Developing scheduled batch processes (Cron jobs) to keep local subsidy data synchronized with official government portals while handling API rate limits.
  • Sanitizing and formatting complex HTML content from government listings for clean, secure display in modern web applications.
namehojokin-navi
description|
使用タイミング(1) JグランツAPIからデータ取得 (2) 補助金データのDB保存・検索 (3) HTMLフィールドの処理 (4) Slack通知実装

補助金ナビ開発スキル

JグランツAPI

エンドポイント

BASE_URL = https://api.jgrants-portal.go.jp/exp/v1/public
エンドポイント 用途
/subsidies 一覧取得(検索)
/subsidies/id/{id} 詳細取得

一覧取得

// 募集中の補助金を取得
const request = {
  acceptance: 1,        // 1=募集中, 0=全て
  keyword: "IT",        // オプション
  area: "東京都",       // オプション
};

const url = `${BASE_URL}/subsidies?request=${encodeURIComponent(JSON.stringify(request))}`;
const res = await fetch(url);
const { result, metadata } = await res.json();
// result: JGrantsSubsidy[]
// metadata.resultset.count: 総件数

詳細取得

const res = await fetch(`${BASE_URL}/subsidies/id/${jgrantsId}`);
const { result } = await res.json();
const subsidy = result[0]; // 単一オブジェクト

レスポンス型定義

type JGrantsSubsidy = {
  id: string;                          // "a0WJ200000CDWaWMAX"
  name: string;                        // "S-00007689"
  title: string;
  subsidy_catch_phrase?: string;
  detail?: string;                     // ⚠️ HTML含む
  target_area_search?: string;
  target_area_detail?: string;
  industry?: string;                   // ⚠️ "/"区切り
  use_purpose?: string;
  subsidy_max_limit?: number;
  subsidy_rate?: string;
  target_number_of_employees?: string;
  acceptance_start_datetime?: string;
  acceptance_end_datetime?: string;
  front_subsidy_detail_page_url?: string;
  application_guidelines?: Array<{     // ⚠️ 大容量
    name: string;
    data: string;  // base64
  }>;
};

データ変換

業種パース

// "製造業/建設業/情報通信業" → ["製造業", "建設業", "情報通信業"]
function parseIndustry(industry?: string): string[] {
  if (!industry) return [];
  return industry.split('/').map(s => s.trim()).filter(Boolean);
}

HTMLサニタイズ

import DOMPurify from 'isomorphic-dompurify';

function sanitizeHtml(html?: string): string {
  if (!html) return '';
  return DOMPurify.sanitize(html, {
    ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'ul', 'ol', 'li', 'a'],
    ALLOWED_ATTR: ['href', 'target'],
  });
}

DB保存用変換

function toDbSubsidy(api: JGrantsSubsidy) {
  return {
    jgrants_id: api.id,
    name: api.name,
    title: api.title,
    catch_phrase: api.subsidy_catch_phrase,
    description: sanitizeHtml(api.detail),
    target_area: api.target_area_search ? [api.target_area_search] : [],
    target_area_detail: api.target_area_detail,
    industry: parseIndustry(api.industry),
    use_purpose: api.use_purpose,
    max_amount: api.subsidy_max_limit,
    subsidy_rate: api.subsidy_rate,
    start_date: api.acceptance_start_datetime,
    end_date: api.acceptance_end_datetime,
    front_url: api.front_subsidy_detail_page_url,
    is_active: true,
    updated_at: new Date().toISOString(),
  };
}

Supabase操作

Upsert(作成 or 更新)

const { error } = await supabase
  .from('subsidies')
  .upsert(toDbSubsidy(apiData), {
    onConflict: 'jgrants_id',
  });

検索クエリ

// 基本検索
let query = supabase
  .from('subsidies')
  .select('*')
  .eq('is_active', true)
  .order('end_date', { ascending: true });

// キーワード検索(ILIKE)
if (keyword) {
  query = query.or(`title.ilike.%${keyword}%,description.ilike.%${keyword}%`);
}

// 地域フィルター
if (area) {
  query = query.contains('target_area', [area]);
}

// 業種フィルター(JSONB配列)
if (industry) {
  query = query.contains('industry', [industry]);
}

// 金額フィルター
if (maxAmount) {
  query = query.lte('max_amount', maxAmount);
}

// ページネーション
const { data, count } = await query
  .range(offset, offset + limit - 1)
  .select('*', { count: 'exact' });

Slack通知

Webhook送信

async function sendSlackNotification(message: string, blocks?: any[]) {
  await fetch(process.env.SLACK_WEBHOOK_URL!, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      text: message,
      blocks,
    }),
  });
}

新着補助金通知テンプレート

const blocks = [
  {
    type: 'header',
    text: { type: 'plain_text', text: '🆕 新着補助金' },
  },
  {
    type: 'section',
    text: {
      type: 'mrkdwn',
      text: `*${subsidy.title}*\n${subsidy.catch_phrase || ''}`,
    },
  },
  {
    type: 'section',
    fields: [
      { type: 'mrkdwn', text: `*上限額:* ${formatCurrency(subsidy.max_amount)}` },
      { type: 'mrkdwn', text: `*締切:* ${formatDate(subsidy.end_date)}` },
    ],
  },
  {
    type: 'actions',
    elements: [{
      type: 'button',
      text: { type: 'plain_text', text: '詳細を見る' },
      url: `${APP_URL}/subsidies/${subsidy.id}`,
    }],
  },
];

バッチ処理

データ同期フロー

1. JグランツAPI一覧取得(acceptance=1)
2. 各補助金の詳細取得(並列、rate limit考慮)
3. データ変換(業種パース、HTMLサニタイズ)
4. Supabase Upsert
5. 新規追加分をSlack通知
6. 募集終了分のis_active更新

Vercel Cron設定

// vercel.json
{
  "crons": [{
    "path": "/api/cron/sync-subsidies",
    "schedule": "0 3 * * 1"  // 毎週月曜3時
  }]
}

ユーティリティ

金額フォーマット

function formatCurrency(amount?: number): string {
  if (!amount) return '金額未定';
  if (amount >= 100000000) return `${(amount / 100000000).toFixed(1)}億円`;
  if (amount >= 10000) return `${(amount / 10000).toFixed(0)}万円`;
  return `${amount.toLocaleString()}円`;
}

残り日数計算

function getDaysRemaining(endDate?: string): number | null {
  if (!endDate) return null;
  const end = new Date(endDate);
  const now = new Date();
  const diff = end.getTime() - now.getTime();
  return Math.ceil(diff / (1000 * 60 * 60 * 24));
}

締切ステータス

function getDeadlineStatus(days: number | null): 'urgent' | 'soon' | 'normal' | 'closed' {
  if (days === null) return 'normal';
  if (days < 0) return 'closed';
  if (days <= 7) return 'urgent';
  if (days <= 30) return 'soon';
  return 'normal';
}

注意事項

  1. APIレート: 公式制限なしだが、並列リクエストは5件程度に抑える
  2. PDF: application_guidelinesは保存しない(数MB/件)
  3. 文字コード: APIはUTF-8、特殊文字エスケープ済み
  4. 日付: ISO 8601形式、タイムゾーンはJST想定
  5. NULL処理: オプショナルフィールドは全てnull許容