芯位答题插件(大语言模型调用LLM Qwen通义千问版本)

自动调用 Qwen-VL + Qwen-Max 答题(Base64 图片 + 实时进度)

// ==UserScript==
// @name         芯位答题插件(大语言模型调用LLM Qwen通义千问版本)
// @namespace    http://tampermonkey.net/
// @version      1.01
// @description  自动调用 Qwen-VL + Qwen-Max 答题(Base64 图片 + 实时进度)
// @author       You
// @match        *://*.beeline-ai.com/student/*
// @grant        none
// @license      MIT
// ==/UserScript==

(function () {
    'use strict';

    // ======================
    // 配置模块(apiKey 从 localStorage 读取)
    // ======================
    const Config = {
        VisionModelSelect: "qwen-vl-plus",
        LLM_modelSelect: "qwen-max",
        get apiKey() {
            return localStorage.getItem('qwen_api_key') || '';
        },
        set apiKey(val) {
            if (val) {
                localStorage.setItem('qwen_api_key', val.trim());
            } else {
                localStorage.removeItem('qwen_api_key');
            }
        }
    };

    // ======================
    // 状态管理
    // ======================
    const State = {
        answerreturn: [],
        isDone: false,
        hasTriggered: false
    };

    // ======================
    // UI 模块(新增 API Key 输入面板)
    // ======================
    const UI = (function () {
        let boardanswer, bottonanswer, displaylevel, textanswer, textlevelnum, textleveltag;
        let apiKeyPanel = null;

        function createElement(tag, styles = {}, text = '', children = []) {
            const el = document.createElement(tag);
            Object.assign(el.style, styles);
            if (text) el.textContent = text;
            children.forEach(child => el.appendChild(child));
            return el;
        }

        function showApiKeyInput() {
            if (apiKeyPanel) return;

            apiKeyPanel = createElement('div', {
                position: 'fixed',
                top: '50%',
                left: '50%',
                transform: 'translate(-50%, -50%)',
                padding: '20px',
                backgroundColor: '#fff',
                borderRadius: '10px',
                boxShadow: '0 0 15px rgba(0,0,0,0.3)',
                zIndex: '99999',
                width: '300px',
                textAlign: 'center'
            });

            const title = createElement('h3', { margin: '0 0 15px 0' }, '请输入 Qwen API Key');
            const input = createElement('input', {
                width: '100%',
                padding: '8px',
                marginBottom: '10px',
                border: '1px solid #ccc',
                borderRadius: '4px',
                boxSizing: 'border-box'
            });
            input.type = 'password'; // 可改为 'text' 如果希望可见

            const submitBtn = createElement('button', {
                padding: '6px 12px',
                backgroundColor: '#4CAF50',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer'
            }, '保存并开始');
            submitBtn.onclick = () => {
                const key = input.value.trim();
                if (key) {
                    Config.apiKey = key;
                    document.body.removeChild(apiKeyPanel);
                    apiKeyPanel = null;
                    // 可选:提示成功
                    alert('API Key 已保存!插件将在检测到题目后自动运行。');
                } else {
                    alert('请输入有效的 API Key!');
                }
            };

            const note = createElement('p', {
                fontSize: '12px',
                color: '#666',
                marginTop: '10px'
            }, 'Key 格式如:sk-xxxxxxxxxxxxxxxxxxxxxxxx');

            apiKeyPanel.append(title, input, submitBtn, note);
            document.body.appendChild(apiKeyPanel);

            // 聚焦输入框
            input.focus();
        }

        function createUI() {
            if (boardanswer) return;

            boardanswer = createElement('div', {
                position: 'fixed',
                bottom: '20px',
                right: '20px',
                width: '190px',
                height: '230px',
                backgroundColor: '#F5F5F5',
                borderRadius: '10px',
                outline: '2px solid #757575',
                zIndex: '9999'
            });

            bottonanswer = createElement('div', {
                position: 'absolute',
                top: '10px',
                right: '10px',
                width: '170px',
                height: '50px',
                backgroundColor: '#B4D4FF',
                outline: '2px solid #86B6F6',
                borderRadius: '10px',
                display: 'flex',
                alignItems: 'center',
                justifyContent: 'center'
            });

            displaylevel = createElement('div', {
                position: 'absolute',
                top: '70px',
                right: '10px',
                width: '100px',
                height: '50px',
                backgroundColor: '#BFD8AF',
                outline: '2px solid #99BC85',
                borderRadius: '10px',
                display: 'flex',
                flexDirection: 'column',
                alignItems: 'center',
                justifyContent: 'center'
            });

            textanswer = createElement('h3', {
                color: '#1A365D',
                margin: '0',
                fontSize: '14px',
                textAlign: 'center'
            }, 'answer: 等待题目...');

            const textlevel = createElement('h4', {
                color: '#12372A',
                margin: '0',
                fontSize: '12px'
            }, '题号:');

            textlevelnum = createElement('h2', {
                color: '#12372A',
                margin: '0',
                fontSize: '18px'
            });

            textleveltag = createElement('h5', {
                color: '#12372A',
                margin: '0',
                fontSize: '12px'
            });

            bottonanswer.appendChild(textanswer);
            displaylevel.append(textlevel, textlevelnum, textleveltag);
            boardanswer.append(displaylevel, bottonanswer);
            document.body.appendChild(boardanswer);

            // 检查是否已有 API Key
            if (!Config.apiKey) {
                showApiKeyInput();
            }
        }

        function updateAnswerText(text) {
            if (textanswer) textanswer.textContent = text;
        }

        function updateLevel(num, tag) {
            if (textlevelnum) textlevelnum.textContent = num;
            if (textleveltag) textleveltag.textContent = tag;
        }

        return {
            createUI,
            updateAnswerText,
            updateLevel
        };
    })();

    // ======================
    // 工具函数(支持 Base64 图片)
    // ======================
    const Utils = {
        isValidURL(str) {
            return typeof str === 'string' && /<img\s/i.test(str);
        },

        extractImgSrc(htmlStr) {
            const temp = document.createElement('div');
            temp.innerHTML = htmlStr;
            const img = temp.querySelector('img');
            return img ? img.src : null;
        },

        async urlToBase64(url) {
            if (!url || typeof url !== 'string') return null;
            try {
                const cleanUrl = url.replace(/ /g, '%20');
                const response = await fetch(cleanUrl);
                if (!response.ok) {
                    console.warn('Image fetch failed:', response.status, url);
                    return null;
                }
                const blob = await response.blob();
                return new Promise((resolve, reject) => {
                    const reader = new FileReader();
                    reader.onload = () => resolve(reader.result);
                    reader.onerror = () => reject(reader.error);
                    reader.readAsDataURL(blob);
                });
            } catch (e) {
                console.error('urlToBase64 error:', url, e.message || e);
                return null;
            }
        }
    };

    // ======================
    // AI 调用模块(使用 Config.apiKey)
    // ======================
    const AI = (function () {
        async function callModel(model, messages, timeoutMs = 12000) {
            const apiKey = Config.apiKey;
            if (!apiKey) {
                console.error('API Key 未设置!');
                return '[API Key 未设置]';
            }

            const controller = new AbortController();
            const timeoutId = setTimeout(() => controller.abort(), timeoutMs);

            try {
                // 🔧 修复:移除 URL 末尾空格!
                const response = await fetch("https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions", {
                    method: 'POST',
                    headers: {
                        'Content-Type': 'application/json',
                        'Authorization': `Bearer ${apiKey}`
                    },
                    body: JSON.stringify({ model, messages }),
                    signal: controller.signal
                });

                clearTimeout(timeoutId);

                if (!response.ok) {
                    const errText = await response.text().catch(() => '[无法读取错误]');
                    console.error(`API ${response.status}:`, errText);
                    return '[调用失败]';
                }

                const data = await response.json().catch(e => {
                    console.error('JSON parse failed:', e);
                    return null;
                });

                if (!data || !data.choices?.[0]?.message?.content) {
                    console.warn('Invalid API response:', data);
                    return '[无有效内容]';
                }

                return data.choices[0].message.content.trim();
            } catch (error) {
                clearTimeout(timeoutId);
                if (error.name === 'AbortError') {
                    console.error(`请求超时 (${timeoutMs}ms)`);
                } else {
                    console.error('Fetch error:', error);
                }
                return '[请求异常]';
            }
        }

        async function callVisionModel(imageData) {
            const messages = [{
                role: "user",
                content: [
                    { type: "text", text: "请准确提取图片中的所有文字和数学公式,仅返回内容,不要任何解释!" },
                    { type: "image_url", image_url: { url: imageData } }
                ]
            }];
            return await callModel(Config.VisionModelSelect, messages, 10000);
        }

        async function callLLM(prompt) {
            const messages = [
                {
                    role: "system",
                    content: "你是一个答题助手。你必须只输出一行严格的 JSON 对象,不要任何其他字符、解释、空格、换行、Markdown 代码块或标点。格式必须为:{\"question\":\"...\",\"answer\":\"A\",\"diff\":true}"
                },
                {
                    role: "user",
                    content: prompt.trim()
                }
            ];
            const result = await callModel(Config.LLM_modelSelect, messages, 12000);
            return result === '[请求异常]' || result === '[调用失败]' || result === '[无有效内容]' || result === '[API Key 未设置]'
                ? '{}'
                : result;
        }

        return { callVisionModel, callLLM };
    })();

    // ======================
    // 主逻辑(新增 Key 检查)
    // ======================
    const MainLogic = (function () {
        const qTypeMap = { 'danxuan': 0, 'duoxuan': 1, 'panduan': 2 };
        const prompts = [
            `这是一道单选题。请严格按以下要求作答:仅输出一行 JSON,格式为 {"question":"题目内容","answer":"A","diff":true}。不要任何额外字符、解释、换行或空格。题目如下:\n`,
            `这是一道多选题。请严格按以下要求作答:仅输出一行 JSON,格式为 {"question":"题目内容","answer":"A B","diff":true}。不要任何额外字符、解释、换行或空格。题目如下:\n`,
            `这是一道判断题。请严格按以下要求作答:仅输出一行 JSON,格式为 {"question":"题目内容","answer":"T","diff":true}。不要任何额外字符、解释、换行或空格。题目如下:\n`
        ];

        async function limitConcurrencyWithProgress(tasks, limit = 2, onProgress = null) {
            const results = [];
            const total = tasks.length;
            let completed = 0;

            for (let i = 0; i < tasks.length; i += limit) {
                const batch = tasks.slice(i, i + limit);
                const batchResults = await Promise.all(
                    batch.map(task => task().catch(err => {
                        console.error('Task failed:', err);
                        return '[处理失败]';
                    }))
                );
                results.push(...batchResults);
                completed += batch.length;
                if (onProgress) onProgress(completed, total);
            }
            return results;
        }

        async function processTopic(topic) {
            try {
                let topicTitle = topic.topicTitle || '';
                if (Utils.isValidURL(topicTitle)) {
                    const imgUrl = Utils.extractImgSrc(topicTitle);
                    if (imgUrl) {
                        const base64 = await Utils.urlToBase64(imgUrl);
                        if (base64) {
                            topicTitle = await AI.callVisionModel(base64);
                        } else {
                            topicTitle = '[图片加载失败]';
                        }
                    }
                }

                let optionsText = '';
                const questions = topic.topicQuestionCoreDtoList || [];
                const optionPromises = questions.map(async (q) => {
                    let content = q.content || '';
                    if (Utils.isValidURL(content)) {
                        const imgUrl = Utils.extractImgSrc(content);
                        if (imgUrl) {
                            const base64 = await Utils.urlToBase64(imgUrl);
                            if (base64) {
                                content = await AI.callVisionModel(base64);
                            } else {
                                content = '[图片加载失败]';
                            }
                        }
                    }
                    return `${q.index}\t${content}\n`;
                });

                const optionChunks = [];
                for (let i = 0; i < optionPromises.length; i += 2) {
                    const chunk = optionPromises.slice(i, i + 2);
                    const results = await Promise.all(chunk.map(p => p.catch(e => {
                        console.warn('Option parse failed:', e);
                        return `${questions[i]?.index || '?'}\t[图片解析失败]\n`;
                    })));
                    optionChunks.push(...results);
                }
                optionsText = optionChunks.join('');

                const qType = qTypeMap[topic.topicType];
                if (qType === undefined) {
                    console.warn('Unknown question type:', topic.topicType);
                    return null;
                }

                return prompts[qType] + topicTitle + '\n' + optionsText;
            } catch (e) {
                console.error('processTopic error:', e);
                return null;
            }
        }

        function extractJsonFromString(str) {
            if (typeof str !== 'string') return null;
            try {
                return JSON.parse(str);
            } catch (e) {
                const start = str.indexOf('{');
                const end = str.lastIndexOf('}');
                if (start === -1 || end <= start) return null;
                let jsonStr = str.slice(start, end + 1);
                try {
                    return JSON.parse(jsonStr);
                } catch (e2) {
                    try {
                        const fixed = jsonStr.replace(/\\\\/g, '\\');
                        return JSON.parse(fixed);
                    } catch (e3) {
                        return null;
                    }
                }
            }
        }

        async function run(data) {
            if (!Config.apiKey) {
                UI.createUI(); // 会自动弹出输入框
                return;
            }
            if (State.hasTriggered) return;
            State.hasTriggered = true;

            UI.createUI();
            UI.updateAnswerText('检测到作业数据,正在加载题目...');

            const homeworkTopicList = data.data?.homeworkTopicList;
            if (!homeworkTopicList || !Array.isArray(homeworkTopicList)) {
                UI.updateAnswerText('无效题目数据');
                return;
            }

            const topicTasks = homeworkTopicList.map(topic => () => processTopic(topic));
            const promptList = (await limitConcurrencyWithProgress(topicTasks, 2))
                .filter(prompt => prompt && prompt !== '[处理失败]');

            if (promptList.length === 0) {
                UI.updateAnswerText('未解析到有效题目');
                return;
            }

            UI.updateAnswerText(`共 ${promptList.length} 题,正在分析...`);

            const llmTasks = promptList.map(prompt => () => AI.callLLM(prompt));
            console.log("正在启动LLM任务");

            const results = await limitConcurrencyWithProgress(llmTasks, 2, (completed, total) => {
                UI.updateAnswerText(`正在分析第 ${completed}/${total} 题...`);
            });

            console.log("LLM任务完成");

            State.answerreturn = results.map((res, i) => {
                const parsed = extractJsonFromString(res);
                if (parsed && parsed.answer !== undefined) {
                    return parsed.answer;
                } else {
                    console.error('Failed to extract answer from Q' + (i + 1), res);
                    return 'NaN';
                }
            });

            State.isDone = true;
            console.log("答题结果已生成");

            UI.updateAnswerText('答题完成!');
            updateCurrentDisplay();
        }

        function updateCurrentDisplay() {
            const indexEl = document.querySelector('.index');
            const tagEl = document.querySelector('.tag');
            if (!indexEl || !tagEl || !State.isDone) return;

            const numMatch = indexEl.textContent.trim().match(/^(\d+)/);
            const num = numMatch ? parseInt(numMatch[1], 10) : 1;
            const tag = tagEl.textContent;

            UI.updateLevel(num, tag);

            if (State.answerreturn.length >= num) {
                UI.updateAnswerText(`answer: ${State.answerreturn[num - 1]}`);
            }
        }

        return { run, updateCurrentDisplay };
    })();

    // ======================
    // 网络拦截
    // ======================
    const Interceptor = (function () {
        function tryTrigger(data) {
            if (State.hasTriggered) return;
            try {
                const hasField = JSON.stringify(data).includes('"homeworkAssessPoints"');
                if (hasField && data.code === 200 && data.data?.homeworkTopicList) {
                    console.log('[答题插件] 检测到 homeworkAssessPoints,启动答题逻辑');
                    MainLogic.run(data);
                }
            } catch (e) {
                console.warn('[答题插件] 数据检测异常:', e);
            }
        }

        const originalXhrOpen = XMLHttpRequest.prototype.open;
        XMLHttpRequest.prototype.open = function () {
            this.addEventListener('load', function () {
                if (this.readyState === 4 && this.status === 200) {
                    const ct = this.getResponseHeader('content-type');
                    if (ct?.includes('application/json')) {
                        try {
                            const data = JSON.parse(this.responseText);
                            tryTrigger(data);
                        } catch (e) { /* ignore */ }
                    }
                }
            });
            return originalXhrOpen.apply(this, arguments);
        };

        const originalFetch = window.fetch;
        window.fetch = function () {
            const promise = originalFetch.apply(this, arguments);
            promise.then(response => {
                if (response.ok && response.headers.get('content-type')?.includes('application/json')) {
                    response.clone().json().then(data => tryTrigger(data)).catch(() => {});
                }
            }).catch(() => {});
            return promise;
        };
    })();

    // ======================
    // DOM 监听(题号变化)
    // ======================
    function debounce(func, wait) {
        let timeout;
        return function (...args) {
            clearTimeout(timeout);
            timeout = setTimeout(() => func.apply(this, args), wait);
        };
    }

    const examContainer =
        document.querySelector('.exam-container') ||
        document.querySelector('.question-wrapper') ||
        document.querySelector('[class*="topic"]') ||
        document.querySelector('[class*="question"]') ||
        document.body;

    const debouncedUpdate = debounce(MainLogic.updateCurrentDisplay, 100);

    const observer = new MutationObserver(debouncedUpdate);
    observer.observe(examContainer, {
        childList: true,
        subtree: true,
        characterData: true
    });

})();