なぜ作ったのか
フリーランスを始めたばかりの頃、私がいちばん苦しかったのは「この案件を受けていいのか分からない」ことでした。
単価は相場に合っているのか。納期は現実的か。「初心者歓迎」「まずは低単価で」「継続あり」といった言葉の裏に何が隠れているのか。判断材料がないまま応募して消耗する、ということを何度も繰り返しました。
あの頃の自分と、今まさに同じ場所で迷っているフリーランスを支援したい ― その思いから、フリーランス案件をワンクリックで診断するChrome拡張「Freelance Project Fit AI」 を個人開発しました。この記事はそのプロダクト紹介と、実装の要点のまとめです。
作ったもの
案件詳細ページを開いて拡張のボタンを押すだけで、AIが以下を返します。
| 機能 | 内容 |
|---|---|
| 案件スコアリング | 100点満点で 応募 / 要確認 / 見送り を提示 |
| 評価の内訳 | スキル適合度・予算妥当性・納期リスク・クライアントリスク・初心者/実績者適合の5軸を理由付きで |
| 地雷案件の検知 | 危険ワードを"文脈込み"で判定(low / medium / high、single wordで断罪しない) |
| 提案文の材料出し | 刺さる冒頭・実績の見せ方・納期の伝え方・見積もり・すべき質問 |
| 応募前チェックリスト | 確認すべき3〜5項目 |
| プロフィール連動 | 得意領域・実績・希望単価・稼働時間に応じた個別診断 |
こだわったのは「安売りさせない」ことと「良い案件を誤って地雷扱いしない」ことです。教育目的で丁寧に書かれた案件まで危険判定してしまっては、支援ツールとして本末転倒だからです。
アーキテクチャ
ブラウザ拡張にAPIキーを置けないため、自前のAPIプロキシを挟んでいます。
構成は Chrome拡張(Manifest V3)+ Express製のAPIプロキシ + OpenAI のモノレポです。
実装のポイント
① 本文抽出は「クリック後・可視テキストだけ」
常時監視ではなく activeTab + scripting を使い、ユーザー操作のときだけ現在タブを読み取ります。ノイズ要素を除去し16,000字に切り詰めて送信します。
const clone = document.body.cloneNode(true);
['script', 'style', 'nav', 'footer', 'header', 'iframe', '[aria-hidden="true"]']
.forEach((sel) => clone.querySelectorAll(sel).forEach((el) => el.remove()));
const text = clone.innerText.replace(/\s+/g, ' ').trim().slice(0, 16000);
② APIキーは拡張に置かない
拡張は自前エンドポイントを叩くだけで、OpenAIのキーはサーバーの環境変数にのみ存在します。拡張のJavaScriptに秘密情報を含めないための基本方針です。
③ 出力を JSON Schema (strict) で固定する
診断結果をそのままUIカードに流し込むため、出力構造の安定が必須でした。response_format: json_schema を strict: true で使い、スコア・判定・危険シグナル・提案文・チェックリストの型を強制しています。
response_format: {
type: 'json_schema',
json_schema: {
name: 'freelance_project_fit_analysis',
strict: true,
schema: analysisSchema,
},
}
strict: true では全オブジェクトに additionalProperties: false と required の全項目列挙が必須です。1箇所でも漏らすとスキーマが受け付けられないので、繰り返し部分は関数で組み立てるのが安全でした。
④ プロンプトで日本語出力と文脈判定を担保
JSONのキーやenum値(decision, riskLevel)は英語のまま、ユーザー向け文はすべて日本語に。危険ワードは文脈で評価し、提案文は完成文ではなく"材料"として出すよう指示しています。
⑤ 公開エンドポイントをリポジトリに書かない
当初は manifest.json の host_permissions にデプロイ先URLを直書きしていましたが、それではリポジトリを公開した瞬間にエンドポイントも晒されます。manifest にはローカル開発用のホストだけを残し、本番URLはオプション画面で各自が設定する方式にしました。
"host_permissions": ["http://localhost/*", "http://127.0.0.1/*"],
"optional_host_permissions": ["https://*/*"]
保存時に chrome.permissions.request() でそのオリジンへの権限を実行時に要求します。このAPIはユーザー操作起点でしか呼べないので、保存ボタンのクリックハンドラ内で呼ぶ必要があります。
const pattern = `${new URL(apiUrl).origin}/*`;
if (await chrome.permissions.contains({ origins: [pattern] })) return true;
return chrome.permissions.request({ origins: [pattern] });
⑥ コスト暴走を止めるレート制限
OpenAIを叩くエンドポイントを公開する以上、怖いのは大量リクエストによる課金です。IP単位でインメモリの固定ウィンドウ制限をかけています(既定:60秒に10回、1日200回)。上限に達したら 429 と Retry-After を返します。
ハマりどころは、Render などリバースプロキシ配下だと req.ip がプロキシのIPになる点です。app.set('trust proxy', 1) を入れないと全ユーザーが1つのバケットを共有してしまいます。この方式はプロセスのメモリ依存なので、複数インスタンスにスケールしたら Redis 等への置き換えが前提です。
使い方(ローカル)
-
apps/apiで.env(OPENAI_API_KEY/OPENAI_MODEL)を設定し起動 -
chrome://extensions→「パッケージ化されていない拡張機能を読み込む」→apps/extension - オプション画面でプロフィールと API URL を保存
- 案件詳細ページで「このページの案件を診断する」をクリック
現状の制約と、これから直すこと
-
CORSが
*のまま …ALLOWED_EXTENSION_ORIGINを自分のchrome-extension://<id>に絞る - エンドポイントに認証がない … 共有シークレット方式も検討しましたが、拡張として配布した時点で鍵はユーザーの手元に渡るため per-user 認証にはなりません。現状は「URLの秘匿+レート制限」で凌いでいます
- レート制限がインメモリ … 複数インスタンスにスケールしたら共有ストアへ
- プロフィールの自動学習 … いまは手入力のみ
- リクエストログ … どんな案件で診断を外したかの記録が精度改善に必要
フィードバックをください
私ひとりの案件経験では診断の観点が偏ります。特に「良い案件を地雷判定した/その逆」の実例と、「こういう観点も診てほしい」(契約形態、著作権の扱い、テストの有無など)が集まると、次のバージョンで直せます。
GitHub の Issue に投げていただければ必ず目を通します。コメントでももちろん歓迎です。
おわりに
「あの頃これが欲しかった」を形にできた個人開発でした。同じ道を歩くフリーランスの判断の一助になれば嬉しいです。いただいたフィードバックを反映したら、この記事も更新します。