日本語 | English
participants-router は、心理学実験やオンライン調査のために設計された、PHP製のバックエンドルーティングシステムです。 参加者ごとに一意の実験条件を割り当て、複数の実験ステップ(同意書、タスク、アンケートなど)への遷移を管理します。
- 単一URL配布: 全ての参加者に同じURL(エントリポイント)を配布するだけで、自動的に条件別のURLへ誘導します。
- 重複参加防止: ブラウザID等をキーにして参加状況を管理し、重複参加を防止・制御します。
- 柔軟な割り当て戦略: 参加者数の最小化(Minimal Group Assignment)やランダム割り当てに対応。
- アクセス制御: 正規表現や外部API(CrowdWorksなど)と連携した高度な参加条件(スクリーニング)設定が可能。
- ハートビート監視: 参加者の離脱を検知するためのハートビートAPIを提供。
- ステートフルな進行管理: 参加者が現在どのステップにいるかをDBで管理し、リロードや再アクセス時も正しい位置から再開(Resume)できます。
- Webサーバー: Apache, Nginx など
- PHP: 8.3 以上推奨
- データベース: MySQL, PostgreSQL, SQLite (PDO対応のDB)
- Composer: PHPパッケージ管理ツール (https://getcomposer.org/)
-
リポジトリのクローン
git clone https://github.com/miyamoto-hai-lab/participants-router.git cd participants-router -
依存ライブラリのインストール Composerを使って依存パッケージをインストールします。
composer install
-
データベース設定
config.jsoncでを設定するだけで完了です。 アプリケーション起動時に、必要なテーブル(デフォルト:participants_routes)が自動的に作成されます。SQLiteを使用する場合、指定したパスにデータベースファイルが存在しなければ自動的に作成されます。
-
設定ファイルの編集
config.jsoncを環境に合わせて編集します。
データベース接続情報や実験設定などを記述します。Visual Studio Codeなどで編集すると、
config.shema.jsonを基に設定項目の説明が表示されます。 -
Webサーバーへの配置 ステップ2で生成された
vendorディレクトリも含むすべてのファイルをWebサーバーの公開ディレクトリ(ドキュメントルート)または、そこからアクセス可能な場所に配置します。APIへの初回アクセス時に、必要なテーブル(デフォルト:
participants_routes)が自動的に作成されます。
SQLiteを使用する場合、指定したパスにデータベースファイルが存在しなければ自動的に作成されます。
設定ファイルは JSONC (JSON with Comments) 形式で記述します。主な設定項目は以下の通りです。
| キー | 説明 |
|---|---|
access_control |
参加条件(スクリーニング)ルール。正規表現や外部API連携が可能。 |
assignment_strategy |
割り当て戦略。minimum (人数の少ない条件へ) または random。 |
fallback_url |
満員時や実験無効時にリダイレクトさせるURL。 |
heartbeat_intervalsec |
有効な参加者としてカウントする時間枠(秒)。この時間以内にハートビートがない参加者は「離脱」とみなされ、人数カウントから除外される場合があります。 |
groups |
実験条件(群)の定義。 |
access_control は、実験に参加できるユーザーを制限するための機能です。all_of (AND), any_of (OR), not (NOT) を組み合わせた論理条件ツリーとして定義します。
条件演算子:
| キー | 説明 |
|---|---|
all_of |
リスト内の全ての条件が true の場合に true を返します (AND)。 |
any_of |
リスト内のいずれかの条件が true の場合に true を返します (OR)。 |
not |
指定した条件の真偽を反転させます (NOT)。 |
ルール (末端条件):
論理演算子の末端には、以下のいずれかのルールを記述します。
1. 正規表現判定 (type: regex)
クライアントから送信された properties の値を正規表現でチェックします。
fieldにparticipant_idを指定すると、participant_idの値でpatternをチェックします。
{
"type": "regex",
"field": "age", // チェック対象のプロパティ名
"pattern": "^2[0-9]$" // 正規表現パターン (例: 20代)
}2. 外部API問い合わせ (type: fetch)
外部サーバーにHTTPリクエストを送り、その結果に基づいて判定します。
URLやheader、Body内で ${keyname} 形式のプレースホルダを使用でき、properties の値に置換されます。
keynameにparticipant_idを指定すると、participant_idの値に置換されます。
{
"type": "fetch",
"url": "https://api.example.com/check?id=${participant_id}",
"method": "GET", // GET (default) or POST
// "headers": { "Authorization": "Bearer ..." },
// "body": { "id": "${participant_id}" }, // POSTの場合
"expected_status": 200 // 成功とみなすHTTPステータス (省略時は200系OK判定)
}設定例: 複合条件
「CrowdWorks IDが6桁以上の数字」かつ「外部APIで重複チェックがOK(200が返ってきたらNGなので not で反転)」の場合のみ許可する例:
"access_control": {
"condition": {
"all_of": [
{
"type": "regex",
"field": "participant_id",
"pattern": "^\\d{6,}$"
},
{
"not": {
"type": "fetch",
"url": "https://api.example.com/check_duplicate/${participant_id}",
"expected_status": 200 // 重複あり(200)なら true -> not で false(拒否) になる
}
}
]
},
"action": "allow", // 条件が true の時の動作 (現在は allow のみ)
"deny_redirect": "https://example.com/denied.html" // 拒否された場合の遷移先
}実験の進行(ステップ)をURLのリストとして定義します。ユーザーが現在のURLから「次へ」リクエストを送ると、リストの次のURLが返されます。
"groups": {
"group_A": {
"limit": 50, // 参加人数上限
"steps": [
// STEP 1
"https://survey.example.com/consent",
// STEP 2
"https://task.example.com/task_A",
// STEP 3
"https://survey.example.com/post_survey"
]
},
"group_B": {
"limit": 50,
"steps": [
"https://survey.example.com/consent",
"https://task.example.com/task_B", // group_Aと異なるタスク
"https://survey.example.com/post_survey"
]
}
}クライアント(実験実施用のフロントエンドアプリなど)からは、主に以下の3つのAPIを利用します。すべてのレスポンスはJSON形式です。
実験への参加登録を行い、最初のステップのURLを取得します。
- Endpoint:
POST /router/assign - Content-Type:
application/json
Request Body:
{
"experiment_id": "sample_experiment",
"participant_id": "unique_participant_id_abc123", // 実験参加者を一意に識別するID
"properties": {
// access_control等の判定に使われる属性
"browser_id": "019ba8d6-748e-70ae-bdf0-b29fc9188782", // ブラウザ固有のID(後述)
"age": 25
}
}Tip
browser_id について
クラウドソーシング実験では、別アカウントでの二重参加を防止するために、propertiesに browser_id を設定することをお勧めします。
browser_id はブラウザ固有のIDであり、かつ同一ブラウザでの再アクセス時に復元可能なIDです。
セッションIDのように実験ページへのアクセス毎に変更されるIDを使用すると、再アクセス時に途中から開始できないため browser_id には使用できません。
ID生成・管理には、宮本研究室で開発された browser-id ライブラリの使用を推奨します。これを利用することで、ローカルストレージへの適切な永続化とブラウザ固有のID生成が容易に行えます。
browser_idを使った参加制限の例はこちらを参照してください。
Response (Success):
{
"status": "ok",
"url": "https://survey.example.com/consent", // 遷移すべきURL
"message": null
}Response (Denied/Full/Error):
{
"status": "ok", // または "error"
"url": "https://example.com/sorry.html", // リダイレクト先(設定されている場合)
"message": "Access denied" // または "Full" 等
}現在のステップを完了し、次のステップのURLを取得します。システムは現在のURL (current_url) を元に進行状況を判定します。
- Endpoint:
POST /router/next - Content-Type:
application/json
Request Body:
{
"experiment_id": "sample_experiment",
"participant_id": "unique_participant_id_abc123",
"current_url": "https://survey.example.com/consent?user=123", // 現在表示しているURL
"properties": {
"score": 100 // 必要に応じてプロパティを更新可能
}
}Response (Next Step):
{
"status": "ok",
"url": "https://task.example.com/task_A", // 次のURL
"message": null
}Response (Completed):
{
"status": "ok",
"url": null, // 次がない場合はnull
"message": "Experiment completed"
}参加者が実験を継続中(ブラウザを開いている)であることを通知します。heartbeat_intervalsec の設定と連動し、アクティブな参加者数を正確に把握するために使用します。
- Endpoint:
POST /router/heartbeat - Content-Type:
application/json
Request Body:
{
"experiment_id": "sample_experiment",
"participant_id": "unique_participant_id_abc123"
}Response:
{
"status": "ok"
}browser_idによる参加制限を含む設定例
{
"$schema": "./config.schema.json",
"base_path": "",
"database": {
"url": "mysql://user:pass@localhost/dbname",
"table": "participants_routes"
},
"experiments": {
"sample_experiment": {
"enable": true,
"config": {
"access_control": {
"condition": {
"all_of": [
{
"type": "regex",
"field": "participant_id",
"pattern": "^\\d{6,}$"
},
{
"not": {
"type": "fetch",
"url": "https://api.example.com/check_duplicate/sample_experiment",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer ${token}"
},
"body": {
"participant_id": "${participant_id}",
"browser_id": "${browser_id}"
},
"expected_status": 200
}
}
]
},
"action": "allow",
"deny_redirect": "https://example.com/denied.html"
},
"assignment_strategy": "minimum",
"fallback_url": "https://example.com/fallback.html",
"heartbeat_intervalsec": 60,
"groups": {
"group_a": {
"size": 10,
"url": "https://task.example.com/task_A"
},
"group_b": {
"size": 10,
"url": "https://task.example.com/task_B"
}
}
}
}
}
}以下のようにfetch conditionとbrowser_idを組み合わせることで参加制限を実現できます。
"not": {
"type": "fetch",
"url": "https://api.example.com/check_duplicate/sample_experiment",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Authorization": "Bearer ${token}"
},
"body": {
"participant_id": "${participant_id}", // 参加者ID
"browser_id": "${browser_id}" // ブラウザID
},
"expected_status": 200
}browser-id ライブラリと jsPsych を組み合わせた実装例です。
最初の画面で participant_id を取得(生成)し、Assign APIを叩いて実験URLへ遷移します。
// htmlボディ等で browser-id ライブラリを読み込んでおく
// <script src="browser-id.global.js"></script>
const APP_NAME = "sample_experiment";
// jsPsychのtrialとして定義する例
const loading_process_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: `<div class="loader"></div><p>実験ページへ遷移中です...</p>`,
choices: "NO_KEYS",
on_load: async () => {
try {
// 1. browser-idの初期化
const browser = new BrowserIdLib.AsyncBrowser(
APP_NAME,
undefined,
// IDのバリデーション関数
(id) => typeof id === "string" && id.length > 0
);
// 2. browser_id の取得 (初回は生成、2回目以降はLocalStorageから取得)
const browserId = await browser.get_id();
// 3. 属性情報の保存 (必要に応じて)
// 例: 直前のトライアルで入力させたID等を取得
const cwid = jsPsych.data.get().last(1).values()[0].response.cwid;
await browser.set_attribute("participant_id", cwid);
// 4. Serverへ参加リクエスト (Assign)
const response = await fetch('/api/router/assign', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
experiment_id: APP_NAME,
participant_id: cwid,
properties: {
// browser_idも送信して複数アカウントでの多重参加を検知
browser_id: browserId
}
})
});
if (!response.ok) throw new Error('Network response was not ok');
const result = await response.json();
// 5. 遷移先URLへリダイレクト
if (result.data.url) {
window.location.href = result.data.url + "?cwid=" + cwid;
} else {
alert("参加できませんでした: " + (result.data.message || "Unknown error"));
}
} catch (e) {
console.error(e);
alert("エラーが発生しました");
}
}
};実験中の各ページでは、ハートビートを定期送信しつつ、タスク終了時に Next APIを叩いて次のステップへ進みます。
// ページ読み込み時に Heartbeat を開始
const browser = new BrowserIdLib.AsyncBrowser(APP_NAME, /* ... */);
const cwid = window.location.searchParams.get("cwid");
document.addEventListener("DOMContentLoaded", async () => {
// 10秒ごとにハートビート送信
if (cwid) {
setInterval(() => {
fetch("/api/router/heartbeat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
experiment_id: APP_NAME,
participant_id: cwid
})
}).catch(e => console.error("Heartbeat error:", e));
}, 10000);
}
});
// 次へ進む処理 (jsPsychのtrialなど)
const next_step_trial = {
type: jsPsychHtmlKeyboardResponse,
stimulus: "処理中...",
on_load: async () => {
const currentUrl = window.location.href;
// Next API を叩く
const response = await fetch('/api/router/next', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
experiment_id: APP_NAME,
participant_id: cwid,
current_url: currentUrl,
properties: {
// スコアなどで分岐する場合
// score: 100
}
})
});
const result = await response.json();
if (result.data.url) {
window.location.href = result.data.url + "?cwid=" + cwid;
} else {
alert("実験終了です。お疲れ様でした。");
}
}
};詳細なディレクトリ構成やデータベース設計図については、CONTRIBUTING.md を参照してください。
src/Domain: ドメインロジック(RouterService, Participantモデルなど)src/Application: アプリケーション層(Action, Controller)config.jsonc: 設定ファイルpublic: 公開ディレクトリ(index.php等)
このプロジェクトはMIT Licenseで提供されています。
{ "$schema": "./config.schema.json", // APIのベースパス (例: "/api/router") "base_path": "/api/router", // データベース接続設定 "database": { "url": "mysql://user:pass@localhost/dbname", // または sqlite://./db.sqlite "table": "participants_routes" }, "experiments": { // 実験ID (APIリクエスト時に使用) "sample_experiment": { "enable": true, // falseにするとアクセスを停止 "config": { ... } // 実験ごとの詳細設定 } } }