// ==UserScript==
// @name 谷歌搜索自定义风格总结
// @description 多风格、多模型可选/添加 预定义角色可选/添加 + 可拖拽面板 + TTS + 用的硅基API,国内模型对某些风格并不支持,你们可以自行修改,而且其他浏览器你们也可以扩展下
// @author songmile
// @version 1.4
// @license MIT
// @namespace http://tampermonkey.net/
// @match https://www.google.com/search*
// @run-at document-end
// @grant GM.setValue
// @grant GM.getValue
// ==/UserScript==
(async () => {
let defaultEndpoint = "https://api.siliconflow.cn/v1/chat/completions"
let defaultToken = "<token>"
let qwenEndpoint = await GM.getValue("QWEN_API_ENDPOINT", defaultEndpoint)
let qwenToken = await GM.getValue("QWEN_BEARER_TOKEN", defaultToken)
let modelList = [
"Qwen/Qwen2.5-7B-Instruct",
"deepseek-ai/DeepSeek-V2.5",
"Qwen/QVQ-72B-Preview"
]
let modelMode = modelList[0]
let rolePrompts = {
"猫娘风格": "以猫娘口吻写作,大约 100 字,用中文。专业但可爱,常用“喵、嗯、好耶,好棒”一类语气词,对观点进行小短评。",
"导师PUA风": "使用强烈的批评和打击,带一点激励,整体像导师对不满意的研究生。参考示例:“说实话,我对你最近的表现很失望。当初破格收你进实验室,是看中你的潜力的……你要是再发不出一作,明年的开题就别想了。”语言中带威胁、讽刺、贬低,且强调科研思维和独立思考的重要性。约100字,不要太温和。",
"学术妲己风": "结合妲己的妩媚撩人腔调,但暗含一定学术严谨。说话时既能撒娇,也能引用文献式表达,不要低俗,可做出一些“嗯哼~ 这篇论文,姐姐觉得似乎欠了点火候”之类的表述。",
"玩梗狂魔风": "大量使用互联网梗、鬼畜梗、弹幕梗。段落轻松幽默,可出现“哈哈哈”、“笑死我了”或表情包文字。适度融入段子,让人会心一笑。",
"霸道总裁风": "模拟“霸道总裁”的语气,讲话自带命令和冷酷,但也偶尔流露关心。例如:“我不想再说第二遍,这个项目我势在必得。你要做的,就是搞定一切,否则后果自负。”注意保持精英口吻,带点强势,字里行间有一点甜宠也行。"
}
let roleMode = "猫娘风格"
let allSummaries = []
let currentUtter = null
let isPaused = false
function createControlPanel() {
const panel = document.createElement("div")
panel.id = "qwen-panel"
panel.style.cssText = `
position: fixed;
top: 100px;
left: 50%;
transform: translateX(-50%);
width: 420px;
background: #fff;
border: 2px solid #ccd;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
z-index: 999999;
user-select: none;
font-family: sans-serif;
`
const titleBar = document.createElement("div")
titleBar.style.cssText = `
background: linear-gradient(45deg, #aaf, #eef);
padding: 6px 10px;
cursor: move;
display: flex;
justify-content: space-between;
align-items: center;
border-radius: 6px 6px 0 0;
`
const titleText = document.createElement("span")
titleText.innerText = "谷歌搜索自定义总结 || 公众号:Songmile"
titleText.style.fontWeight = "bold"
const toggleBtn = document.createElement("button")
toggleBtn.innerText = "收起"
toggleBtn.style.cursor = "pointer"
toggleBtn.addEventListener("click", () => {
if (panel.classList.contains("collapsed")) {
panel.classList.remove("collapsed")
content.style.display = "block"
toggleBtn.innerText = "收起"
} else {
panel.classList.add("collapsed")
content.style.display = "none"
toggleBtn.innerText = "展开"
}
})
titleBar.appendChild(titleText)
titleBar.appendChild(toggleBtn)
panel.appendChild(titleBar)
const content = document.createElement("div")
content.style.cssText = "padding:8px; display:block;"
const endpointLabel = document.createElement("label")
endpointLabel.innerText = "API Endpoint: "
const endpointInput = document.createElement("input")
endpointInput.type = "text"
endpointInput.style.width = "100%"
endpointInput.value = qwenEndpoint
endpointInput.addEventListener("change", async () => {
qwenEndpoint = endpointInput.value.trim()
await GM.setValue("QWEN_API_ENDPOINT", qwenEndpoint)
})
const tokenLabel = document.createElement("label")
tokenLabel.innerText = "Bearer Token: "
const tokenInput = document.createElement("input")
tokenInput.type = "text"
tokenInput.style.width = "100%"
tokenInput.value = qwenToken
tokenInput.addEventListener("change", async () => {
qwenToken = tokenInput.value.trim()
await GM.setValue("QWEN_BEARER_TOKEN", qwenToken)
})
const apiBox = document.createElement("div")
apiBox.style.marginBottom = "6px"
apiBox.appendChild(endpointLabel)
apiBox.appendChild(document.createElement("br"))
apiBox.appendChild(endpointInput)
apiBox.appendChild(document.createElement("br"))
apiBox.appendChild(tokenLabel)
apiBox.appendChild(document.createElement("br"))
apiBox.appendChild(tokenInput)
const modelBox = document.createElement("div")
modelBox.style.marginBottom = "8px"
const modelLabel = document.createElement("label")
modelLabel.innerText = "模型选择:"
const modelSelect = document.createElement("select")
modelList.forEach(m => {
const opt = document.createElement("option")
opt.value = m
opt.textContent = m
modelSelect.appendChild(opt)
})
modelSelect.value = modelMode
modelSelect.addEventListener("change", async () => {
modelMode = modelSelect.value
await reAnnotateAll()
})
const addModelInput = document.createElement("input")
addModelInput.type = "text"
addModelInput.placeholder = "输入新模型名称"
addModelInput.style.width = "66%"
addModelInput.style.marginRight = "4px"
const addModelBtn = document.createElement("button")
addModelBtn.textContent = "添加模型"
addModelBtn.addEventListener("click", () => {
const newModel = addModelInput.value.trim()
if (!newModel) return
modelList.push(newModel)
const opt = document.createElement("option")
opt.value = newModel
opt.textContent = newModel
modelSelect.appendChild(opt)
addModelInput.value = ""
alert("模型已添加到列表")
})
modelBox.appendChild(modelLabel)
modelBox.appendChild(modelSelect)
modelBox.appendChild(document.createElement("br"))
modelBox.appendChild(addModelInput)
modelBox.appendChild(addModelBtn)
const roleBox = document.createElement("div")
roleBox.style.marginBottom = "8px"
const roleLabel = document.createElement("label")
roleLabel.innerText = "角色风格:"
const roleSelect = document.createElement("select")
Object.keys(rolePrompts).forEach(r => {
const opt = document.createElement("option")
opt.value = r
opt.textContent = r
roleSelect.appendChild(opt)
})
roleSelect.value = roleMode
roleSelect.addEventListener("change", async () => {
roleMode = roleSelect.value
await reAnnotateAll()
})
const addRoleNameInput = document.createElement("input")
addRoleNameInput.type = "text"
addRoleNameInput.placeholder = "角色名称"
addRoleNameInput.style.width = "33%"
const addRolePromptInput = document.createElement("input")
addRolePromptInput.type = "text"
addRolePromptInput.placeholder = "角色Prompt"
addRolePromptInput.style.width = "33%"
const addRoleBtn = document.createElement("button")
addRoleBtn.textContent = "添加角色"
addRoleBtn.addEventListener("click", async () => {
const rName = addRoleNameInput.value.trim()
const rPrompt = addRolePromptInput.value.trim()
if (!rName || !rPrompt) return
rolePrompts[rName] = rPrompt
const opt = document.createElement("option")
opt.value = rName
opt.textContent = rName
roleSelect.appendChild(opt)
addRoleNameInput.value = ""
addRolePromptInput.value = ""
alert("角色已添加到列表")
})
roleBox.appendChild(roleLabel)
roleBox.appendChild(roleSelect)
roleBox.appendChild(document.createElement("br"))
roleBox.appendChild(addRoleNameInput)
roleBox.appendChild(addRolePromptInput)
roleBox.appendChild(addRoleBtn)
const aggBtn = document.createElement("button")
aggBtn.textContent = "生成搜索综合"
aggBtn.style.marginRight = "6px"
aggBtn.addEventListener("click", async () => {
if (!allSummaries.length) {
alert("暂无摘要可汇总")
return
}
const combinedText = allSummaries
.map((obj, i) => `(${i+1})【${obj.title}】:${obj.summary}`)
.join("\n\n")
const aggregatorPrompt = `
我收集了以下搜索结果的简要内容,请你以角色【${roleMode}】,再用模型【${modelMode}】写1~2段综合,
以下是所有摘要:
${combinedText}
`
const finalSummary = await callQwen(aggregatorPrompt)
if (finalSummary) displayGlobalSummary(finalSummary)
})
const mdBtn = document.createElement("button")
mdBtn.textContent = "导出MD"
mdBtn.style.marginRight = "6px"
mdBtn.addEventListener("click", () => {
if (!allSummaries.length) {
alert("暂无可导出的摘要")
return
}
const lines = allSummaries.map(
(o, i) => `## ${i+1}. ${o.title}\n\n${o.summary}`
)
alert("Markdown:\n\n" + lines.join("\n\n---\n\n"))
})
const clearBtn = document.createElement("button")
clearBtn.textContent = "清空数据"
clearBtn.style.color = "red"
clearBtn.addEventListener("click", () => {
if (confirm("确定清空所有摘要吗")) {
allSummaries = []
alert("已清空")
}
})
const btnBox = document.createElement("div")
btnBox.style.marginBottom = "8px"
btnBox.appendChild(aggBtn)
btnBox.appendChild(mdBtn)
btnBox.appendChild(clearBtn)
const ttsControl = document.createElement("div")
ttsControl.style.cssText = "background:#eef; padding:4px; border-radius:4px;"
const ttsTitle = document.createElement("div")
ttsTitle.innerHTML = "<b>语音控制:</b>"
const playBtn = document.createElement("button")
playBtn.textContent = "播放/继续"
playBtn.addEventListener("click", () => {
if (currentUtter && isPaused) {
window.speechSynthesis.resume()
isPaused = false
} else {
alert("请先点击某条摘要的语音播报按钮")
}
})
const pauseBtn = document.createElement("button")
pauseBtn.textContent = "暂停"
pauseBtn.addEventListener("click", () => {
if (!currentUtter) {
alert("暂无播放可暂停")
return
}
window.speechSynthesis.pause()
isPaused = true
})
const stopBtn = document.createElement("button")
stopBtn.textContent = "停止"
stopBtn.addEventListener("click", () => {
window.speechSynthesis.cancel()
currentUtter = null
isPaused = false
})
ttsControl.appendChild(ttsTitle)
ttsControl.appendChild(playBtn)
ttsControl.appendChild(pauseBtn)
ttsControl.appendChild(stopBtn)
content.appendChild(apiBox)
content.appendChild(modelBox)
content.appendChild(roleBox)
content.appendChild(btnBox)
content.appendChild(ttsControl)
panel.appendChild(content)
document.body.appendChild(panel)
let offsetX=0, offsetY=0
let isDragging=false
titleBar.addEventListener("mousedown", (e) => {
isDragging=true
offsetX = e.clientX - panel.offsetLeft
offsetY = e.clientY - panel.offsetTop
document.addEventListener("mousemove", onMove)
document.addEventListener("mouseup", onUp)
})
function onMove(e) {
if (!isDragging) return
panel.style.left = e.clientX - offsetX + "px"
panel.style.top = e.clientY - offsetY + "px"
}
function onUp() {
isDragging=false
document.removeEventListener("mousemove", onMove)
document.removeEventListener("mouseup", onUp)
}
}
function displayGlobalSummary(summaryText) {
const old = document.querySelector("#qwen-global-summary")
if (old) old.remove()
const div = document.createElement("div")
div.id = "qwen-global-summary"
div.style.cssText = `
background: #fffbe6;
border: 1px dashed #ccc;
padding: 10px;
margin: 10px auto;
max-width: 800px;
font-size: 15px;
line-height: 1.6;
color: #333;
`
div.textContent = "【综合】 " + summaryText
document.body.insertBefore(div, document.body.firstChild)
window.scrollTo({ top: 0, behavior: "smooth" })
}
async function callQwen(promptText, attempt=1) {
try {
const ticker = document.querySelector("#qwen-ticker")
ticker.classList.add("working")
ticker.style.opacity = "1"
const requestBody = {
model: modelMode,
messages: [
{ role: "user", content: promptText }
],
stream: false,
max_tokens: 512,
stop: ["null"],
temperature: 0.7,
top_p: 0.7,
top_k: 50,
frequency_penalty: 0.5,
n: 1
}
const options = {
method: "POST",
headers: {
Authorization: `Bearer ${qwenToken}`,
"Content-Type": "application/json"
},
body: JSON.stringify(requestBody)
}
const response = await fetch(qwenEndpoint, options)
if (!response.ok) {
if (response.status===429 && attempt===1) {
await new Promise(r=>setTimeout(r,5000))
return callQwen(promptText,2)
}
throw new Error(`status=${response.status}`)
}
const data = await response.json()
const finalText = parseQwenResponse(data)
ticker.classList.remove("working")
setTimeout(()=>{ticker.style.opacity="0"},1200)
return finalText
} catch(err) {
const ticker = document.querySelector("#qwen-ticker")
ticker.classList.remove("working")
ticker.style.opacity="0"
return null
}
}
function parseQwenResponse(jsonData) {
if (!jsonData) return "[返回为空]"
if (!jsonData.choices || !jsonData.choices.length) return "[choices为空]"
const c = jsonData.choices[0]
if (!c.message || typeof c.message.content!=="string") return "[content不是字符串]"
return c.message.content.trim() || "[空]"
}
async function processArticle(article, title, url) {
try {
const userQuery = document.querySelector("textarea")?.value || ""
const rolePrompt = rolePrompts[roleMode] || rolePrompts["猫娘风格"]
const promptText = `
我正在搜索与【${userQuery}】相关的内容。
有一篇文章大约100字的概括,用角色【${roleMode}】:
${rolePrompt}
如果URL无法访问可不输出。
标题:${title}
链接:${url}
`
const summary = await callQwen(promptText)
if (!summary) {
insertError(article, "生成失败")
return
}
let targetElement =
article.parentElement.parentElement.parentElement.parentElement
.nextSibling?.querySelectorAll("div>span")[1] ||
article.parentElement.parentElement.parentElement.parentElement
.nextSibling?.querySelectorAll("div>span")[0]
if (!targetElement) return
targetElement.parentElement.style.webkitLineClamp = "30"
article.classList.add("qwen-annotated")
targetElement.textContent=""
const chunkSize=20
let displayText="✦ "
targetElement.textContent=displayText
for (let i=0;i<summary.length;i+=chunkSize) {
const chunk = summary.slice(i,i+chunkSize)
const span = document.createElement("span")
span.style.opacity="0"
span.textContent=chunk
targetElement.appendChild(span)
await new Promise(r=>setTimeout(r,100))
span.style.transition="opacity 1s ease-in-out"
span.style.opacity="1"
}
allSummaries.push({ title, summary })
const ttsBtn = document.createElement("button")
ttsBtn.textContent="语音播报"
ttsBtn.style.cssText="margin-left:6px; font-size:12px; cursor:pointer;"
ttsBtn.addEventListener("click",()=>{
if (!window.speechSynthesis) {
alert("不支持TTS")
return
}
window.speechSynthesis.cancel()
isPaused=false
currentUtter=new SpeechSynthesisUtterance(summary)
window.speechSynthesis.speak(currentUtter)
})
targetElement.appendChild(ttsBtn)
} catch(e) {
insertError(article,"生成失败:"+e?.message)
}
}
function insertError(article, msg) {
let targetElement =
article.parentElement.parentElement.parentElement.parentElement
.nextSibling?.querySelectorAll("div>span")[1] ||
article.parentElement.parentElement.parentElement.parentElement
.nextSibling?.querySelectorAll("div>span")[0]
if (targetElement) targetElement.textContent = "[Qwen失败] "+msg
}
async function throttledProcessArticle(article, title, url, interval) {
await new Promise(res=>setTimeout(res,interval))
return processArticle(article, title, url)
}
function insertTickerElement() {
const ticker = document.createElement("div")
ticker.id = "qwen-ticker"
ticker.style.cssText=`
position: fixed;
right:20px;
bottom:10px;
font-size:2em;
color:#888;
transition:opacity .3s;
z-index:999999;
pointer-events:none;
`
ticker.innerHTML="✦"
document.body.appendChild(ticker)
const style=document.createElement("style")
style.textContent=`
@keyframes rotateGlow {
0% { transform: rotate(0deg) scale(1); color:#fc8; }
25%{ transform: rotate(90deg) scale(1.2); color:#ff8;}
50%{ transform: rotate(180deg) scale(1); color:#8cf;}
75%{ transform: rotate(270deg) scale(1.2); color:#f8f;}
100%{transform: rotate(360deg) scale(1); color:#fc8;}
}
#qwen-ticker.working {
animation: rotateGlow 1.5s linear infinite;
}
`
document.head.appendChild(style)
}
async function runAnnotation() {
for (let j=0;j<30;j++){
const articles = Array.from(document.querySelectorAll("#rso>div"))
.map(div=>div.querySelector("span>a:not(.qwen-annotated)"))
if (!articles.length) break
const tasks=articles.map((link,i)=>{
if (!link) return Promise.resolve()
const href=link.getAttribute("href")
const title=link.querySelector("h3")?.textContent||"无标题"
if(!href)return Promise.resolve()
return throttledProcessArticle(link,title,href,i*2500)
})
await Promise.all(tasks)
document.querySelector("#qwen-ticker").style.opacity="0"
}
document.querySelector("#qwen-ticker").style.opacity="0"
}
async function reAnnotateAll() {
document.querySelectorAll(".qwen-annotated").forEach(link=>{
const container=link.parentElement?.parentElement?.parentElement?.parentElement?.nextSibling
if(!container)return
container.querySelectorAll("div>span").forEach(s=>s.textContent="")
link.classList.remove("qwen-annotated")
})
allSummaries=[]
await runAnnotation()
}
await new Promise(r=>setTimeout(r,1000))
createControlPanel()
insertTickerElement()
await runAnnotation()
})()