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をそのまま読み込みこんで使うことができます。
