# 案件スクレイパー（Job Scraper）

## 概要

クラウドソーシング系のサービスから IT / システム開発系の案件を横断スクレイピングし、AI（Anthropic Claude）で自分のスキルとの適合度スコアを付けたり、提案文を下書き生成したりできる機能。

## 対応プラットフォーム

| プラットフォーム | スラッグ | スクレイパー | 備考 |
|-----------------|---------|-------------|------|
| ランサーズ | `lancers` | `LancersScraperService` | システム開発カテゴリ全般 |
| クラウドワークス | `crowdworks` | `CrowdWorksScraperService` | 同上 |
| ジモティー | `jmty` | `JmtyJobScraperService` | 地域案件 |

## 使い方

- **URL**: `/job-scraper`
- ログイン必須

### 基本フロー

1. プラットフォーム（複数選択可）・キーワードを指定して「スクレイピング実行」
2. 非同期ジョブ（`JobScraperScrapeJob`）がバックグラウンドで各サイトを走査し、進捗バーをポーリング表示
3. 取得した案件は `job_listings` テーブルに保存される（既存は更新、新規は追加）
4. 一覧画面からフィルタ・ソート・お気に入り・除外キーワード登録
5. 「AI一括分析」で選択案件に適合度スコア（0〜100）と寸評を付与
6. 詳細モーダルから AI 詳細分析 / 提案文生成

### フィルタ

| フィルタ | 説明 |
|---------|------|
| プラットフォーム | 複数選択、件数バッジ表示 |
| キーワード | タイトル・本文・カテゴリの LIKE 検索 |
| 案件タイプ | `work_type`（固定報酬／時間単価など）を複数選択 |
| 報酬下限・上限 | `budget_max >= min` / `budget_min <= max` |
| 提案数上限 | `proposals_count <= max`（NULL は通す） |
| タブ | 全件 / お気に入り / 閲覧履歴 |
| ソート | 日付 / AIスコア / 報酬 / 提案数 |
| 除外キーワード | `job_scraper_exclude_keywords` テーブル。タイトル・本文の NOT LIKE |

フィルタ条件は 30 日間クッキーに保存され、次回アクセス時に復元される（`/reset-filters` でクリア）。

## エンドポイント

| メソッド | URL | 名前 | スロットル |
|----------|-----|------|-----------|
| GET | `/job-scraper` | `job_scraper.index` | - |
| GET | `/job-scraper/reset-filters` | `job_scraper.reset_filters` | - |
| POST | `/job-scraper/scrape` | `job_scraper.scrape` | `10/1min` |
| GET | `/job-scraper/scrape/progress/{jobId}` | `job_scraper.scrape.progress` | - |
| POST | `/job-scraper/ai-analyze` | `job_scraper.ai_analyze` | `10/1min` |
| GET | `/job-scraper/api/detail/{id}` | `job_scraper.api.detail` | `60/1min` |
| POST | `/job-scraper/api/favorite/{id}` | `job_scraper.api.favorite` | - |
| POST | `/job-scraper/api/ai-detail/{id}` | `job_scraper.api.ai_detail` | `30/1min` |
| POST | `/job-scraper/api/proposal/{id}` | `job_scraper.api.proposal` | `30/1min` |
| POST | `/job-scraper/api/exclude-keyword` | `job_scraper.api.exclude_keyword.store` | - |
| DELETE | `/job-scraper/api/exclude-keyword/{id}` | `job_scraper.api.exclude_keyword.destroy` | - |

## データベース

### `job_listings`（`2026_04_12_230000_create_job_listings_table.php`）

| カラム | 型 | 説明 |
|--------|-----|------|
| `platform` | string(30) | `lancers` / `crowdworks` / `jmty` |
| `external_id` | string? | サイト側の案件 ID |
| `title` | string(500) | 案件タイトル |
| `description` | text? | 本文（詳細フェッチ時にキャッシュ） |
| `category` | string? | カテゴリ |
| `budget_min` / `budget_max` | int? | 予算（円）下限・上限 |
| `budget_text` | string? | 表示用予算テキスト |
| `work_type` | string? | 固定報酬／時間報酬など |
| `url` | string(1024) | 案件 URL |
| `client_name` | string? | 発注者 |
| `proposals_count` | int? | 提案数 |
| `status` | string | `open` / `closed` |
| `location` | string? | 地域（ジモティー） |
| `ai_analysis` | text? | AI 分析結果 |
| `ai_score` | int? | AI 適合スコア（0–100） |
| `posted_at` | timestamp? | 掲載日 |
| `scraped_at` | timestamp? | スクレイピング日時 |

追加マイグレーション `2026_04_13_173802_add_favorite_and_viewed_to_job_listings_table.php` で `is_favorite`, `viewed_at` を付与。

UNIQUE: `(platform, external_id)` / INDEX: `ai_score`, `posted_at`

### `job_scraper_exclude_keywords`

| カラム | 型 | 説明 |
|--------|-----|------|
| `keyword` | string | 除外キーワード（全サイト共通） |

## ファイル構成

```
app/
├── Http/Controllers/Job/
│   └── JobScraperController.php    # すべてのエンドポイント
├── Services/Job/
│   ├── LancersScraperService.php   # ランサーズ用
│   ├── CrowdWorksScraperService.php
│   ├── JmtyJobScraperService.php   # ジモティー用
│   └── JobAiAnalysisService.php    # Claude による分析・提案文生成
├── Jobs/Job/
│   └── JobScraperScrapeJob.php     # 非同期スクレイピング + 進捗キャッシュ
└── Models/Job/
    ├── JobListing.php
    └── JobScraperExcludeKeyword.php

resources/
├── js/job/scraper.js
├── css/job/scraper.css
└── views/job/scraper/
    ├── index.blade.php
    └── parts/listing_card.blade.php

tests/Feature/JobScraperTest.php
```

## 非同期スクレイピング

`POST /job-scraper/scrape` は `JobScraperScrapeJob` をキューに投入して即座に `job_id`（UUID）を返す。フロントエンドは `GET /job-scraper/scrape/progress/{jobId}` をポーリングして以下の JSON を取得する。

```json
{
  "status": "running",  // queued, running, completed, failed
  "current": 2,
  "total": 3,
  "percent": 66,
  "platform": "crowdworks",
  "message": "クラウドワークスをスクレイピング中..."
}
```

進捗データは `Cache` に `JobScraperScrapeJob::CACHE_PREFIX + jobId` のキーで保存される（TTL: `CACHE_TTL`）。

## セキュリティ

- `fetchDetail()` はプラットフォーム毎の許可ドメイン（lancers.jp / crowdworks.jp / jmty.jp）と `url` のホストを照合する **SSRF 防御** を実装（`JobScraperController@fetchDetail`, 442-451 行）。
- API エンドポイントは個別にレートリミット済み。
- すべてのエンドポイントは `auth` 必須。

## レビュー時のメモ

- `index()` の除外キーワード適用は各キーワードごとに WHERE のクロージャをネストする。キーワード数が多い場合は SQL 肥大化の懸念あり — 将来的には `NOT REGEXP` やフルテキストインデックスへの置き換えを検討。
- スクレイピングの User-Agent はハードコード。ブロックされた際は `*ScraperService` の定数 `USER_AGENT` を差し替える。
- AI 一括分析は `limit(20)` に絞られている（API コスト／レイテンシのため）。

## 必要な環境変数

| 変数名 | 説明 |
|--------|------|
| `ANTHROPIC_API_KEY` | AI スコアリング・提案文生成に必須 |
| `QUEUE_CONNECTION` | 非同期スクレイピング用（`redis` / `database` 推奨） |
