speak post
// ==UserScript==
// @name bbsspeak
// @namespace http://tampermonkey.net/
// @version 2026-01-02
// @description speak post
// @author fthvgb1
// @match https://*/*
// @grant GM_getValue
// @grant GM_setValue
// ==/UserScript==
(function () {
'use strict';
const rules = deepAssign({
'bbs': {
'list': '#postlist > div[id^=post_]',
'items': {
'content': {
selector: '.t_f,.t_fsz',
removes: '.pstatus,.quote, a',
},
'author': '.xw1',
//'date': '.authi em span, .authi em',
},
'stick': '.pi > strong a',
'describeFormat': '{author}说道:{content}' //default format
},
'v2ex.com': {
'list': '#Main > .box:nth-child(2),.box > div[id^=r_]',
'items': {
'content': {
selector: '.topic_content,.markdown_body:not(.topic_content>.markdown_body),.reply_content',
multiple: true,
'replaces': {
' ': ' ',
'@(.+?) ': '对$1说:',
'^(?!对.*?说)(.*)': '说道:$1'
},
'attribute': 'textContent', // default innerText
'removes': 'a:not([href^="/member"])',
},
'no': {
selector: '.no',
replaces: {
"(\\d+)": '$1楼的'
},
'defaultValue': '楼主',
},
'author': '.gray > a,.dark',
//'date': '.ago',
},
'stick': '.gray > span,.no',
'describeFormat': '{no} {author} {content}',
},
}, GM_getValue('rules', {}));
console.log('speak bbs');
const rule = rules[location.host] ?? rules['bbs'];
function replaceVars(vars, str) {
return Object.keys(vars).reduce((str, key) => str.replaceAll(`{${key}}`, vars[key]), str);
}
function deepAssign(target, ...sources) {
for (const source of sources) {
for (let k in source) {
let vs = source[k], vt = target[k]
if (Object(vs) === vs && Object(vt) === vt) {
target[k] = deepAssign(vt, vs)
continue
}
target[k] = source[k]
}
}
return target
}
function extractValue(varEle, item) {
if (!varEle) {
return item?.defaultValue ?? '';
}
if (item?.removes) {
varEle = varEle.cloneNode(true);
varEle.querySelectorAll(item.removes)?.forEach(el => el.remove());
}
let value = varEle?.[item?.attribute] ?? varEle.innerText;
if (item?.replaces) {
value = Object.keys(item.replaces).reduce((val, key) => {
try {
val = val.replace(new RegExp(key, 'g'), item.replaces[key])
} catch (e) {
val = val.replaceAll(key, item.replaces[key]);
}
return val;
}, value)
}
return value ? value : item?.defaultValue;
}
function getVars(div, rule) {
const vars = {}, fields = Object.keys(rule.items);
const values = fields.map(k => {
const item = rule.items[k];
if (!item) {
return '';
}
if (typeof item === 'string') {
return div.querySelector(item)?.innerText ?? '';
}
if (typeof item !== 'object' || !item?.selector) {
return '';
}
if (!item?.multiple) {
return extractValue(div.querySelector(item.selector), item);
}
return [...div.querySelectorAll(item.selector)].map(el => extractValue(el, item)).join('\n');
});
fields.forEach((key, i) => vars[key] = values[i]);
return vars;
}
function getText(div, rule) {
const vars = getVars(div, rule);
const fields = Object.keys(vars);
return replaceVars(vars, rule?.describeFormat ?? `{${fields.join('} {')}`);
}
function initiation() {
let voices = speechSynthesis.getVoices();
if (!voices) {
speechSynthesis.addEventListener('voiceschanged', () => voices = speechSynthesis.getVoices());
}
const langVoice = GM_getValue(`langVoice_${location.host}`, (() => {
let lang = document.documentElement.lang ? document.documentElement.lang : navigator.language;
lang = lang.toLowerCase();
for (const i in voices) {
if (voices[i].lang.toLowerCase() === lang) {
return i;
}
}
return 0;
})());
const voice = voices[langVoice] ?? null;
const utterance = new SpeechSynthesisUtterance();
utterance.voice = voice;
const speak = text => {
utterance.text = text;
speechSynthesis.speak(utterance);
}
const posts = [...document.querySelectorAll(rule.list)];
posts.forEach((div, i) => {
const a = document.createElement('a');
let count = 0;
a.addEventListener('mousedown', ev => {
if (ev.button !== 0) {
return;
}
if (count > 0) {
count++;
return
}
count++;
const t = setTimeout(() => {
clearTimeout(t);
if (count > 1) {
count = 0;
if (rule?.callback) {
rule.callback(posts.slice(i), rule);
} else {
speak(posts.slice(i).map(item => getText(item, rule)).join('\n'));
}
return
}
count = 0;
rule?.callback ? rule.callback(div, rule) : speak(getText(div, rule));
}, 500)
});
const select = document.createElement('select');
select.addEventListener('change', ev => {
const v = parseInt(select.value);
utterance.voice = voices[v];
GM_setValue(`langVoice_${location.host}`, v);
select.replaceWith(a);
})
const arr = voices.map((v, i) => [`${v.lang} - ${v.localService ? 'local' : ''}-${v.name}`, i]);
select.innerHTML = buildOption(arr, langVoice, 1, 0);
a.addEventListener('contextmenu', ev => {
ev.preventDefault();
a.replaceWith(select);
});
a.innerText = '📢';
a.title = '左键单击朗读此楼,双击键朗读此楼及后面的回复,右键选择语音';
a.href = 'javascript:void(0)';
const stick = rule.stick.split('|');
div.querySelector(stick[0])?.insertAdjacentElement(stick?.[1] ?? 'afterend', a);
});
}
function buildOption(arr, select = '', key = 'k', val = 'v', attr = null) {
const sels = new Set();
if (Array.isArray(select)) {
select.forEach(sels.add);
} else if (select) {
sels.add(select);
}
return arr.map(v => {
let att = '', sel = '';
if (attr !== null && v[attr] && typeof v[attr] === 'object') {
att = Object.keys(v[attr]).map(k => `${k}="${v[attr][k]}"`).join(' ');
}
if (typeof v === 'string' || typeof v === 'number') {
sel = sels.has(v) ? 'selected' : '';
return `<option ${att} ${sel} value="${v}">${v}</option>`
} else if (typeof v === 'object' || v instanceof Array) {
sel = sels.has(v[key]) ? 'selected' : '';
return `<option ${att} ${sel} value="${v[key]}">${v[val]}</option>`
}
return ''
}).join('\n');
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initiation)
return
}
initiation();
})();