import { ParameterType } from 'jspsych'; const info = { name: 'captcha', parameters: { prompt: { type: ParameterType.HTML_STRING, default: '', }, difficulty: { type: ParameterType.INT, default: 3, }, button_label: { type: ParameterType.STRING, default: 'Submit', }, allow_refresh: { type: ParameterType.BOOL, default: true, }, require_correct: { type: ParameterType.BOOL, default: true, }, error_text: { type: ParameterType.HTML_STRING, default: 'That entry did not match, please try again.', }, }, }; class CaptchaPlugin { constructor(jsPsych) { this.jsPsych = jsPsych; this.characters = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; } trial(display_element, trial) { const start_time = performance.now(); const attempt_counts = { submission_count: 0, refresh_count: 0 }; const difficulty_settings = this.resolve_difficulty(trial.difficulty); let captcha_text = this.generate_text(difficulty_settings.length); let last_target_at_submission = captcha_text; display_element.innerHTML = this.build_markup( trial.prompt, trial.button_label, trial.allow_refresh ); const canvas_element = display_element.querySelector( '.jspsych_captcha_canvas' ); const canvas_context = canvas_element.getContext('2d'); const input_element = display_element.querySelector( '.jspsych_captcha_input' ); const button_element = display_element.querySelector( '.jspsych_captcha_submit' ); const refresh_button = display_element.querySelector( '.jspsych_captcha_refresh' ); const error_element = display_element.querySelector( '.jspsych_captcha_error' ); const draw_captcha = () => { this.draw_captcha(canvas_context, captcha_text, difficulty_settings); }; draw_captcha(); input_element.focus(); if (refresh_button) { refresh_button.addEventListener('click', () => { attempt_counts.refresh_count += 1; captcha_text = this.generate_text(difficulty_settings.length); draw_captcha(); error_element.textContent = ''; input_element.value = ''; input_element.focus(); }); } const length_hint_element = display_element.querySelector( '.jspsych_captcha_length_hint' ); const update_button_state = () => { const current_length = input_element.value.trim().length; const required_length = captcha_text.length; button_element.disabled = current_length < required_length; if (length_hint_element) { length_hint_element.textContent = current_length < required_length ? `Type ${required_length - current_length} more character(s) to continue.` : ''; } }; const finish_trial = (response_value, is_correct) => { const trial_data = { rt: performance.now() - start_time, response: response_value, participant_entry: response_value, participant_entry_length: response_value.length, captcha_target: last_target_at_submission, correct: is_correct, submission_count: attempt_counts.submission_count, manual_refreshes: attempt_counts.refresh_count, difficulty_label: difficulty_settings.label, difficulty_length: difficulty_settings.length, difficulty_level: difficulty_settings.level, }; display_element.innerHTML = ''; this.jsPsych.finishTrial(trial_data); }; const validate_response = () => { attempt_counts.submission_count += 1; const response_value = input_element.value.trim().toUpperCase(); last_target_at_submission = captcha_text; const is_correct = response_value === captcha_text; if (response_value.length < captcha_text.length) { error_element.textContent = `Please enter all ${captcha_text.length} characters shown in the captcha.`; update_button_state(); return; } if (is_correct || !trial.require_correct) { finish_trial(response_value, is_correct); return; } error_element.textContent = trial.error_text; captcha_text = this.generate_text(difficulty_settings.length); draw_captcha(); input_element.value = ''; input_element.focus(); }; button_element.addEventListener('click', validate_response); input_element.addEventListener('keydown', event => { if (event.key === 'Enter') { event.preventDefault(); validate_response(); } }); input_element.addEventListener('input', update_button_state); update_button_state(); } build_markup(prompt, button_label, allow_refresh) { const prompt_html = prompt ? `
${prompt}
` : ''; const refresh_button = allow_refresh ? '' : ''; return `
${prompt_html}
${refresh_button}
`; } draw_captcha(canvas_context, captcha_text, settings) { const { width, height } = canvas_context.canvas; canvas_context.clearRect(0, 0, width, height); const gradient = canvas_context.createLinearGradient(0, 0, width, height); gradient.addColorStop(0, '#f8fafc'); gradient.addColorStop(1, '#e2e8f0'); canvas_context.fillStyle = gradient; canvas_context.fillRect(0, 0, width, height); for (let index = 0; index < settings.noise_lines; index++) { canvas_context.beginPath(); canvas_context.moveTo(Math.random() * width, Math.random() * height); canvas_context.bezierCurveTo( Math.random() * width, Math.random() * height, Math.random() * width, Math.random() * height, Math.random() * width, Math.random() * height ); canvas_context.lineWidth = 1 + Math.random() * 2; canvas_context.strokeStyle = `rgba(30, 41, 59, ${0.15 + Math.random() * 0.2})`; canvas_context.stroke(); } canvas_context.font = `bold ${settings.font_size}px 'Courier New', monospace`; canvas_context.textBaseline = 'middle'; canvas_context.textAlign = 'center'; const horizontal_step = width / (captcha_text.length + 1); const center_y = height / 2; for (let index = 0; index < captcha_text.length; index++) { const current_character = captcha_text[index]; canvas_context.save(); const jitter_y = this.random_between(-settings.jitter, settings.jitter); canvas_context.translate( horizontal_step * (index + 1), center_y + jitter_y ); canvas_context.rotate( this.random_between(-settings.rotation, settings.rotation) ); canvas_context.fillStyle = '#000000'; canvas_context.fillText(current_character, 0, 0); canvas_context.restore(); } const noise_pixels = Math.floor(width * height * settings.speckle_density); const image_data = canvas_context.getImageData(0, 0, width, height); for (let index = 0; index < noise_pixels; index++) { const pixel_offset = (Math.floor(Math.random() * width) + Math.floor(Math.random() * height) * width) * 4; const shade = Math.random() > 0.5 ? 255 : 0; image_data.data[pixel_offset] = shade; image_data.data[pixel_offset + 1] = shade; image_data.data[pixel_offset + 2] = shade; image_data.data[pixel_offset + 3] = 255; } canvas_context.putImageData(image_data, 0, 0); } generate_text(length) { let generated_text = ''; for (let index = 0; index < length; index++) { generated_text += this.characters.charAt( Math.floor(Math.random() * this.characters.length) ); } return generated_text; } resolve_difficulty(difficulty_input) { const default_level = 3; let normalized_level = Number(difficulty_input); if (!Number.isFinite(normalized_level)) { normalized_level = default_level; } normalized_level = Math.round(Math.min(6, Math.max(1, normalized_level))); const base_length = 7; const base_font_size = 20; const base_rotation = 0.25; const max_rotation = base_rotation * 3; const base_noise_lines = 4; const base_speckle_density = 0.01; const base_jitter = 25; const noise_line_step = 0.8; const speckle_step = 0.0015; const jitter_step = 1.6; const rotation_step = (max_rotation - base_rotation) / 5; const rotation = base_rotation + rotation_step * (normalized_level - 1); const noise_lines = Math.round( base_noise_lines + noise_line_step * (normalized_level - 1) ); const speckle_density = base_speckle_density + speckle_step * (normalized_level - 1); const jitter = base_jitter + jitter_step * (normalized_level - 1); return { length: base_length, noise_lines: noise_lines, rotation: rotation, jitter: jitter, font_size: base_font_size, speckle_density: speckle_density, label: `level_${normalized_level}`, level: normalized_level, }; } random_between(min, max) { return Math.random() * (max - min) + min; } } CaptchaPlugin.info = info; export default CaptchaPlugin;