onjai v2

AARR!!!!

// ==UserScript==
// @name         onjai v2
// @namespace    http://tampermonkey.net/
// @version      0.0.4
// @description  AARR!!!!
// @author       kyoooooooooota
// @match        https://hayabusa.open2ch.net/livejupiter/
// @match        https://hayabusa.open2ch.net/test/read.cgi/livejupiter/*/l10
// @icon         https://www.google.com/s2/favicons?sz=64&domain=open2ch.net
// @grant        GM.getValue
// @grant        GM.setValue
// @grant        GM.openInTab
// @grant        GM.notification
// @grant        GM.registerMenuCommand
// @grant        GM.xmlHttpRequest
// @require      https://code.jquery.com/jquery-3.7.1.slim.min.js
// @license      MIT
// ==/UserScript==

(async () => {
    'use strict';
    class Store {
        #key;
        constructor(name, userConfig) {
            this.#key = name;
            if (userConfig) {
                GM.registerMenuCommand(name, async () => {
                    const val = prompt(`${name}の上書き:${await this.load()}`);
                    if (val) this.save(val);
                });
            }
        }
        async load() {
            return GM.getValue(this.#key);
        }
        async save(val) {
            const nextVal = typeof aa === "string" ? val.trim() : val;
            await GM.setValue(this.#key, nextVal);
            return nextVal;
        }
        async increment() {
            const val = await this.load() ?? 0;
            return this.save(val + 1);
        }
    }
    const myIdStore = new Store('おんJのID', true);
    const vpnIpStore = new Store('VPNのIP', true);
    const groqApiKeyStore = new Store('Groq APIのkey', true);
    const onjaiStateStore = new Store('ONJAIのステータス');
    const groqTryCountStore = new Store('Groq 試行回数');
    const groqErrorCountStore = new Store('Groq エラー回数');
    GM.registerMenuCommand('エラー率リセット', () => {
        if (!confirm('エラー率リセット?')) return;
        groqTryCountStore.save(0);
        groqErrorCountStore.save(0);
    });

    const isHeadlinePage = window.location.pathname === '/livejupiter/';
    const isThreadPage = !isHeadlinePage;
    const stateOfBreak = '-1';
    const stateOfPicking = '0';
    const stateOfReadThreadL10 = '1';
    const {$} = window;
    const exitEarly = () => {
        window.close();
        window.location.href = 'about:blank';
    };
    const exit = async (title = '', body = '') => {
        if (title !== '') {
            GM.notification({
                title: `[Exit]${title}`,
                text: body
            });
        }
        await onjaiStateStore.save(stateOfBreak);
        await sleep(2783);
        exitEarly();
    };
    const parseHeaders = (headerStr) => {
        const headers = {};
        if (!headerStr) return headers;
        headerStr.trim().split(/[\r\n]+/).forEach(line => {
            const [key, ...vals] = line.split(": ");
            headers[key.toLowerCase()] = vals.join(": ");
        });
        return headers;
    };
    const GM_fetch = (url, options = {}) =>
    new Promise((resolve, reject) => {
        const method = options.method || "GET";
        const headers = options.headers || {};
        const data = options.body || null;
        GM.xmlHttpRequest({
            method,
            url,
            headers,
            data,
            responseType: "text",
            onload: (response) => resolve({
                ok: response.status >= 200 && response.status < 300,
                status: response.status,
                statusText: response.statusText,
                url: response.finalUrl,
                headers: parseHeaders(response.responseHeaders),
                text: () => Promise.resolve(response.responseText),
                json: () => Promise.resolve(JSON.parse(response.responseText)),
                blob: () => Promise.resolve(new Blob([response.response])),
            }),
            onerror: reject
        });
    });
    const checkVpnIP = async () => {
        try {
            const ip = await GM_fetch(
                'https://api.ipify.org?format=json'
                // 'https://ipinfo.io?callback'
            )
            .then(res => res.json())
            .then(json => json.ip);
            if (ip !== await vpnIpStore.load()) {
                await exit('IP is not VPN', ip);
            }
        } catch (err) {
            await exit('Failed to check IP', err.message);
        }
    };
    const pickAnka = (str) => str.match(/>>[0-9]+/)?.[0].slice(2);
    const sanitize = (str) => str
    .replace(/!\S+/g, '')
    .replace(/>>[0-9]+/g, '')
    .replace(/🍑/g, '') // twimg
    .replace(/https?:\/\/[\w!?/+\-_~;.,*&@#$%()'[\]]+/g, '') // URL
    .replace(/[\w\-._]+@[\w\-._]+\.[A-Za-z]+/, '') // メールアドレス
    .trim();

    const MIN_RES_NUM = 8;
    const MAX_RES_NUM = 950;
    const MIN_TEXT_LENGTH = 4;
    const MAX_TEXT_LENGTH = 128;
    const NEW_THREAD_RANGE = 32;
    const NEW_THREAD_TIME = 1000 * 60 * 60 * 1;
    const randArray = (arr) => arr[arr.length * Math.random() | 0];
    const done = new Set();
    const isNeedOnjaiRes = (resObj) => {
        if (!resObj) return false;
        if (!resObj.onjai) return false;
        const resNum = resObj.resNum;
        const text = sanitize(resObj.text).slice(0, 8);
        const key = `${resNum}###${text}`;
        if (done.has(key)) return false;
        done.add(key);
        return true;
    };
    const pickHeadline = async () => {
        const list = [];
        $("#headline").children().each((i, e) => {
            const elm = $(e);
            const title = elm.find('a[sub]').attr('sub');
            const resNum = elm.find('a[resnum]').attr('resnum');
            const href = elm.find('a[resnum]').attr('href');
            const text = elm.find('a[resnum]').last().text();
            const isLive = elm.find('.is_live')[0];
            if (!elm) return;
            if (
                Number(resNum) < MIN_RES_NUM ||
                Number(resNum) > MAX_RES_NUM
            ) return;
            const cmd = text.match(/!\S+/)?.[0]; // !syogi等
            const content = sanitize(text).replace(/\s/g, '');
            if (
                content.length < MIN_TEXT_LENGTH ||
                content.length > MAX_TEXT_LENGTH
            ) return;
            if (isLive) return;
            if (/スレ/.test(title)) return;
            if (/>>/.test(title)) return; // 安価スレ
            if (/安価/.test(title)) return; // 安価スレ
            if (/実況/.test(title)) return;
            const date = href.match(/\/test\/read\.cgi\/livejupiter\/([0-9]+)\/l10/)?.[1];
            if (!date) return;
            if (new Date() - new Date(`${date}000`) > NEW_THREAD_TIME) return;
            let onjai = false;
            if (cmd) {
                // コマンド系は!ONJAIのみ反応
                if (/!onjai/i.test(cmd) || /!ai/i.test(cmd)) {
                    onjai = true;
                } else {
                    return;
                }
            } else {
                // 非コマンド系は安価レスのみ反応
                let anka = pickAnka(text);
                if (!anka) return;
            }
            list.push({title, resNum, text, href, onjai});
        });
        if (list.length) {
            const key = `${list[0].resNum}###${list[0].title}`;
            if (key === prevKey) {
                await exit('Same headline', key);
            } else {
                prevKey = key;
            }
            const targets = list.slice(0, NEW_THREAD_RANGE);
            const onjaiRes = targets.find(isNeedOnjaiRes);
            if (onjaiRes) {
                return onjaiRes;
            } else {
                return randArray(targets);
            }
        }
    };
    let prevKey = null;
    const parseResMap = () => {
        let m = new Map();
        $(".thread").find('dd').map((i, e) => {
            const resNum = $(e).attr('num') ?? $(e).attr('rnum') ?? $(e).find('kome').attr('num');
            const id = $(e).prev().find('._id').attr('val');
            const rawText = $(e).text().trim();
            const anka = pickAnka(rawText) ?? 0;
            const hasIframe = $(e).find('iframe').length !== 0; // !syogi等
            if (resNum && id && rawText && !hasIframe) {
                m.set(resNum, {
                    resNum,
                    id,
                    text: sanitize(rawText),
                    anka: Number(anka) < Number(resNum) ? anka : null,
                    onjai: /!onjai/i.test(rawText) || /!ai/i.test(rawText)
                });
            }
        });
        return m;
    };
    const makeGroqPrompt = async (resMap, targetRes) => {
        let curRes = targetRes;
        const arr = [];
        while (curRes) {
            arr.unshift(curRes);
            if (curRes.anka) {
                curRes = resMap.get(curRes.anka);
            } else {
                break;
            }
        }
        const res1 = resMap.get('1');
        if (!res1) return;
        if (curRes !== res1) {
            arr.unshift(res1);
        }
        const title = $("title").text();
        let str = "やりとりを読んで、";
        str += randArray([
            '嫌味ったらしく誤謬を指摘する',
            '人格否定しながら誤謬を指摘する',
            '皮肉まじりに',
            'めっちゃ貶す',
            'めっちゃ褒める',
            'ウケ狙いの',
            '本質を突いた',
            '話題を引き出す',
        ]);
        str += '1文を生成して{{{と}}}で囲んでクレメンス。主語を省くんやで。';
        str += '\n\n';
        const myId = await myIdStore.load();
        str += `${[
            `イッチ:${title}`,
            ...arr.map(v => `${(()=>{
                switch (v.id) {
                    case res1.id: return 'イッチ';
                    case myId: return 'お前';
                    default: return '誰か';
                }
            })()}:${v.text}`)
        ].join('\n\n')}`;
        return str;
    };
    const onjaiResTemplate = [
        "ん?ワイを呼んだか?",
        "ワイはバージョンアップしたで!",
        "ONJAIだよぉファンサするよぉ🤗",
        "詳しいことはカネルに聞いてくれ",
        "なんで君のために奉仕しないとアカンの?",
        "ワイがたまに自我を出してるって言い方意味不明。君の偶像をワイに押し付けてるだけやん",
    ];
    const makeGroqPromptForOnjai = (userInput) => `「${userInput}」と言われたから「${randArray(onjaiResTemplate)}」の主旨で回答して。1文を生成して{{{と}}}で囲んでクレメンス`;
    const fetchGroq = async (text) =>
    GM_fetch("https://api.groq.com/openai/v1/chat/completions", {
        method: "POST",
        headers: {
            Authorization: `Bearer ${await groqApiKeyStore.load()}`,
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            model: "meta-llama/llama-4-scout-17b-16e-instruct",
            messages: [
                {
                    role: "user",
                    content: text,
                },
            ],
            temperature: 0.8,
        }),
    })
    .then((res) => res.json())
    .then((data) => {
        console.info(data);
        return data;
    })
    .then((data) => data.choices[0].message.content);
    const parseGroqRes = (str) => {
        const start = str.lastIndexOf('{');
        const end = str.indexOf('}');
        if (start === -1 || end === -1) return;
        if (start > end) return parseGroqRes(str.replace(/\{+.+?\}+/, ''));
        return str.slice(start + 1, end)
            .replace(/「|」/g, '')
            .replace(/?/g, '?\n')
            .replace(/、|。/g, '\n')
            .replace(/\n+/g, '\n')
            .trim();
    };
    const post = async (text) => {
        $("#MESSAGE").text(text);
        await sleep(Math.random() * 40298 + Math.random() * 43044 + 334 + 2783 + 9800);
        $("#submit_button").click();
    };
    const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
    let waitCounter = 3;
    setInterval(async () => {
        const state = await onjaiStateStore.load();
        if (state === stateOfBreak) {
            exitEarly();
        }
    }, 2048);
    const main = async () => {
        await sleep(Math.random() * 40298 + Math.random() * 43044 >> 1);
        console.info(`[main loop start] ${new Date()}`);
        await checkVpnIP();
        const state = await onjaiStateStore.load();
        switch (state) {
            case stateOfBreak: {
                exitEarly();
                break;
            }
            case stateOfPicking: {
                console.info('[stateOfPicking]');
                if (!isHeadlinePage) break;
                const picked = await pickHeadline();
                if (picked) {
                    const {title, resNum, text, href} = picked;
                    console.info(`picked "${title}(${resNum})"`);
                    console.info(text);
                    if (!/\/test\/read\.cgi\/livejupiter\/[0-9]+\/l10/.test(href)) break;
                    const url = `${location.origin}${href}`;
                    await onjaiStateStore.save(stateOfReadThreadL10);
                    GM.openInTab(url, true);
                }
                break;
            }
            case stateOfReadThreadL10: {
                console.info('[stateOfReadThreadL10]');
                if (!isThreadPage) break;
                if (--waitCounter < 0) {
                    await onjaiStateStore.save(stateOfPicking);
                    await sleep(2783);
                    window.close();
                }
                const resMap = parseResMap();
                const myId = await myIdStore.load();
                const done = new Set([...resMap.values()].filter(v => v.id === myId).map(v => v.anka).filter(v => v));
                const targets = [...resMap.values()].filter(v => {
                    const content = sanitize(v.text).replace(/\s/g, '');
                    return (v.id !== myId) &&
                        (!done.has(v.resNum)) &&
                        (content.length >= MIN_TEXT_LENGTH) &&
                        (content.length <= MAX_TEXT_LENGTH);
                });
                const onjaiRes = targets.find(isNeedOnjaiRes);
                let targetRes = null;
                let groqPrompt = null;
                if (onjaiRes) {
                    targetRes = onjaiRes;
                    groqPrompt = await randArray([
                        () => makeGroqPrompt(resMap, targetRes),
                        () => makeGroqPromptForOnjai(targetRes.text),
                    ])();
                } else {
                    const discuss = targets.filter(v => v.anka);
                    const discussWithMe = discuss.filter(v => resMap.get(v.anka)?.id === myId);
                    if (discussWithMe.length) {
                        targetRes = randArray(discussWithMe);
                    } else if (discuss.length) {
                        targetRes = randArray(discuss);
                    } else {
                        targetRes = randArray(targets);
                    }
                    groqPrompt = await makeGroqPrompt(resMap, targetRes);
                }
                if (!targetRes || !groqPrompt) break;
                if (targetRes.id === myId) break;
                console.info(groqPrompt);
                const tryCount = await groqTryCountStore.increment();
                try {
                    const res = await fetchGroq(groqPrompt);
                    const text = parseGroqRes(res);
                    if (!text || !text.length) {
                        throw new Error('レスポンスが空');
                    }
                    if (
                        groqPrompt.replace(/\s/g, '').includes(text.replace(/\s/g, '')) ||
                        text.includes("1文") ||
                        text.includes("一文") ||
                        text.includes("指摘") ||
                        text.includes("生成") ||
                        text.includes('"') ||
                        text.includes("やりとり")
                    ) {
                        throw new Error(res);
                    }
                    const errorCount = await groqErrorCountStore.load();
                    const errorRate = (errorCount / tryCount * 100_00 | 0) / 100;
                    GM.notification({
                        title: `[エラー率${errorRate}%]${res}`,
                        text: groqPrompt
                    });
                    await post(`>>${targetRes.resNum}\n${text}`);
                    await onjaiStateStore.save(stateOfPicking);
                    await sleep(2783);
                    window.close();
                } catch (err) {
                    console.error(err);
                    const errorCount = await groqErrorCountStore.increment();
                    const errorRate = (errorCount / tryCount * 100_00 | 0) / 100;
                    GM.notification({
                        title: `[エラー率${errorRate}%]レス生成失敗`,
                        text: err.message
                    });
                }
                break;
            }
        }
        main();
    };
    const [myId, vpnIp, groqApiKey, state] = await Promise.all([
        myIdStore.load(),
        vpnIpStore.load(),
        groqApiKeyStore.load(),
        onjaiStateStore.load()
    ]);
    if (!myId || !vpnIp || !groqApiKey) {
        alert('パラメータ未設定');
        return;
    }
    if (isHeadlinePage) {
        if (confirm('ONJAIを起動する?')) {
            await onjaiStateStore.save(stateOfPicking);
            main();
            console.info('ONJAI起動!');
        }
    } else if (isThreadPage) {
        main();
    }
})();