プロジェクト

全般

プロフィール

SMART-CONTACT Chrome拡張機能 要件定義書

項目 内容
プロジェクト名 SMART-CONTACT
ドキュメント種別 Chrome拡張機能 機能要件
バージョン 1.1
作成日 2026-02-17
ステータス 開発中 (develop ブランチ)

1. 概要

SMART-CONTACT Chrome拡張機能は、Webサイトのお問い合わせフォームへの自動入力・送信を行うBtoB営業支援ツールである。事前に登録したテンプレート情報をもとに、複数のURLに対して順次フォーム入力・送信を実行し、営業活動における問い合わせ業務を自動化する。

拡張機能ダウンロード

1.1 対象ユーザー

  • 法人営業担当者(BtoB営業)
  • マーケティング担当者
  • 事業開発担当者

1.2 解決する課題

  • 手作業による大量のお問い合わせフォーム入力の工数削減
  • 送信先の重複管理・スケジュール管理の自動化
  • ライセンス管理によるSaaS型ビジネスモデルの実現

1.3 技術構成

レイヤー 技術
拡張機能フレームワーク WXT 0.20.17 (Manifest V3)
拡張機能 UI React 19 + TypeScript 5.9
スタイリング Tailwind CSS 4 + PostCSS
ビルドツール Vite 7
認証 Laravel Sanctum 4.0 (APIトークン)

1.4 コンポーネント構成

┌──────────────────────────────────────────┐
│          Chrome Browser Extension         │
│  ┌───────────┐ ┌──────────┐ ┌──────────┐ │
│  │ Options   │ │Background│ │ Content  │ │
│  │ Dashboard │ │ Service  │ │ Script   │ │
│  │ (React)   │ │ Worker   │ │          │ │
│  └─────┬─────┘ └────┬─────┘ └────┬─────┘ │
│        └──────┬──────┘            │       │
│               │ chrome.runtime    │       │
│               ├───────────────────┘       │
└───────────────┼───────────────────────────┘
                │ HTTP (Sanctum Token)
                ▼
         Laravel 12 Backend
コンポーネント 役割
Options Dashboard React製のUI画面。ログイン、テンプレート編集、実行、設定、アカウント管理を提供
Background Service Worker バッチ実行エンジン。URLリストの逐次処理、タブ管理、ライセンスチェック、ログ記録を担当
Content Script 各Webページに注入されるスクリプト。フォーム検出、フィールドマッチング、自動入力、送信ボタン押下を実行

2. 認証・ライセンス管理

FR-AUTH-001: ログイン

項目 内容
説明 メールアドレスとパスワードでログインし、APIトークンを取得する
入力 email (必須), password (必須)
処理 Sanctumトークン発行、既存トークン削除、ユーザー情報返却、チケット情報取得
出力 token, user (id, name, email, is_white_user, contract_period, plan_type, ticket_balance, monthly_free_limit, monthly_free_remaining, subscription_expires_at, is_banned)
保存先 Chrome Storage Local (access_token, user_info)

APIエンドポイント: POST /api/login

リクエスト:

{
  "email": "user@example.com",
  "password": "password"
}

レスポンス (200):

{
  "status": "success",
  "token": "1|abc123...",
  "user": {
    "id": 1,
    "name": "ユーザー名",
    "email": "user@example.com",
    "is_white_user": true,
    "contract_period": "2026/02/07 〜 無期限 (Premium)",
    "plan_type": "subscription",
    "ticket_balance": 0,
    "monthly_free_limit": 10,
    "monthly_free_remaining": 10,
    "subscription_expires_at": null,
    "is_banned": false
  }
}

追加フィールド (v1.1):

  • plan_type: "subscription" (ホワイトユーザー) / "ticket" (一般ユーザー)
  • ticket_balance: 購入チケット残枚数
  • monthly_free_limit: 月間無料URL枠 (SystemSettingから)
  • monthly_free_remaining: 今月の無料枠残数
  • subscription_expires_at: サブスク期限 (ISO 8601 / null)
  • is_banned: BAN状態

ログイン成功後、拡張機能はこれらの情報を user_info として Chrome Storage Local に保存し、以後の認証チェックやURL件数制限をローカルで行う (APIコール不要)。また、保留中の同期データ (pending_sync_user_{userId}) があれば自動リトライする。

レスポンス (401):

{
  "status": "error",
  "message": "メールアドレスまたはパスワードが間違っています。"
}

契約期間フォーマット:

ユーザー種別 フォーマット
ホワイトユーザー {created_at} 〜 無期限 (Premium)
トライアル {created_at} 〜 {trial_ends_at} (Trial)
未契約 未契約

FR-AUTH-002: ライセンスチェック

項目 内容
説明 実行前にデバイスのライセンス状態を確認する
入力 device_uuid (必須), device_name (任意)
判定ロジック 0. BANユーザー (banned_at IS NOT NULL) → 拒否 (account_banned)
1. ホワイトユーザー → 無条件で許可 (white_unlimited)
2. 既存登録デバイス → 許可 + last_active_at 更新
3. 新規デバイス + 枠あり → デバイス登録 + 許可
4. 新規デバイス + 枠なし → 拒否 (device_limit_reached)

優先度0: BANチェック

BANユーザー (banned_at IS NOT NULL) の判定は全ての他チェックより先に実行される。BANされたユーザーはホワイトユーザーであっても利用不可となる。

APIエンドポイント: POST /api/check-license

リクエスト:

{
  "device_uuid": "550e8400-e29b-41d4-a716-446655440000",
  "device_name": "Chrome on MacOS"
}

レスポンス (200 - 許可):

{
  "status": "allowed",
  "plan": "white_unlimited",
  "message": "ホワイトユーザー認証OK"
}

レスポンス (403 - 拒否):

{
  "status": "denied",
  "reason": "device_limit_reached",
  "current_usage": 2,
  "limit": 2,
  "message": "利用台数の上限(2台)に達しています。管理画面で整理するか、追加ライセンスを購入してください。"
}

FR-AUTH-003: デバイスID管理

項目 内容
説明 拡張機能初回起動時にUUID v4を生成し、ブラウザに紐付ける。ユーザー画面でIDとブラウザの再紐付けも担保する
生成タイミング 初回起動時 (存在しない場合のみ)
生成方法 crypto.randomUUID()
保存先 Chrome Storage Local (device_id)
サーバー登録 user_devices テーブルに (user_id, device_uuid) として登録
export const getDeviceId = async (): Promise<string> => {
  const result = await chrome.storage.local.get(['device_id']);
  if (result.device_id) return result.device_id;

  // UUIDを生成して保存
  const newId = crypto.randomUUID();
  await chrome.storage.local.set({ device_id: newId });
  return newId;
};

FR-AUTH-004: ログアウト

項目 内容
説明 現在のデバイスのトークンを無効化し、ローカル情報を削除する
処理 サーバー側: 現在のアクセストークン削除
クライアント側: access_token, user_info を Chrome Storage から削除

APIエンドポイント: POST /api/logout

レスポンス (200):

{
  "message": "ログアウトしました"
}

FR-AUTH-005: ユーザー種別

種別 is_white_user デバイス制限 契約期間
ホワイトユーザー (Premium) true 無制限 無期限
一般ユーザー (Standard) false stripe_subscription_quantity 台まで (デフォルト1) trial_ends_at またはStripeサブスクリプション

3. テンプレート管理

FR-TPL-001: テンプレートデータ構造

interface Template {
  id: string;
  name: string;             // テンプレート管理名

  // 送信者情報
  companyName: string;      // 会社名
  companyKana: string;      // 会社名カナ
  department: string;       // 部署名

  // 氏名 (分割入力)
  lastName: string;         // 姓
  firstName: string;        // 名
  lastKana: string;         // セイ (カタカナ)
  firstKana: string;        // メイ (カタカナ)
  lastNameHira: string;     // せい (ひらがな)
  firstNameHira: string;    // めい (ひらがな)

  // 連絡先
  email: string;            // メールアドレス
  tel1: string;             // 電話番号 (市外局番)
  tel2: string;             // 電話番号 (市内局番)
  tel3: string;             // 電話番号 (加入者番号)

  // 住所
  zip1: string;             // 郵便番号 (3桁)
  zip2: string;             // 郵便番号 (4桁)
  prefecture: string;       // 都道府県
  city: string;             // 市区町村
  addressOther: string;     // 番地・建物名

  siteUrl: string;          // WebサイトURL

  // メッセージ
  subject: string;          // 件名
  body: string;             // 本文
}

全25フィールド。氏名はフォームの姓名分割入力に対応するため lastName / firstName に分割し、カタカナ (lastKana / firstKana) とひらがな (lastNameHira / firstNameHira) も個別に保持する。電話番号は市外局番 (tel1) / 市内局番 (tel2) / 加入者番号 (tel3) の3分割、郵便番号は3桁 (zip1) / 4桁 (zip2) の2分割とする。

FR-TPL-002: テンプレートCRUD操作

操作 説明
新規作成 空のテンプレートを作成
編集 既存テンプレートの各フィールドを編集
複製 既存テンプレートをコピーして新規作成
削除 テンプレートを削除 (最低1件は残す必要あり)
保存先 Chrome Storage Local (templates)

FR-TPL-003: 差し込みタグ

本文内で以下のタグを使用でき、実行時にテンプレート値に置換される。

タグ 置換値
{会社名} companyName
{会社名カナ} companyKana
{部署} department
{担当者名} lastName + firstName
{担当者カナ} lastKana + firstKana
{サイトURL} siteUrl
{メール} email
{電話番号} tel1-tel2-tel3
{郵便番号} zip1-zip2
{住所} prefecture + city + addressOther

4. フォーム自動入力エンジン (Content Script)

FR-FILL-001: フォーム検出

項目 内容
対象 全Webページ (*://*/*)
Google Forms docs.google.com/forms は専用ロジックで処理 (質問ラベルベースのマッチング + カスタムUI要素対応)
有効なフォーム判定条件 送信ボタンが存在 AND (textarea OR select OR 入力欄2個以上)
入力欄カウント除外 検索ボックス (search, keyword, q, query)、header/footer/nav内の入力欄

フォーム準備待機: 最大4秒 (200ms x 20回ポーリング)。textarea が存在するか、visible な input が2個以上になるまで待機する。

async function waitForFormReady() {
    for (let i = 0; i < 20; i++) {
        const hasTextarea = Array.from(document.querySelectorAll('textarea')).some(el => isVisible(el));
        const inputs = Array.from(document.querySelectorAll('input:not([type="hidden"])')).filter(el => isVisible(el));
        if (hasTextarea || inputs.length >= 2) return;
        await new Promise(r => setTimeout(r, 200));
    }
}

有効フォーム判定ロジック (detectValidFormStructure):

async function detectValidFormStructure(): Promise<boolean> {
    const submitBtn = findSubmitButton();
    const hasTextarea = document.querySelectorAll('textarea').length > 0;
    const hasSelect = document.querySelectorAll('select').length > 0;

    const inputs = document.querySelectorAll(
      'input:not([type="hidden"]):not([type="submit"]):not([type="checkbox"]):not([type="radio"]), textarea'
    );
    let realInputs = 0;

    inputs.forEach(input => {
        const el = input as HTMLInputElement;
        if (!isVisible(el)) return;
        if (el.type === 'search' || /search|検索|keyword/i.test(el.placeholder || '')
            || /search|検索/i.test(el.name || '')) return;
        if (el.closest('header, footer, .search, nav')) return;
        realInputs++;
    });

    return !!submitBtn && (hasTextarea || hasSelect || realInputs >= 2);
}

FR-FILL-002: フィールドマッチングロジック

フォームの各入力欄に対して、以下の優先度でフィールドを特定する。

  1. 構造的マッチング (fillByStructure): 親要素内の入力欄数と周辺テキストからグループ単位で判定
    • 2入力グループ → 姓名分割、郵便番号分割、メール+確認、氏名+カナ
    • 3入力グループ → 電話番号3分割
    • 1入力 + textarea → 本文
  2. 属性ベースマッチング (performAutoFill): name属性、id、placeholder、周辺テキストのスコアリング
    • 100点: name/id属性がパターンに一致
    • 80点: 周辺テキスト (label、placeholder、aria-label、親要素テキスト) がパターンに一致
    • 追加ボーナス: email型inputに +80点、メール確認フィールドに +200点
    • 50点以上で入力対象として採用

構造的マッチング詳細:

グループ入力数 周辺ラベル マッピング
2個 氏名/名前/Name [0]=lastName, [1]=firstName
2個 フリガナ/カナ [0]=lastKana, [1]=firstKana
2個 ひらがな [0]=lastNameHira, [1]=firstNameHira
2個 郵便/〒 [0]=zip1, [1]=zip2 (入力後1.5秒待機)
2個 メール/mail [0]=email, [1]=email (確認用)
2個 (2番目にkana/furi/カナ) [0]=lastName firstName, [1]=lastKana firstKana
3個 電話/tel [0]=tel1, [1]=tel2, [2]=tel3
1個 (textarea) 本文/内容/詳細/message/body [0]=body
1個 氏名/名前/Name [0]=lastName firstName (結合)
1個 フリガナ/カナ [0]=lastKana firstKana (結合)

FR-FILL-003: フィールドマッピング一覧

フィールド キー 検出パターン (正規表現) 除外パターン
郵便番号 zip /zip|郵便|postal|〒/i /1|2/i
本文 body /body|content|message|text|inquiry|本文|内容|詳細|備考|お問い合わせ内容/i -
会社名 company /会社|貴社|法人|company|御社|所属/i /kana|ふりがな|フリガナ/i
部署 department /部署|部門|department|事業部/i -
lastName /姓|苗字|名字|last.?name|name1/i /kana|hira|furi|カナ|フリガナ|first|名$|ビル|建物|bldg/i
firstName /名|first.?name|name2|メイ/i /last|姓|kana|hira|カナ|フリガナ|mail|email|confirm|check|確認|ビル|建物|bldg/i
氏名(結合) fullName /氏名|フルネーム|お名前|^名前|name/i /kana|カナ|フリガナ|ふりがな|last|first|姓|名$|name[12]|ビル|建物|bldg/i
カナ(結合) fullKana /フリガナ|ふりがな|カナ|かな|^kana/i /セイ|メイ|last|first/i
セイ lastKana /セイ|フリガナ1|カナ1|kana1/i /メイ|名/i
メイ firstKana /メイ|フリガナ2|カナ2|kana2/i /セイ|姓/i
メール確認 emailConf /確認|confirm|verify|再入力|mail.*(confirm|check)|メール.*(再|確認)/i -
メール email /mail|メール|アドレス/i /confirm|check|再|確認/i
電話 tel /tel|phone|電話|携帯/i /1|2|3|zip|fax/i
都道府県 pref /pref|県|都|道|府/i - (select要素)
市区町村 city /市区|町村|city|municipality/i /zip|郵便|postal|code|〒|地域|area|ビル|building|bldg|street|番地/i
番地 street /番地|street/i /zip|郵便|postal|code|〒|地域|area|ビル|building|bldg|市区|町村|city/i
住所(結合) addr /住所|所在地/i /zip|郵便|postal|code|〒|地域|area|ビル|building|bldg|市区|町村|city|番地/i

住所フィールドの結合ロジック:

  • 都道府県が既にselect入力済み → city + addressOther を入力
  • 都道府県が未入力 → prefecture + city + addressOther を結合入力
  • 既存値がある場合 → 既存値 + addressOther を追記

FR-FILL-004: 補助入力処理

対象 処理内容
selectボックス 都道府県は値マッチング (テキスト一致 or 都道府県末尾除去で一致)。その他のselectは「その他」→「ビジネス/協業」系→最後のオプション順に選択
ラジオボタン 必須の場合、「その他」を優先選択。見つからない場合は最後の選択肢
チェックボックス 「同意/規約」系は自動チェック。必須の場合も自動チェック
プライバシーポリシー同意 role="checkbox", role="switch", 通常のcheckboxすべて対応

selectボックスの選択優先度:

  1. 都道府県selectの場合: テンプレートの prefecture 値でマッチング → 末尾の「都道府県」を除去してマッチング → 「その他/other」
  2. その他のselectの場合:
    1. その他 / Other / other (完全一致)
    2. /その他|other|お問い合わせ/i (部分一致)
    3. /協業|ビジネス|business|partner|提携|案件|事業|営業|内容|content|subject/i
    4. 最後のオプション (フォールバック)

ラジオボタン / チェックボックスの処理:

  • プライバシーポリシー判定: /同意|規約|policy|term|承諾|確認/i
  • プライバシー系の場合: /同意|承諾|規約|agree|確認/i に一致する選択肢を優先
  • 必須判定: required 属性、aria-required="true"、周辺テキストに /必須|required|[*]|\*/i、親要素クラスに require.required / .req / img[alt*="必須"] の存在

ARIA対応:

  • role="radiogroup" 内の role="radio" 要素に対応
  • role="checkbox" 要素に対応
  • role="switch" 要素に対応 (プライバシーポリシー同意)
  • aria-checked, aria-required, aria-labelledby 属性を参照

FR-FILL-005: 値の設定方法

  • React等のフレームワーク対応: Object.getOwnPropertyDescriptor(prototype, "value").set を使用したネイティブセッター呼び出し
  • イベント発火: input, change, blur, focus イベントをバブリング付きで発火
  • 入力済みフィールドのハイライト: 背景色を rgba(212, 175, 55, 0.15) に変更
function setValue(el: HTMLElement, val: string) {
  if (el.tagName === 'SELECT') {
    const select = el as HTMLSelectElement;
    const match = Array.from(select.options).find(o => o.text.includes(val) || o.value === val);
    if (match) select.value = match.value;
  } else {
      const input = el as HTMLInputElement | HTMLTextAreaElement;

      const prototype = el.tagName === 'TEXTAREA'
        ? window.HTMLTextAreaElement.prototype
        : window.HTMLInputElement.prototype;

      const nativeSetter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;

      if (nativeSetter && nativeSetter !== Object.getOwnPropertyDescriptor(input, "value")?.set) {
          nativeSetter.call(input, val);
      } else {
          input.value = val;
      }
  }
  ['input','change','blur', 'focus'].forEach(ev => el.dispatchEvent(new Event(ev, { bubbles: true })));
  el.style.backgroundColor = 'rgba(212, 175, 55, 0.15)';
}

FR-FILL-006: ページ遷移・探索ロジック

フォームが見つからない場合の探索処理(優先度順):

  1. サイトマップ探索 (findLinksFromSitemap): /sitemap.xml, /sitemap_index.xml から問い合わせページURLを検出
    • 検索キーワード: /contact/i, /inquiry/i, /form/i, /otoiawase/i, /お問い合わせ/
  2. リンク探索 (analyzeSiteStructure): ページ内のリンクをスコアリング
    • 200点: サイトマップ内のリンク
    • 100点: リンクテキストに /お問い合わせ|フォーム|相談|入力|Contact|Inquiry|Mail/i
    • 70点: hrefに /contact|inquiry|form|otoiawase|support/i
    • +50点: header/nav/footer内のリンク
    • +20点: クラス名に btn または primary を含む
    • -50点: /会社概要|about|company/i
    • -150点: /faq|question|privacy|policy|guideline|login|recruit|採用/i
    • -1000点: パスが / (ルートページへのリンク)
  3. iframe検出: form/contact/inquiry/entry を含むiframeのsrcへ遷移
  4. メールアドレス抽出 → mailto:リンク蓄積: ページ内のメールアドレスを正規表現 /[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}/ で抽出し、collectedEmails 配列に蓄積する。バッチ完了後に mailto: リンクを一括生成
  5. 手詰まり: フォーム・リンク・メールアドレスすべて見つからない場合はエラー (NO_FORM_FOUND)

5. バッチ実行エンジン (Background Service Worker)

FR-EXEC-001: 実行フロー

1. ローカル認証チェック (Chrome Storage の user_info を参照)
   - is_banned=true → 利用拒否
   - plan_type="subscription" → subscription_expires_at 期限チェック
   - plan_type="ticket" → ticket_balance + monthly_free_remaining > 0 を確認
   - is_white_user=true → 無制限許可
2. 設定読み込み (Chrome Storage)
3. 稼働制限チェック (曜日・時間)
4. URLリストのループ処理:
   a. 除外ドメインチェック
   b. 重複送信チェック
   c. タブ作成 → ページ読み込み待機
   d. ページ解析待機 (pageLoadDelay秒)
   e. コンテンツスクリプトへメッセージ送信 (FILL_FORM)
   f. レスポンス処理:
      - LINKS_FOUND / MOVE_PAGE → 再帰的にページ遷移
      - FOUND_MAIL_SEND_SERVER → メールアドレス抽出成功 → collectedEmails配列に蓄積
      - CAPTCHA_DETECTED → 保留キューへ追加 (テストモードも同一動作)
      - BUTTON_CLICKED → 送信完了待機 (本番モードのみ)
        → CHECK_PAGE_STATUS で完了確認
        → 確認画面の場合は確定ボタン押下
      - TEST_COMPLETED → テストモード時、送信ボタン非押下で完了
      - ERROR → 失敗記録
   g. 送信履歴記録 (成功時)
   h. トランザクション記録 (url_hash, status, type, timestamp)
   i. 1秒待機 → 次のURL
5. 蓄積メールがあれば mailto:リンクを生成 (BCC一括・メーラー起動)
6. トランザクション記録をUIに送信 (EXECUTION_SYNC_READY)
7. UIが POST /api/sync-execution でサーバーと同期 → チケット消費
8. 保留キューの通知 (CAPTCHA件数)
9. 実行履歴の保存

v1.1 変更点: ステップ1はサーバーAPIコール (POST /api/check-license) からローカルストレージ参照に変更。ステップ5でmailtoリンク生成、ステップ6-7でバッチ同期を追加。

レスポンスステータス一覧:

ステータス 意味 Background側の処理
MOVE_PAGE お問い合わせページへ遷移 再帰的にページ遷移して処理続行
LINKS_FOUND リンク候補を検出 最高スコアリンクへ遷移
FOUND_MAIL_SEND_SERVER メールアドレス抽出成功 メールアドレスを collectedEmails に蓄積 (本番/テスト共通)
BUTTON_CLICKED 送信ボタン押下完了 (本番のみ) 5秒待機後 CHECK_PAGE_STATUS で確認
TEST_COMPLETED テストモードでフォーム入力完了 2秒待機後タブ閉じ→成功記録
CAPTCHA_DETECTED reCAPTCHA検知 保留キューへ追加 (テスト/本番共通)
NO_FORM_FOUND フォーム/リンク/メール全て不検出 失敗記録
ERROR エラー発生 失敗記録
COMPLETED 送信完了ページ確認 成功記録
CONFIRM_SUBMITTED 確認画面で確定ボタン押下 再度 CHECK_PAGE_STATUS で完了確認

FR-EXEC-002: テストモード (Dry Run)

基本方針: テストモードと本番モードの唯一の違いは「送信ボタンを押さない」「メール送信しない」のみ。CAPTCHA検知・音声通知・保留キュー・稼働制限チェック・送信履歴記録・エラー処理・タブの自動閉じ等、送信以外の全処理は本番モードと完全に同一に動作する。

項目 テストモード 本番モード
フォーム自動入力 実行する 実行する
送信ボタン 押さない (赤枠 10px solid #FF0000 でハイライト) 自動クリック
メール送信 (フォーム未検出時) 蓄積しない (ログのみ記録) メールアドレスを蓄積し、完了後に mailto:リンクを生成
CAPTCHA検知 本番と同一 (保留キューへ追加、音声アラート再生) 保留キューへ追加、音声アラート再生
タブ動作 自動閉じ (本番と同一) 自動閉じ
送信履歴 (sentHistory) 記録する (本番と同一) 記録する
エラー処理 本番と同一 (タブ閉じ→次へ) タブ閉じ→次へ
無限ループ検知 本番と同一 (即停止) 即停止
実行履歴 isTestMode: true フラグ付きで記録 isTestMode: false で記録

レスポンスの違い: テストモードでは送信ボタン発見時に TEST_COMPLETED ステータスを返す。本番モードでは送信ボタンをクリックし BUTTON_CLICKED ステータスを返す。

FR-EXEC-003: 重複送信防止

スコープ 説明
none 制限なし (常に送信)
batch 同一実行バッチ内で重複しない
1day 24時間以内の送信済みURLをスキップ
2days 2日以内
3days 3日以内
4days 4日以内
5days 5日以内
6days 6日以内
7days 1週間以内
2weeks 2週間以内
month 1ヶ月以内 (デフォルト推奨値)
3months 3ヶ月以内
year 1年以内
forever 永久にスキップ

送信履歴は Chrome Storage Local の sentHistory{ [url]: ISO日時文字列 } 形式で保存。

function shouldSkipUrl(url: string, scope: string, history: Record<string, string>, currentBatch: Set<string>): boolean {
  if (!scope || scope === 'none') return false;
  if (scope === 'batch' || currentBatch.has(url)) return true;

  const lastSentStr = history[url];
  if (!lastSentStr) return false;
  if (scope === 'forever') return true;

  const lastSentTime = new Date(lastSentStr).getTime();
  const diffMs = Date.now() - lastSentTime;
  const oneDay = 24 * 60 * 60 * 1000;

  switch (scope) {
    case '1day':     return diffMs < oneDay;
    case '2days':    return diffMs < oneDay * 2;
    case '3days':    return diffMs < oneDay * 3;
    case '4days':    return diffMs < oneDay * 4;
    case '5days':    return diffMs < oneDay * 5;
    case '6days':    return diffMs < oneDay * 6;
    case '7days':    return diffMs < oneDay * 7;
    case '2weeks':   return diffMs < oneDay * 14;
    case 'month':    return diffMs < oneDay * 30;
    case '3months':  return diffMs < oneDay * 90;
    case 'year':     return diffMs < oneDay * 365;
    default:         return false;
  }
}

FR-EXEC-004: 稼働制限

制限種別 説明 デフォルト
曜日制限 チェックした曜日は実行不可 日曜 (sun)・土曜 (sat) を制限
時間帯制限 指定時間帯外は実行不可 09:00 ~ 18:00 (無効状態: enableTimeLimit: false)
function checkRestrictions(settings: any, logFn: any): boolean {
  const now = new Date();
  const days = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
  const currentDayKey = days[now.getDay()];

  if (settings.restrictedDays && settings.restrictedDays[currentDayKey] === true) {
    logFn('ERROR', `本日は送信制限曜日(${currentDayKey.toUpperCase()})のため、処理を停止します。`);
    return false;
  }

  if (settings.enableTimeLimit) {
    const currentHm = now.getHours() * 60 + now.getMinutes();
    const [startH, startM] = (settings.timeLimitStart || '00:00').split(':').map(Number);
    const [endH, endM] = (settings.timeLimitEnd || '23:59').split(':').map(Number);
    const startHm = startH * 60 + startM;
    const endHm = endH * 60 + endM;

    if (currentHm < startHm || currentHm > endHm) {
      logFn('ERROR', `現在時刻は送信許可時間外です (${settings.timeLimitStart} ~ ${settings.timeLimitEnd})`);
      return false;
    }
  }
  return true;
}

FR-EXEC-005: 除外ドメイン

URLに以下のキーワードが含まれる場合はスキップする。1行1キーワードで設定。

デフォルト除外リスト:

houjin
prtimes
initial
wantedly
wiki
hrmos
goo.ne.jp
baseconnect
mapion
itp.ne.jp

除外判定: url.includes(ignoreWord) で部分一致チェック。

FR-EXEC-006: 無限ループ検知

  • 訪問済みURLを Set で管理
  • URLの正規化: フラグメント (#) 除去、末尾スラッシュ除去
  • 同一URLへの再訪問を検知した場合、処理を停止 (テストモード・本番モード共通)
const normalizedUrl = url.split('#')[0].replace(/\/$/, '');

if (visitedUrls.has(normalizedUrl)) {
    logFn('ERROR', `無限ループ検知: 既に訪問済みのページのため停止します (${url})`);
    return 'FAILED';
}
visitedUrls.add(normalizedUrl);

FR-EXEC-007: CAPTCHA対応

検知対象 判定方法
reCAPTCHA iframe[src*="recaptcha"] または .g-recaptcha の存在 (可視状態)
reCAPTCHA v3 (非表示) .grecaptcha-badge → 非表示版のため無視 (通過可能)
function hasRecaptcha() {
    if (document.querySelector('.grecaptcha-badge')) return false;
    const iframe = document.querySelector('iframe[src*="recaptcha"]');
    if (iframe && isVisible(iframe as HTMLElement)) return true;
    const container = document.querySelector('.g-recaptcha');
    if (container && isVisible(container as HTMLElement)) return true;
    return false;
}
モード 動作
手動解決モード (推奨) CAPTCHA検知 → 処理を保留キューへ移動 → 後で一括手動解決
スキップモード CAPTCHA検知 → 送信失敗として次のURLへ進む

手動解決モードのタイムアウト: 設定可能 (デフォルト5分)。タイムアウト時は全処理を中断 (TIMEOUT_STOP)。

CAPTCHA解決の待機ロジック:

async function handleCaptchaMode(timeout: number) {
  return new Promise((resolve, reject) => {
    const start = Date.now();
    const timer = setInterval(() => {
      if (document.querySelector('[name="g-recaptcha-response"]')?.getAttribute('value')) {
        clearInterval(timer); resolve(true);
      }
      if (Date.now() - start > timeout * 60000) {
        clearInterval(timer); reject(new Error('TIMEOUT_STOP'));
      }
    }, 1000);
  });
}

FR-EXEC-008: 音声通知

通知タイミング 音声ファイル 説明
CAPTCHA検知時 startAlertLoop.mp3 最初に1回鳴る
待機中 alertAudio.mp3 ループ再生
完了/タイムアウト時 stopAlert.mp3 1回鳴る
  • カスタム音声ファイルのアップロード可能 (上限1MB、audio/*形式)
  • Base64エンコードで Chrome Storage に保存 (audioStart, audioLoop, audioStop)
  • 有効/無効の切り替え可能 (enableSound)
  • デフォルト音声は chrome.runtime.getURL(defaultFileName) で取得

FR-EXEC-009: ページ読み込み待機

  • 設定可能 (デフォルト3秒、最小1秒)
  • chrome.tabs.onUpdatedstatus: 'complete' を待機後、追加で設定秒数待機
function waitForTabLoad(tabId: number): Promise<void> {
  return new Promise((resolve) => {
    const listener = (tid: number, changeInfo: any) => {
      if (tid === tabId && changeInfo.status === 'complete') {
        chrome.tabs.onUpdated.removeListener(listener);
        resolve();
      }
    };
    chrome.tabs.onUpdated.addListener(listener);
  });
}

FR-EXEC-010: 実行制御

アクション 説明
START_PROCESS バッチ実行開始。実行前に確認ダイアログ表示 (モード・テンプレート名・件数)
STOP_PROCESS AbortController による強制停止
GET_STATUS 現在の実行状態を返す (isRunning)
CLEAR_LOGS ログ履歴を消去

START_PROCESS ペイロード:

{
  action: 'START_PROCESS',
  data: {
    targets: string[],      // URLリスト
    template: Template,      // 使用テンプレート
    settings: Settings,      // 設定値
    userId: string | number  // ユーザーID
  }
}

実行セッション記録:

{
  id: `hist_${Date.now()}`,
  userId: activeUserId,
  templateName: template?.name || '名称未設定',
  startTime: new Date().toISOString(),
  targetCount: targets?.length || 0,
  logs: [],
  isTestMode: boolean
}

実行履歴は executionHistory_user_{userId} に最大50件保存。


6. 送信完了判定

FR-COMPLETE-001: 完了ページ判定

ページ内テキストに以下のキーワードが含まれる場合、送信完了と判定する。

送信完了 | 送信しました | ありがとうございます | complete | success |
registered | received | 承りました | 記録しました | 受け付けました

正規表現:

/送信完了|送信しました|ありがとうございます|complete|success|registered|received|承りました|記録しました|受け付けました/i

FR-COMPLETE-002: 確認画面判定

ページ内テキストに以下のキーワードが含まれる場合、確認画面と判定し自動で確定ボタンを押下する。

確認画面 | 確認して | confirm

正規表現:

/確認画面|確認して|confirm/i

確認画面検出後の処理:

  1. 確定ボタン (findSubmitButton) を検出してクリック → CONFIRM_SUBMITTED
  2. 確定ボタンが見つからない場合 → STUCK_ON_CONFIRM
  3. 確定ボタン押下後5秒待機 → 再度 CHECK_PAGE_STATUS で完了確認

7. メール送信機能 (mailto:リンク方式)

v1.1 変更: サーバーAPI経由のメール送信を廃止し、mailto: リンク生成方式に変更。サーバー負荷を排除し、ユーザーのデフォルトメーラーを利用する。

FR-MAIL-001: 概要

フォーム自動入力の探索過程でお問い合わせフォームが見つからず、ページ上にメールアドレスのみが検出された場合、メールアドレスを collectedEmails 配列に蓄積する。バッチ処理完了後、蓄積したメールアドレスをBCCにまとめた mailto: リンクを生成し、ユーザーがクリックすることでデフォルトメーラーを起動する。

FR-MAIL-002: 処理フロー

1. Content Script がフォーム・リンクを検出できない
2. ページ内からメールアドレスを正規表現で抽出
   正規表現: /[a-zA-Z0-9._-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,6}/
3. Background へ FOUND_MAIL_SEND_SERVER ステータス + 抽出メールアドレスを返却
4. Background が collectedEmails 配列にメールアドレスを蓄積
5. バッチ処理全URL完了後:
   a. user_info からユーザーのメールアドレスを取得
   b. template から subject/body を取得
   c. mailto:{userEmail}?subject={subject}&body={body}&bcc={emails} 形式のリンクを生成
   d. URL長制限対応: 1900文字を超える場合は複数リンクに分割
   e. MAILTO タイプのログエントリとして出力
   f. MAILTO_LINK_READY メッセージでUIに送信
6. ユーザーがログ内のmailtoリンクをクリック → デフォルトメーラーが起動

FR-MAIL-003: テストモード時の動作

項目 内容
動作 メールアドレス抽出のみ (蓄積もスキップ)
ログ [TEST] メールアドレス抽出: {mail} (送信スキップ)
送信履歴 記録する (本番と同一)

FR-MAIL-004: mailto:リンク生成仕様

項目 ソース
宛先 (to) ユーザー自身のメールアドレス (user_info.email)
BCC バッチ処理で抽出した全メールアドレス (カンマ区切り)
件名 (subject) テンプレートの subject
本文 (body) テンプレートの body

URL長制限: OS/ブラウザごとに mailto: URLには約2000文字の制限がある。安全マージンとして1900文字を上限とし、超える場合は複数のmailtoリンクに自動分割する。

function generateMailtoLinks(userEmail: string, emails: string[], subject: string, body: string): string[] {
  const MAX_URL_LENGTH = 1900;
  const basePrefix = `mailto:${encodeURIComponent(userEmail)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}&bcc=`;
  const links: string[] = [];
  let currentBcc: string[] = [];

  for (const email of emails) {
    const testBcc = [...currentBcc, email];
    const testUrl = basePrefix + encodeURIComponent(testBcc.join(','));

    if (testUrl.length > MAX_URL_LENGTH && currentBcc.length > 0) {
      links.push(basePrefix + encodeURIComponent(currentBcc.join(',')));
      currentBcc = [email];
    } else {
      currentBcc.push(email);
    }
  }

  if (currentBcc.length > 0) {
    links.push(basePrefix + encodeURIComponent(currentBcc.join(',')));
  }

  return links;
}

FR-MAIL-005: UI表示

ログ表示部分で MAILTO タイプのログエントリをクリック可能な <a> タグとして描画する。

項目 内容
表示スタイル ゴールド背景、大きめフォント、クリック可能なリンク
リンク属性 href="mailto:...", target="_blank"
ラベル 「メール送信リンク(BCC: XX件)- クリックでメーラー起動」
複数分割時 「メール送信リンク 1/N(BCC: 約XX件)」の形式で表示

8. 画面一覧

8.1 ログイン画面

項目 内容
コンポーネント Login.tsx
表示条件 access_token が Chrome Storage に存在しない場合
入力項目 メールアドレス、パスワード
動作 POST /api/login → トークン取得 → Dashboard へ遷移
エラー表示 認証失敗メッセージ、サーバー接続エラーメッセージ

8.2 ダッシュボード (DASHBOARD)

項目 内容
コンポーネント Dashboard.tsx 内の DashboardPanel
タブキー dash
表示内容 ステータスカード (TARGETS / SUCCESS / ERROR / YIELD RATE)
実行履歴リスト (ページネーション: 5件/ページ)
セッションログ (リアルタイム更新)
データソース executionHistory_user_{userId}, executionLogs_user_{userId}

8.3 メッセージエディタ (MESSAGE EDITOR)

項目 内容
コンポーネント MessageEditor.tsx
タブキー input
レイアウト 左: テンプレート一覧サイドバー (260px)、右: 編集フォーム
機能 新規作成、編集、複製、削除、差し込みタグ挿入
入力セクション 送信者情報 (分割入力)、メッセージ本文

8.4 実行画面 (EXECUTION)

項目 内容
コンポーネント Execution.tsx
タブキー send
レイアウト 左: テンプレート選択 + URLリスト入力、右: コンソールログ
入力 テンプレート選択 (ドロップダウン)、ターゲットURL (1行1URL、textarea)
実行ボタン START PROCESS / STOP
前提条件 設定が一度保存されていること (lastSavedSettings の存在チェック)
実行前確認 モード (テスト/本番)、テンプレート名、件数を表示して confirm

8.5 設定画面 (SETTINGS)

項目 内容
コンポーネント Settings.tsx
タブキー set
セクション構成 基本動作設定 / ロボット認証対策 & 通知 / 稼働制限設定 / 除外ドメイン設定

設定項目一覧:

カテゴリ 設定項目 キー デフォルト
基本動作 テストモード testMode boolean false
基本動作 重複送信防止期間 duplicateCheckScope enum month
基本動作 ページ読み込み待機 (秒) pageLoadDelay number 3
CAPTCHA 手動解決モード solveCaptcha boolean true
CAPTCHA タイムアウト (分) captchaTimeout number 5
通知 音声通知有効化 enableSound boolean true
通知 開始通知音 audioStart audio/base64 | null null (デフォルトMP3使用)
通知 待機ループ音 audioLoop audio/base64 | null null (デフォルトMP3使用)
通知 完了/終了音 audioStop audio/base64 | null null (デフォルトMP3使用)
稼働制限 時間帯制限有効化 enableTimeLimit boolean false
稼働制限 開始時刻 timeLimitStart string (HH:mm) 09:00
稼働制限 終了時刻 timeLimitEnd string (HH:mm) 18:00
稼働制限 曜日制限 restrictedDays object { sun: true, mon: false, tue: false, wed: false, thu: false, fri: false, sat: true }
除外 除外ドメインリスト ignoreDomains string (改行区切り) 10件のデフォルト値 (上記参照)

8.6 アカウント設定画面 (ACCOUNT)

項目 内容
コンポーネント AccountSettings.tsx
タブキー account
表示情報 ログインユーザーのメールアドレス、ユーザー種別、契約期間
外部リンク 登録情報 (/dashboard/profile)、契約確認 (/dashboard/billing) → ユーザーWebパネルへ
操作 ログアウトボタン

9. 拡張機能UIテーマ

ダークテーマ (黒/ゴールド) を基調とした統一デザイン。

項目
メイン背景 #000000 (黒)
カード背景 #141414
パネル背景 #1E1E1E
アクセントカラー #D4AF37 (ゴールド)
ゴールドグラデーション linear-gradient(135deg, #d4af37, #c5a028)
テキスト色 (メイン) #E0E0E0
テキスト色 (サブ) #888888
ボーダー色 #333333
成功色 #00cc00
エラー色 #ff4444
入力済みフィールドハイライト rgba(212, 175, 55, 0.15)
テストモード送信ボタン枠 10px solid #FF0000

10. Chrome Storage データ構造

キー 説明
access_token string Sanctum APIトークン
user_info UserInfo ユーザー情報 (id, name, email, is_white_user, contract_period, plan_type, ticket_balance, monthly_free_limit, monthly_free_remaining, subscription_expires_at, is_banned)
device_id string デバイスUUID (UUIDv4)
templates Template[] テンプレート一覧
settings object 全設定値
lastSavedSettings string 設定の最終保存日時
sentHistory Record<string, string> 送信済みURL履歴 { url: ISO日時 }
executionHistory_user_{userId} object[] 実行履歴 (最大50件)
executionLogs_user_{userId} LogItem[] 実行ログ (最大500件)
executionUrls_user_{userId} string 最後に入力したURLリスト
lastSelectedTemplateId_user_{userId} string 最後に選択したテンプレートID
readNoticeIds number[] 既読通知IDリスト
pending_sync_user_{userId} object 同期失敗時の保留データ (次回ログイン時にリトライ)

LogItem 型定義:

interface LogItem {
  time: string;
  type: 'INFO' | 'SUCCESS' | 'ERROR' | 'SYSTEM' | 'WAITING' | 'MAILTO';
  msg: string;
}

ログレベル表示色:

ログレベル 用途
INFO 処理状況の通知 ゴールド (#D4AF37)
SUCCESS 送信成功 緑 (#00cc00)
ERROR エラー・失敗 赤 (#ff4444)
SYSTEM システムメッセージ (開始/終了等) ゴールド (#D4AF37)
WAITING 待機中 ゴールド (#D4AF37)
MAILTO mailto:リンク (クリック可能) ゴールド背景 + リンク装飾

11. Chrome API利用

11.1 パーミッション

パーミッション 用途
storage テンプレート・設定・履歴・トークン等の永続化
tabs タブの作成・更新・削除
activeTab 現在のタブへのアクセス
scripting コンテンツスクリプトの注入

manifest設定:

manifest: {
  name: 'SMART-CONTACT',
  permissions: ['storage', 'tabs', 'activeTab', 'scripting'],
}

11.2 Tabs API

API 用途
chrome.tabs.create 新規タブ作成 (URL処理開始時)
chrome.tabs.update 既存タブのURL更新 (ページ遷移時)
chrome.tabs.remove タブ削除 (処理完了/エラー後)
chrome.tabs.sendMessage コンテンツスクリプトへのメッセージ送信 (FILL_FORM, CHECK_PAGE_STATUS, SHOW_OVERLAY)
chrome.tabs.onUpdated タブ読み込み完了の監視 (status: 'complete')
chrome.tabs.query タブ存在確認

11.3 Runtime API

API 用途
chrome.runtime.onMessage バックグラウンド ⇄ ダッシュボード間のメッセージング (START_PROCESS, STOP_PROCESS, GET_STATUS, CLEAR_LOGS, LOG_UPDATE, PROCESS_COMPLETE)
chrome.runtime.sendMessage ログ更新、プロセス完了通知等のブロードキャスト
chrome.runtime.getURL 拡張機能内リソース (音声ファイル等) のURL取得
chrome.action.onClicked 拡張機能アイコンクリック時にオプションページを開く

11.4 Storage API

API 用途
chrome.storage.local.get テンプレート、設定、履歴、トークン等の読み込み
chrome.storage.local.set テンプレート、設定、履歴、トークン等の保存
chrome.storage.local.remove ログアウト時のトークン削除等
  • 容量制限: Chrome Storage Local はデフォルトで約5MB
  • 非同期API (get, set, remove)
  • chrome.storage.sync も補助的に参照 (テストモード判定等)