// ==UserScript== // @name 打字鸭自动打字脚本 // @namespace https://docs.scriptcat.org/ // @version 1.2.1 // @description 你的作业也忘记提交了吗?快试试这个脚本叭! // @author Micropue // @match https://www.daziya.com/* // @icon https://www.daziya.com/favicon.svg // @grant none // @run-at document-end // @noframes // @license MIT // ==/UserScript== (function () { 'use strict' if (window.PinyinTyper) { window.PinyinTyper.destroy() } const CONFIG = { selector: '.lesson-key-pinyin-code.pinyin-box', container: '.lesson-key-stage.pinyin-box-content', // 严格匹配换行 SVG newlineSelector: 'svg[data-v-d8177754][viewBox="0 0 13 27"]', // 空格元素 spaceSelector: 'span[data-v-d8177754]', typingInterval: 100, nextInterval: 800, changeTimeout: 5000, startDelay: 500, // 每键总耗时(毫秒) defaultKeyInterval: 180, // 网站会丢弃 30ms 内同键的 keydown,留点余量 minKeyInterval: 40, speedStorageKey: 'pinyin-typer-speed-v1', // 普通按键间隔 keyDownDelay: 40, keyPressDelay: 40, // Enter 按键后等待网页响应的时间 enterWait: 350, timerStorageKey: 'pinyin-typer-timer-v2' } // ============================== // 拼音映射 // ============================== const pinyinMap = { 'ā': 'a', 'á': 'a', 'ǎ': 'a', 'à': 'a', 'ē': 'e', 'é': 'e', 'ě': 'e', 'è': 'e', 'ī': 'i', 'í': 'i', 'ǐ': 'i', 'ì': 'i', 'ō': 'o', 'ó': 'o', 'ǒ': 'o', 'ò': 'o', 'ū': 'u', 'ú': 'u', 'ǔ': 'u', 'ù': 'u', 'ǖ': 'v', 'ǘ': 'v', 'ǚ': 'v', 'ǜ': 'v', 'ü': 'v' } const punctuationMap = { ',': ',', '。': '.', '!': '!', '?': '?', ';': ';', ':': ':', '“': '"', '”': '"', '‘': "'", '’': "'", '(': '(', ')': ')', '【': '[', '】': ']', '《': '<', '》': '>', '—': '-' } const allowedChars = /^[a-zA-Z0-9,.!?;:'"()[\]{}<>_、…·\-]$/ // 这些字符需要按住 Shift const shiftChars = new Set([ '!', '?', ':', '"', '(', ')', '_', '<', '>', '{', '}', '…', '·' ]) function convert(text) { return [...text.toLowerCase()] .map(char => { return pinyinMap[char] ?? punctuationMap[char] ?? char }) .filter(char => allowedChars.test(char)) .join('') } // ============================== // 内容提取 // ============================== function getContent() { const container = document.querySelector(CONFIG.container) if (!container) { console.warn('[PinyinTyper] 找不到练习容器') return '' } const result = [] const walker = document.createTreeWalker( container, NodeFilter.SHOW_ELEMENT, { acceptNode(node) { // 拼音元素 if (node.matches(CONFIG.selector)) { return NodeFilter.FILTER_ACCEPT } // 换行 SVG if (node.matches(CONFIG.newlineSelector)) { return NodeFilter.FILTER_ACCEPT } // 空格元素 if ( node.matches(CONFIG.spaceSelector) && node.textContent === ' ' ) { return NodeFilter.FILTER_ACCEPT } // 不能使用 FILTER_REJECT // 否则会连同子元素一起跳过 return NodeFilter.FILTER_SKIP } } ) let node while ((node = walker.nextNode())) { if (node.matches(CONFIG.newlineSelector)) { result.push('\n') continue } // 先判断拼音框,避免空格框被下面的空格分支 // 和它内部的空格 span 各算一次 if (node.matches(CONFIG.selector)) { const converted = convert(node.textContent) if (converted) { result.push(converted) } continue } if ( node.matches(CONFIG.spaceSelector) && node.textContent === ' ' ) { result.push(' ') } } return result.join('') } // ============================== // 键盘事件 // ============================== const keyCodeMap = { ' ': { keyCode: 32, code: 'Space' }, ',': { keyCode: 188, code: 'Comma' }, '.': { keyCode: 190, code: 'Period' }, '/': { keyCode: 191, code: 'Slash' }, ';': { keyCode: 186, code: 'Semicolon' }, "'": { keyCode: 222, code: 'Quote' }, '[': { keyCode: 219, code: 'BracketLeft' }, ']': { keyCode: 221, code: 'BracketRight' }, '{': { keyCode: 219, code: 'BracketLeft' }, '}': { keyCode: 221, code: 'BracketRight' }, '、': { keyCode: 220, code: 'Backslash' }, '·': { keyCode: 192, code: 'Backquote' }, '…': { keyCode: 54, code: 'Digit6' }, '-': { keyCode: 189, code: 'Minus' }, '_': { keyCode: 189, code: 'Minus' }, '=': { keyCode: 187, code: 'Equal' }, '<': { keyCode: 188, code: 'Comma' }, '>': { keyCode: 190, code: 'Period' }, '(': { keyCode: 57, code: 'Digit9' }, ')': { keyCode: 48, code: 'Digit0' }, '!': { keyCode: 49, code: 'Digit1' }, '?': { keyCode: 191, code: 'Slash' }, ':': { keyCode: 186, code: 'Semicolon' }, '"': { keyCode: 222, code: 'Quote' } } const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) function createKeyboardEvent(type, options) { return new KeyboardEvent(type, { ...options, bubbles: true, cancelable: true, composed: true }) } function getKeyTarget() { // 不要用 activeElement:点过“开始运行”后它是 button, // 网站会忽略落在 button 上的 Enter/空格 return document.querySelector(CONFIG.container) || document.body } // 普通按键:按下 -> 按住 -> 松开 async function dispatchKey(char) { const target = getKeyTarget() const upper = char.toUpperCase() const info = keyCodeMap[char] || { keyCode: char.length === 1 ? char.toUpperCase().charCodeAt(0) : 0, code: /^[a-z]$/i.test(char) ? `Key${upper}` : '' } const options = { key: char, code: info.code, keyCode: info.keyCode, which: info.keyCode, charCode: char.charCodeAt(0), shiftKey: shiftChars.has(char) } target.dispatchEvent( createKeyboardEvent('keydown', options) ) await sleep(CONFIG.keyDownDelay) target.dispatchEvent( createKeyboardEvent('keypress', options) ) await sleep(CONFIG.keyPressDelay) target.dispatchEvent( createKeyboardEvent('keyup', options) ) } // Enter:只发送 keydown 和 keyup // 不发送 keypress,避免部分网页重复处理 async function dispatchEnter() { const target = getKeyTarget() const options = { key: 'Enter', code: 'Enter', keyCode: 13, which: 13, charCode: 0 } target.dispatchEvent( createKeyboardEvent('keydown', options) ) await sleep(CONFIG.keyDownDelay) target.dispatchEvent( createKeyboardEvent('keyup', options) ) // 等待网页完成换行处理 await sleep(CONFIG.enterWait) } // ============================== // 全局计时器 // ============================== function loadTimer() { try { const saved = JSON.parse( localStorage.getItem(CONFIG.timerStorageKey) ) return { elapsed: Math.max(0, Number(saved?.elapsed) || 0), startedAt: Number(saved?.startedAt) || null, running: Boolean(saved?.running && saved?.startedAt) } } catch { return { elapsed: 0, startedAt: null, running: false } } } const timer = loadTimer() function getElapsed() { if (!timer.running || !timer.startedAt) { return timer.elapsed } return timer.elapsed + ( Date.now() - timer.startedAt ) } function saveTimer() { try { localStorage.setItem( CONFIG.timerStorageKey, JSON.stringify({ elapsed: getElapsed(), startedAt: timer.running ? Date.now() : null, running: timer.running }) ) } catch (error) { console.warn('[PinyinTyper] 计时器保存失败', error) } } function startTimer() { if (timer.running) return timer.running = true timer.startedAt = Date.now() saveTimer() updateTimerUI() } function pauseTimer() { if (!timer.running) return timer.elapsed = getElapsed() timer.running = false timer.startedAt = null saveTimer() updateTimerUI() } function resetTimer() { timer.elapsed = 0 timer.running = false timer.startedAt = null saveTimer() updateTimerUI() } function formatTime(ms) { const totalSeconds = Math.floor(ms / 1000) const hours = Math.floor(totalSeconds / 3600) const minutes = Math.floor((totalSeconds % 3600) / 60) const seconds = totalSeconds % 60 return [ String(hours).padStart(2, '0'), String(minutes).padStart(2, '0'), String(seconds).padStart(2, '0') ].join(':') } // ============================== // 创建悬浮面板 // ============================== const panel = document.createElement('div') panel.id = 'pinyin-typer-panel' panel.innerHTML = `
Pinyin Typer
运行状态 已停止
180 ms
最小 40 ms(再快网站会漏键),数值越大越慢
已暂停
00:00:00
-
0
等待开始...
` const style = document.createElement('style') style.textContent = ` #pinyin-typer-panel { position: fixed; right: 24px; bottom: 24px; width: 310px; padding: 18px; z-index: 999999; background: rgba(20, 24, 32, .97); color: #fff; border: 1px solid rgba(255,255,255,.12); border-radius: 16px; box-shadow: 0 12px 50px rgba(0,0,0,.3); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-size: 13px; backdrop-filter: blur(20px); } #pinyin-typer-panel * { box-sizing: border-box; } #pinyin-typer-panel button { border: 0; cursor: pointer; font: inherit; } #pinyin-typer-panel .pt-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 18px; } #pinyin-typer-panel .pt-title { font-size: 15px; font-weight: 700; display: flex; align-items: center; gap: 9px; } #pinyin-typer-panel .pt-dot { width: 8px; height: 8px; border-radius: 50%; background: #777; } #pinyin-typer-panel.active .pt-dot { background: #35d99a; box-shadow: 0 0 12px #35d99a; } #pinyin-typer-panel .pt-close { background: transparent; color: #888; font-size: 23px; } #pinyin-typer-panel .pt-status { display: flex; justify-content: space-between; color: #999; margin-bottom: 12px; } #pinyin-typer-panel .pt-state { color: #aaa; } #pinyin-typer-panel.active .pt-state { color: #35d99a; } #pinyin-typer-panel .pt-speed { padding: 12px; margin-bottom: 18px; background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.07); border-radius: 12px; } #pinyin-typer-panel .pt-speed-heading { display: flex; align-items: center; justify-content: space-between; } #pinyin-typer-panel .pt-speed-heading label { margin: 0; } #pinyin-typer-panel .pt-speed-value { color: #35d99a; font-size: 11px; font-variant-numeric: tabular-nums; } #pinyin-typer-panel .pt-speed-input { width: 100%; margin-top: 8px; padding: 8px 10px; background: #242a35; color: #fff; border: 1px solid rgba(255,255,255,.1); border-radius: 8px; font: inherit; font-variant-numeric: tabular-nums; } #pinyin-typer-panel .pt-speed-hint { margin-top: 6px; color: #656d7c; font-size: 10px; } #pinyin-typer-panel .pt-timer { padding: 15px; margin-bottom: 18px; background: rgba(255,255,255,.055); border: 1px solid rgba(255,255,255,.07); border-radius: 12px; } #pinyin-typer-panel .pt-timer-heading { display: flex; align-items: center; justify-content: space-between; } #pinyin-typer-panel .pt-timer-heading label { margin: 0; } #pinyin-typer-panel .pt-timer-status { font-size: 11px; color: #9299a8; } #pinyin-typer-panel .pt-timer.running .pt-timer-status { color: #35d99a; } #pinyin-typer-panel .pt-timer-value { font-size: 30px; font-weight: 700; letter-spacing: 2px; font-variant-numeric: tabular-nums; margin: 10px 0; color: #fff; } #pinyin-typer-panel .pt-timer-actions { display: flex; justify-content: flex-end; } #pinyin-typer-panel .pt-timer-reset { background: transparent; color: #9299a8; font-size: 11px; padding: 4px 0; } #pinyin-typer-panel .pt-progress { height: 5px; background: #343945; border-radius: 10px; overflow: hidden; margin-bottom: 18px; } #pinyin-typer-panel .pt-progress-bar { width: 0; height: 100%; background: #35d99a; transition: width .15s; } #pinyin-typer-panel .pt-info { display: flex; gap: 12px; margin-bottom: 18px; } #pinyin-typer-panel .pt-info > div { flex: 1; padding: 12px; background: rgba(255,255,255,.06); border-radius: 10px; } #pinyin-typer-panel label { display: block; color: #9299a8; font-size: 11px; margin-bottom: 8px; } #pinyin-typer-panel .pt-info strong { font-size: 22px; } #pinyin-typer-panel .pt-current { color: #35d99a; } #pinyin-typer-panel .pt-preview { padding: 12px; background: rgba(255,255,255,.045); border-radius: 10px; margin-bottom: 16px; } #pinyin-typer-panel .pt-content { color: #d9deea; line-height: 1.7; word-break: break-all; max-height: 100px; overflow: auto; white-space: pre-wrap; } #pinyin-typer-panel .pt-actions { display: flex; gap: 10px; } #pinyin-typer-panel .pt-actions button { flex: 1; padding: 11px; border-radius: 9px; font-weight: 600; } #pinyin-typer-panel .pt-start { background: #35d99a; color: #10251e; } #pinyin-typer-panel .pt-reset { background: #343945; color: #fff; } #pinyin-typer-panel .pt-footer { text-align: center; color: #656d7c; font-size: 10px; margin-top: 14px; } ` document.head.appendChild(style) document.body.appendChild(panel) // ============================== // 面板元素 // ============================== const $ = selector => panel.querySelector(selector) const stateEl = $('.pt-state') const currentEl = $('.pt-current') const remainingEl = $('.pt-remaining') const contentEl = $('.pt-content') const progressEl = $('.pt-progress-bar') const startBtn = $('.pt-start') const timerEl = $('.pt-timer') const timerValueEl = $('.pt-timer-value') const timerStatusEl = $('.pt-timer-status') const speedInput = $('.pt-speed-input') const speedValueEl = $('.pt-speed-value') let running = false let destroyed = false let timerInterval = null function loadSpeed() { try { const raw = localStorage.getItem( CONFIG.speedStorageKey ) if (raw === null) { return CONFIG.defaultKeyInterval } const saved = Number(raw) return Number.isFinite(saved) && saved >= 0 ? saved : CONFIG.defaultKeyInterval } catch { return CONFIG.defaultKeyInterval } } function applySpeed(value) { let interval = Math.round(Number(value)) if (!Number.isFinite(interval) || interval < 0) { interval = 0 } interval = Math.max( CONFIG.minKeyInterval, Math.min(interval, 60000) ) const hold = Math.min(40, Math.floor(interval / 3)) CONFIG.typingInterval = Math.max(0, interval - hold * 2) CONFIG.keyDownDelay = hold CONFIG.keyPressDelay = hold speedInput.value = interval speedValueEl.textContent = `${interval} ms` try { localStorage.setItem( CONFIG.speedStorageKey, String(interval) ) } catch {} } speedInput.addEventListener('change', () => { applySpeed(speedInput.value) }) applySpeed(loadSpeed()) // 用于防止旧任务干扰新任务 let runGeneration = 0 // ============================== // UI 更新 // ============================== function updateUI(status) { panel.classList.toggle('active', running) stateEl.textContent = status || (running ? '运行中' : '已停止') startBtn.textContent = running ? '暂停运行' : '开始运行' } function updateProgress(index, total, char) { currentEl.textContent = char === '\n' ? '换行 ↵' : char === ' ' ? '空格' : char || '-' remainingEl.textContent = Math.max( 0, total - index - 1 ) const progress = total ? ((index + 1) / total) * 100 : 0 progressEl.style.width = `${progress}%` } function updateTimerUI() { timerValueEl.textContent = formatTime(getElapsed()) timerEl.classList.toggle('running', timer.running) timerStatusEl.textContent = timer.running ? '● 计时中' : '已暂停' } timerInterval = setInterval(() => { if (destroyed) return updateTimerUI() }, 250) // ============================== // 等待下一段内容 // ============================== async function waitForNextContent(previous, generation) { const startTime = Date.now() while ( running && generation === runGeneration && Date.now() - startTime < CONFIG.changeTimeout ) { await sleep(200) if (!running || generation !== runGeneration) { return '' } const next = getContent() if (next && next !== previous) { return next } } return '' } // ============================== // 主运行逻辑 // ============================== async function run() { if (running || destroyed) return // 开始前再同步一次输入框,防止只改数字没触发 change applySpeed(speedInput.value) running = true const generation = ++runGeneration startTimer() updateUI() try { await sleep(CONFIG.startDelay) while (running && generation === runGeneration) { const content = getContent() if (!content) { updateUI('未找到内容') break } contentEl.textContent = content console.log( '[PinyinTyper] 当前内容:', JSON.stringify(content) ) for (let i = 0; i < content.length; i++) { if (!running || generation !== runGeneration) { break } const char = content[i] updateProgress(i, content.length, char) if (char === '\n') { console.log( `[PinyinTyper] 第 ${i + 1} 个字符:换行` ) await dispatchEnter() } else { await dispatchKey(char) } if (!running || generation !== runGeneration) { break } await sleep(CONFIG.typingInterval) } if (!running || generation !== runGeneration) { break } currentEl.textContent = 'Enter' remainingEl.textContent = '0' await sleep(200) if (!running || generation !== runGeneration) { break } // 如果最后一个字符已经是换行标记, // 就不再重复发送 Enter if (!content.endsWith('\n')) { console.log('[PinyinTyper] 整段结束,发送 Enter') await dispatchEnter() } else { console.log('[PinyinTyper] 内容已包含末尾换行,跳过额外 Enter') } await sleep(CONFIG.nextInterval) if (!running || generation !== runGeneration) { break } const nextContent = await waitForNextContent( content, generation ) if (!running || generation !== runGeneration) { break } if (!nextContent) { updateUI('等待超时') console.warn( '[PinyinTyper] 页面内容没有更新,已停止。', { previousContent: content, currentContent: getContent() } ) break } console.log( '[PinyinTyper] 下一段:', nextContent ) } } catch (error) { console.error('[PinyinTyper] 运行错误:', error) updateUI('发生错误') } finally { // 只有当前任务可以修改运行状态 if (generation === runGeneration) { running = false pauseTimer() updateUI() } } } // ============================== // 停止与重置 // ============================== function stop() { runGeneration++ running = false pauseTimer() updateUI() } function reset() { stop() currentEl.textContent = '-' remainingEl.textContent = '0' contentEl.textContent = '等待开始...' progressEl.style.width = '0' resetTimer() } function destroy() { stop() destroyed = true clearInterval(timerInterval) panel.remove() style.remove() delete window.PinyinTyper } // ============================== // 事件绑定 // ============================== startBtn.addEventListener('click', () => { if (running) { stop() } else { run() } }) $('.pt-reset').addEventListener('click', reset) $('.pt-close').addEventListener('click', destroy) $('.pt-timer-reset').addEventListener('click', () => { resetTimer() }) // ============================== // 暴露 API // ============================== window.PinyinTyper = { start: run, stop, reset, destroy, getContent, convert, getElapsed, startTimer, pauseTimer, resetTimer, get running() { return running }, get timerRunning() { return timer.running } } console.log('[PinyinTyper] 已安装') console.log('开始:PinyinTyper.start()') console.log('停止:PinyinTyper.stop()') console.log('读取内容:PinyinTyper.getContent()') console.log('累计时间:PinyinTyper.getElapsed()') console.log('重置计时:PinyinTyper.resetTimer()') console.log('卸载:PinyinTyper.destroy()') updateUI() updateTimerUI() })()