企業マッチングシステム - rfdnxbro/trends-laravel GitHub Wiki

企業マッチングシステム(CompanyMatcher)

概要

CompanyMatcherは、スクレイピングした記事データから企業を自動識別するシステムです。ハードコーディングされたマッピングではなく、データベースベースの動的検索により、拡張性と保守性を実現しています。

アーキテクチャ

基本設計

  • Service Layer: App\Services\CompanyMatcher
  • Database: companiesテーブルのJSONカラムによるパターン管理
  • Integration: 各スクレイパー(Qiita、Zenn、はてブ)で統合使用

データベース設計

ALTER TABLE companies ADD COLUMN 
  url_patterns JSON,       -- URLパターンのリスト
  domain_patterns JSON,    -- ドメインパターンのリスト  
  keywords JSON,           -- キーワードのリスト
  zenn_organizations JSON; -- Zenn組織名のリスト

マッチング戦略

CompanyMatcherは以下の優先順序でマッチングを実行します:

1. URLパターンマッチング(最優先)

企業の公式ブログや技術サイトのURLで識別

{
  "url_patterns": [
    "blog.cybozu.io/",
    "speakerdeck.com/cybozuinsideout/",
    "developers.freee.co.jp/"
  ]
}

マッチング例:

  • https://blog.cybozu.io/entry/2025/07/tech-blog → サイボウズ
  • https://developers.freee.co.jp/entry/new-feature → freee

2. ドメインマッチング

企業ドメインでの完全・部分一致

{
  "domain_patterns": [
    "cybozu.io",
    "cybozu.co.jp",
    "freee.co.jp"
  ]
}

3. ユーザー名マッチング

QiitaやZennでの企業アカウント識別

-- 既存カラムを使用
qiita_username VARCHAR(255),
zenn_username VARCHAR(255)

4. キーワードマッチング

記事タイトル内の企業名検出(厳密マッチング)

{
  "keywords": [
    "サイボウズ",
    "cybozu",
    "freee"
  ]
}

5. Zenn組織マッチング

Zenn組織記事の自動検出

{
  "zenn_organizations": [
    "cybozu",
    "freee",
    "cookpad"
  ]
}

マッチング例:

  • https://zenn.dev/cybozu/articles/new-tech → サイボウズ

実装詳細

メインメソッド

public function identifyCompany(array $articleData): ?Company
{
    // 1. URLパターンマッチング
    if (!empty($articleData['url'])) {
        $company = $this->identifyBySpecificUrl($articleData['url']);
        if ($company) return $company;
    }

    // 2. ドメインマッチング  
    if (!empty($articleData['domain'])) {
        $company = $this->identifyByExactDomain($articleData['domain']);
        if ($company) return $company;
    }

    // 3. ユーザー名マッチング
    if (!empty($articleData['platform']) && !empty($articleData['author_name'])) {
        $company = $this->identifyByUsername($articleData['platform'], $articleData['author_name']);
        if ($company) return $company;
    }

    // 4. キーワードマッチング
    $company = $this->identifyByStrictKeywords($articleData);
    if ($company) return $company;

    return null;
}

動的検索の実装

ハードコーディングを排除し、データベースから動的に検索:

private function identifyBySpecificUrl(string $url): ?Company
{
    $companies = Company::whereNotNull('url_patterns')
        ->where('is_active', true)
        ->get();

    foreach ($companies as $company) {
        $patterns = $company->url_patterns ?? [];
        foreach ($patterns as $pattern) {
            if (str_contains($url, $pattern)) {
                return $company;
            }
        }
    }
    
    return null;
}

スクレイパー統合

QiitaScraper

// author_nameを抽出(@記号を削除)
$authorName = ltrim(trim($article['author']), '/@');

// CompanyMatcherで企業を特定
$articleData = array_merge($article, [
    'author_name' => $authorName,
    'platform' => 'qiita',
]);
$company = $companyMatcher->identifyCompany($articleData);

ZennScraper

// author_nameを抽出(会社名を除去)
if (preg_match('/(.+?)(?:in.+)/u', $authorText, $matches)) {
    $authorName = trim($matches[1]);
} else {
    $authorName = $authorText;
}

$articleData = array_merge($article, [
    'author_name' => $authorName,
    'platform' => 'zenn',
]);
$company = $companyMatcher->identifyCompany($articleData);

HatenaBookmarkScraper

$articleData = array_merge($entry, [
    'platform' => 'hatena_bookmark',
]);
$company = $companyMatcher->identifyCompany($articleData);

設定・管理

企業データ設定例

// サイボウズの設定例
Company::where('name', 'サイボウズ')->update([
    'url_patterns' => [
        'speakerdeck.com/cybozuinsideout/',
        'blog.cybozu.io/'
    ],
    'domain_patterns' => [
        'cybozu.io',
        'cybozu.co.jp'
    ],
    'keywords' => [
        'サイボウズ',
        'cybozu'
    ],
    'zenn_organizations' => [
        'cybozu'
    ]
]);

新企業追加手順

  1. 企業基本情報の追加

    Company::create([
        'name' => '新企業名',
        'domain' => 'example.com',
        'is_active' => true
    ]);
    
  2. マッチングパターンの設定

    $company->update([
        'url_patterns' => ['tech.example.com/'],
        'keywords' => ['新企業名', 'example']
    ]);
    
  3. 動作確認

    php artisan scrape:platform qiita --dry-run
    

ログとモニタリング

ログ出力

マッチング成功時に詳細ログを出力:

Log::info("特定URLベースで会社を特定: {$company->name}", [
    'url' => $articleData['url'],
    'article_title' => $articleData['title'] ?? null,
]);

モニタリング指標

  • マッチング率: 全記事に対する企業特定成功率
  • マッチング手法別統計: URL・ドメイン・キーワード別の成功数
  • 精度: 誤判定の発生率

パフォーマンス考慮

最適化ポイント

  1. 優先順序: 高精度なURLパターンマッチングを最優先
  2. 早期リターン: マッチング成功時に即座にreturn
  3. インデックス: JSONカラムにGINインデックス適用を検討
  4. キャッシュ: 頻繁にアクセスされる企業データのキャッシュ

スケーラビリティ

  • 企業数増加に対する線形スケーラビリティ
  • パターン数増加時のパフォーマンス影響は軽微
  • 必要に応じてElasticsearchへの移行も可能

テスト戦略

ユニットテスト

public function test_URLパターンで企業を正しく特定できる()
{
    $company = Company::factory()->create([
        'name' => 'テスト企業',
        'url_patterns' => ['blog.test.com/']
    ]);

    $matcher = new CompanyMatcher();
    $result = $matcher->identifyCompany([
        'url' => 'https://blog.test.com/article/123'
    ]);

    $this->assertEquals($company->id, $result->id);
}

統合テスト

実際のスクレイピングと組み合わせたテスト:

php artisan test --filter CompanyMatcherIntegrationTest

今後の拡張

機械学習による精度向上

  • 記事内容からの企業推定
  • ユーザー投稿パターンの学習
  • 誤判定の自動修正

管理UIの実装

  • 企業マッチングパターンのWeb管理画面
  • マッチング精度のダッシュボード
  • 新企業の半自動追加機能

外部API連携

  • 企業データベースAPIとの連携
  • ソーシャルメディアAPIとの統合
  • 技術ブログRSSフィードの活用

関連ドキュメント