jsPsychのデータを自動で分割してQualtricsに保存する

jsPsychのデータを自動で分割してQualtricsに保存する

Qualtricsの新しいアンケート回答エクスペリエンスにより、埋め込みデータとして保存できるデータサイズが20KBに制限されました。jsPsychで試行数が多い実験をする場合、簡単にこのデータサイズを超えてしまいます。

今回は、保存するデータを削らなくて良いように、分割して、Qualtricsの埋め込みデータに保存するコードをCodexと相談して作成しました。Rの読み込みコードも添付しています。

基本的な方針

  • 以下のJavaScriptコードは、実験を実行し、jsPsychから出力されるデータを19KBごとに分割して埋め込みデータに保存します。
  • ブラウザのコンソールに、いくつの埋め込みブロックを用意したら良いか表示される
  • 上の数に合わせて、必要な数だけ埋め込みデータを設定します(__js_datajs01, __js_datajs02….といった形で)

埋め込みデータ
一度、以下のコードを動かしてみると、コンソール上に埋め込みデータをいくつ作ればよいのかの目安が表示されます。例えば、IATの場合、一人あたり212行×13列のデータが生成されます。データは3つに分割されるということが表示されるので、その分だけ埋め込みデータを設定します。参加者によって出力されるデータサイズや試行数が異なる場合は、多めに埋め込みデータを設定しておくことを推奨します。

jsPsych ver8向けコード:QualtricsのJavaScriptブロックに記載

以下のコードは、これまでの記事のqualtrics.js部分で実施する内容です。

Qualtrics.SurveyEngine.addOnload(function () {
    /*Place your JavaScript here to run when the page loads*/

});

Qualtrics.SurveyEngine.addOnReady(function () {
    /*Place your JavaScript here to run when the page is fully displayed*/
    var qthis = this;
    qthis.hideNextButton();

    var task_github = "自分のサーバーのリンクを入れる"; // https://<your-github-username>.github.io/<your-experiment-name>/
    var taskBase = task_github.charAt(task_github.length - 1) === "/" ? task_github : task_github + "/";
    var assetVersion = "20260616_01";
    var maxChunkBytes = 19000;
    var debugDataSize = true;

    var jsPsychScripts = [
        assetUrl("jspsych/dist/jspsych.js"),
        assetUrl("jspsych/dist/plugin-html-keyboard-response.js"),
        assetUrl("jspsych/dist/plugin-instructions.js"),
        assetUrl("jspsych/dist/plugin-iat-html.js"),
        assetUrl("jspsych/dist/plugin-fullscreen.js"),
        assetUrl("jspsych/dist/plugin-survey-text.js")
    ];

    function assetUrl(path) {
        return taskBase + path + "?v=" + assetVersion;
    }

    function loadJsScript(src) {
        console.log("Loading ", src);
        return new Promise(function (resolve, reject) {
            var script = document.createElement("script");
            script.src = src;
            script.async = false;
            script.onload = function () {
                resolve(src);
            };
            script.onerror = function () {
                reject(new Error("Failed to load " + src));
            };
            document.head.appendChild(script);
        });
    }

    function loadScriptsInOrder(scripts) {
        return scripts.reduce(function (promise, src) {
            return promise.then(function () {
                return loadJsScript(src);
            });
        }, Promise.resolve());
    }

    function loadCss(href) {
        console.log("Loading ", href);
        return new Promise(function (resolve, reject) {
            var anchor = document.createElement("a");
            anchor.href = href;

            var existingLinks = document.querySelectorAll('link[rel="stylesheet"]');
            for (var i = 0; i < existingLinks.length; i++) {
                if (existingLinks[i].href === anchor.href) {
                    resolve(href);
                    return;
                }
            }

            var link = document.createElement("link");
            link.rel = "stylesheet";
            link.type = "text/css";
            link.href = href;
            link.onload = function () {
                resolve(href);
            };
            link.onerror = function () {
                reject(new Error("Failed to load " + href));
            };
            document.head.appendChild(link);
        });
    }

    function showNextButton() {
        if (typeof qthis.showNextButton === "function") {
            qthis.showNextButton();
        }
    }

    function removeElement(id) {
        var element = document.getElementById(id);
        if (element) {
            element.parentNode.removeChild(element);
        }
    }

    function createDisplayElement(id) {
        var existing = document.getElementById(id);

        if (existing) {
            return existing;
        }

        var element = document.createElement("div");
        element.id = id;
        document.body.appendChild(element);
        return element;
    }

    function showLoadError(message, error) {
        console.error(message, error);

        var displayStage = document.getElementById("display_stage");
        if (displayStage) {
            displayStage.innerHTML = "";
            var paragraph = document.createElement("p");
            paragraph.textContent = "実験プログラムの読み込みに失敗しました。お手数ですが管理者までご連絡ください。";
            displayStage.appendChild(paragraph);
        }

        showNextButton();
    }

    function getUtf8Bytes(text) {
        if (window.TextEncoder) {
            return new TextEncoder().encode(text).length;
        }

        return unescape(encodeURIComponent(text)).length;
    }

    function pad2(number) {
        return number < 10 ? "0" + number : String(number);
    }

    function splitStringByUtf8Bytes(text, maxBytes) {
        var chunks = [];
        var currentChunk = "";
        var currentBytes = 0;

        for (var i = 0; i < text.length;) {
            var codePoint = text.codePointAt(i);
            var character = String.fromCodePoint(codePoint);
            var characterBytes = getUtf8Bytes(character);

            if (currentChunk && currentBytes + characterBytes > maxBytes) {
                chunks.push(currentChunk);
                currentChunk = character;
                currentBytes = characterBytes;
            } else {
                currentChunk += character;
                currentBytes += characterBytes;
            }

            i += character.length;
        }

        if (currentChunk || text.length === 0) {
            chunks.push(currentChunk);
        }

        return chunks;
    }

    function showDataSizeDebug(datajs, chunks) {
        var totalBytes = getUtf8Bytes(datajs);
        var chunkSizes = chunks.map(function (chunk, index) {
            return {
                field: "datajs" + pad2(index + 1),
                characters: chunk.length,
                bytes: getUtf8Bytes(chunk)
            };
        });

        console.log("datajs total characters:", datajs.length);
        console.log("datajs total bytes_utf8:", totalBytes);
        console.log("datajs split max bytes:", maxChunkBytes);
        console.log("datajs split parts:", chunks.length);
        console.log("datajs split chunk sizes:", chunkSizes);

        if (!debugDataSize) {
            return;
        }

        var displayStage = document.getElementById("display_stage");
        if (!displayStage) {
            return;
        }

        var debugBox = document.createElement("pre");
        debugBox.id = "datajs_debug";
        debugBox.style.whiteSpace = "pre-wrap";
        debugBox.style.textAlign = "left";
        debugBox.style.margin = "24px auto";
        debugBox.style.maxWidth = "900px";
        debugBox.style.padding = "16px";
        debugBox.style.border = "1px solid #999";
        debugBox.style.background = "#f7f7f7";
        debugBox.textContent =
            "datajs split save\n" +
            "total characters: " + datajs.length + "\n" +
            "total bytes_utf8: " + totalBytes + "\n" +
            "max chunk bytes: " + maxChunkBytes + "\n" +
            "parts: " + chunks.length + "\n" +
            "first field: datajs01\n" +
            "last field: datajs" + pad2(chunks.length) + "\n" +
            "preview:\n" + datajs.slice(0, 300);

        // displayStage.innerHTML = "";
        // displayStage.appendChild(debugBox);
    }

    function saveQualtricsData(key, value) {
        if (typeof Qualtrics.SurveyEngine.setJSEmbeddedData === "function") {
            Qualtrics.SurveyEngine.setJSEmbeddedData(key, value);
            return;
        }

        if (typeof Qualtrics.SurveyEngine.setEmbeddedData === "function") {
            Qualtrics.SurveyEngine.setEmbeddedData(key, value);
            return;
        }

        throw new Error("Qualtrics embedded data API is not available.");
    }

    function saveSplitData(datajs) {
        var chunks = splitStringByUtf8Bytes(datajs, maxChunkBytes);

        showDataSizeDebug(datajs, chunks);

        for (var i = 0; i < chunks.length; i++) {
            saveQualtricsData("datajs" + pad2(i + 1), chunks[i]);
        }

        console.log("datajs saved embedded data count:", chunks.length);
    }

    var displayStage = null;

    function initExp() {
        window.jsPsych = initJsPsych({
            display_element: displayStage,
            on_finish: function (data) {
                try {
                    var datajs = data.json();
                    saveSplitData(datajs);
                } catch (error) {
                    showLoadError("Failed to save jsPsych data", error);
                    return;
                }

                window.setTimeout(function () {
                    removeElement("display_stage");
                    removeElement("display_stage_background");
                    qthis.clickNextButton();
                }, debugDataSize ? 5000 : 1000);
            }
        });

        loadJsScript(assetUrl("main.js")).then(function () {
            jsPsych.run(timeline);
        }).catch(function (error) {
            showLoadError("Failed to load main.js", error);
        });
    }

    if (window.Qualtrics && (!window.frameElement || window.frameElement.id !== "mobile-preview-view")) {
        if (window.__jsPsychQualtricsV2SplitStarted) {
            return;
        }

        window.__jsPsychQualtricsV2SplitStarted = true;
        createDisplayElement("display_stage_background");
        displayStage = createDisplayElement("display_stage");

        Promise.all([
            loadCss(assetUrl("jspsych/dist/jspsych.css")),
            loadScriptsInOrder(jsPsychScripts)
        ]).then(initExp).catch(function (error) {
            showLoadError("Failed to load jsPsych v8 resources", error);
        });
    }
});

Qualtrics.SurveyEngine.addOnUnload(function () {
    /*Place your JavaScript here to run when the page is unloaded*/

});

jsPsych ver6向けコード:QualtricsのJavaScriptブロックに記載

以下のコードは、これまでの記事のqualtrics.js部分で実施する内容です。

Qualtrics.SurveyEngine.addOnload(function () {
    /*Place your JavaScript here to run when the page loads*/

});

Qualtrics.SurveyEngine.addOnReady(function () {
    /*Place your JavaScript here to run when the page is fully displayed*/
    var qthis = this;
    qthis.hideNextButton();

    var task_github = "自分のサーバーのリンク"; // https://<your-github-username>.github.io/<your-experiment-name>/
    var taskBase = task_github.charAt(task_github.length - 1) === "/" ? task_github : task_github + "/";
    var assetVersion = "20260616_01";
    var maxChunkBytes = 19000;
    var debugDataSize = true;

    var requiredResources = [
        assetUrl("jspsych-6.1.0/jspsych.js"),
        assetUrl("jspsych-6.1.0/plugins/jspsych-html-keyboard-response.js"),
        assetUrl("jspsych-6.1.0/plugins/jspsych-instructions.js"),
        assetUrl("jspsych-6.1.0/plugins/jspsych-iat-html.js"),
        assetUrl("jspsych-6.1.0/plugins/jspsych-fullscreen.js"),
        assetUrl("jspsych-6.1.0/plugins/jspsych-survey-text.js"),
        assetUrl("main.js")
    ];

    function assetUrl(path) {
        return taskBase + path + "?v=" + assetVersion;
    }

    function loadJsScript(src) {
        console.log("Loading ", src);
        return new Promise(function (resolve, reject) {
            var script = document.createElement("script");
            script.src = src;
            script.async = false;
            script.onload = function () {
                resolve(src);
            };
            script.onerror = function () {
                reject(new Error("Failed to load " + src));
            };
            document.head.appendChild(script);
        });
    }

    function loadScriptsInOrder(scripts) {
        return scripts.reduce(function (promise, src) {
            return promise.then(function () {
                return loadJsScript(src);
            });
        }, Promise.resolve());
    }

    function loadCss(href) {
        console.log("Loading ", href);
        return new Promise(function (resolve, reject) {
            var anchor = document.createElement("a");
            anchor.href = href;

            var existingLinks = document.querySelectorAll('link[rel="stylesheet"]');
            for (var i = 0; i < existingLinks.length; i++) {
                if (existingLinks[i].href === anchor.href) {
                    resolve(href);
                    return;
                }
            }

            var link = document.createElement("link");
            link.rel = "stylesheet";
            link.type = "text/css";
            link.href = href;
            link.onload = function () {
                resolve(href);
            };
            link.onerror = function () {
                reject(new Error("Failed to load " + href));
            };
            document.head.appendChild(link);
        });
    }

    function showNextButton() {
        if (typeof qthis.showNextButton === "function") {
            qthis.showNextButton();
        }
    }

    function removeElement(id) {
        var element = document.getElementById(id);
        if (element) {
            element.parentNode.removeChild(element);
        }
    }

    function createDisplayElement(id) {
        var existing = document.getElementById(id);

        if (existing) {
            return existing;
        }

        var element = document.createElement("div");
        element.id = id;
        document.body.appendChild(element);
        return element;
    }

    function showLoadError(message, error) {
        console.error(message, error);

        var displayStage = document.getElementById("display_stage");
        if (displayStage) {
            displayStage.innerHTML = "";
            var paragraph = document.createElement("p");
            paragraph.textContent = "実験プログラムの読み込みに失敗しました。お手数ですが管理者までご連絡ください。";
            displayStage.appendChild(paragraph);
        }

        showNextButton();
    }

    function getUtf8Bytes(text) {
        if (window.TextEncoder) {
            return new TextEncoder().encode(text).length;
        }

        return unescape(encodeURIComponent(text)).length;
    }

    function pad2(number) {
        return number < 10 ? "0" + number : String(number);
    }

    function splitStringByUtf8Bytes(text, maxBytes) {
        var chunks = [];
        var currentChunk = "";
        var currentBytes = 0;

        for (var i = 0; i < text.length;) {
            var codePoint = text.codePointAt(i);
            var character = String.fromCodePoint(codePoint);
            var characterBytes = getUtf8Bytes(character);

            if (currentChunk && currentBytes + characterBytes > maxBytes) {
                chunks.push(currentChunk);
                currentChunk = character;
                currentBytes = characterBytes;
            } else {
                currentChunk += character;
                currentBytes += characterBytes;
            }

            i += character.length;
        }

        if (currentChunk || text.length === 0) {
            chunks.push(currentChunk);
        }

        return chunks;
    }

    function showDataSizeDebug(datajs, chunks) {
        var totalBytes = getUtf8Bytes(datajs);
        var chunkSizes = chunks.map(function (chunk, index) {
            return {
                field: "datajs" + pad2(index + 1),
                characters: chunk.length,
                bytes: getUtf8Bytes(chunk)
            };
        });

        console.log("datajs total characters:", datajs.length);
        console.log("datajs total bytes_utf8:", totalBytes);
        console.log("datajs split max bytes:", maxChunkBytes);
        console.log("datajs split parts:", chunks.length);
        console.log("datajs split chunk sizes:", chunkSizes);

        if (!debugDataSize) {
            return;
        }

        var displayStage = document.getElementById("display_stage");
        if (!displayStage) {
            return;
        }

        var debugBox = document.createElement("pre");
        debugBox.id = "datajs_debug";
        debugBox.style.whiteSpace = "pre-wrap";
        debugBox.style.textAlign = "left";
        debugBox.style.margin = "24px auto";
        debugBox.style.maxWidth = "900px";
        debugBox.style.padding = "16px";
        debugBox.style.border = "1px solid #999";
        debugBox.style.background = "#f7f7f7";
        debugBox.textContent =
            "datajs split save\n" +
            "total characters: " + datajs.length + "\n" +
            "total bytes_utf8: " + totalBytes + "\n" +
            "max chunk bytes: " + maxChunkBytes + "\n" +
            "parts: " + chunks.length + "\n" +
            "first field: datajs01\n" +
            "last field: datajs" + pad2(chunks.length) + "\n" +
            "preview:\n" + datajs.slice(0, 300);

        // displayStage.innerHTML = "";
        // displayStage.appendChild(debugBox);
    }

    function saveQualtricsData(key, value) {
        if (typeof Qualtrics.SurveyEngine.setJSEmbeddedData === "function") {
            Qualtrics.SurveyEngine.setJSEmbeddedData(key, value);
            return;
        }

        if (typeof Qualtrics.SurveyEngine.setEmbeddedData === "function") {
            Qualtrics.SurveyEngine.setEmbeddedData(key, value);
            return;
        }

        throw new Error("Qualtrics embedded data API is not available.");
    }

    function saveSplitData(datajs) {
        var chunks = splitStringByUtf8Bytes(datajs, maxChunkBytes);

        showDataSizeDebug(datajs, chunks);

        for (var i = 0; i < chunks.length; i++) {
            saveQualtricsData("datajs" + pad2(i + 1), chunks[i]);
        }

        console.log("datajs saved embedded data count:", chunks.length);
    }

    function initExp() {
        jsPsych.init({
            timeline: timeline,
            display_element: "display_stage",
            on_finish: function () {
                try {
                    var datajs = jsPsych.data.get().json();
                    saveSplitData(datajs);
                } catch (error) {
                    showLoadError("Failed to save jsPsych data", error);
                    return;
                }

                window.setTimeout(function () {
                    removeElement("display_stage");
                    removeElement("display_stage_background");
                    qthis.clickNextButton();
                }, debugDataSize ? 5000 : 1000);
            }
        });
    }

    if (window.Qualtrics && (!window.frameElement || window.frameElement.id !== "mobile-preview-view")) {
        if (window.__jsPsychQualtricsV6SplitStarted) {
            return;
        }

        window.__jsPsychQualtricsV6SplitStarted = true;
        createDisplayElement("display_stage_background");
        createDisplayElement("display_stage");

        Promise.all([
            loadCss(assetUrl("jspsych-6.1.0/css/jspsych.css")),
            loadScriptsInOrder(requiredResources)
        ]).then(initExp).catch(function (error) {
            showLoadError("Failed to load jsPsych v6 resources", error);
        });
    }
});

Qualtrics.SurveyEngine.addOnUnload(function () {
    /*Place your JavaScript here to run when the page is unloaded*/

});



データ読み込みのRコード

library(dplyr)
library(tibble)
library(jsonlite)
library(stringr)
library(qualtRics)

# データ読み込み(Qualtricsのデータそのまま)
dat <- qualtRics::read_survey("raw_data.csv")

# jsPsychのデータを取り出す
js_data <- dat %>% 
  select(ResponseId, matches("^__js_datajs[0-9]+$")) %>% 
  filter(if_any(matches("^__js_datajs[0-9]+$"), ~ !is.na(.x) & .x != "")) |> 
  as.data.frame()


js_df <- tibble()

## 分割されたjsPsychデータを抽出する関数
jspsych_extract2 <- function(data){
  js_df <- tibble()
  
  # __js_datajs01, __js_datajs02, ... を取得
  datajs_cols <- names(data)[str_detect(names(data), "^__js_datajs[0-9]+$")]
  
  # 数字部分で並び替え
  datajs_cols <- datajs_cols[order(as.numeric(str_extract(datajs_cols, "[0-9]+$")))]
  
  for(i in 1:nrow(data)){
    
    # Qualtricsから出力された分割データを番号順に結合
    tidy_df01 <- data[i, datajs_cols] |> 
      unlist(use.names = FALSE) |> 
      as.character()
    
    # NAや空文字を除外
    tidy_df01 <- tidy_df01[!is.na(tidy_df01) & tidy_df01 != ""]
    
    # 分割されたJSON文字列を1つに戻す
    tidy_text <- paste0(tidy_df01, collapse = "")
    
    # txtとして書き出し
    writeLines(tidy_text, "output.txt", useBytes = TRUE)
    
    # jsonとして読み込み整理
    tidy_df02 <- fromJSON("output.txt") %>% 
      tibble::as_tibble(.name_repair = "minimal") %>% 
      dplyr::mutate(ResponseId = data[i, 1])
    
    # 他の参加者と結合する
    if(i == 1){
      js_df <- as.data.frame(tidy_df02)
    }else{
      js_df <- union(js_df, as.data.frame(tidy_df02))
    }
  }
  
  return(js_df)
}

js_df <- jspsych_extract2(js_data)

出てきた__js_datajs0xを、一つのテキストファイルに出力し読み込むRコードです。Qualtricsから出力されるcsvをそのまま読み込みこんで使うことができます。