Google News Enhanced via Gemini AI

Google News with AI-Generated Annotation via Gemini

// ==UserScript==
// @match           https://news.google.com/*
// @name            Google News Enhanced via Gemini AI
// @version         3.8
// @license         MIT
// @namespace       djshigel
// @description  Google News with AI-Generated Annotation via Gemini
// @run-at          document-end
// @grant           GM.setValue
// @grant           GM.getValue
// ==/UserScript==

(async () => {
    let GEMINI_API_KEY = await GM.getValue("GEMINI_API_KEY");
    if (!GEMINI_API_KEY || !Object.keys(GEMINI_API_KEY).length) {
        GEMINI_API_KEY = window.prompt('Get Generative Language Client API key from Google AI Studio\nhttps://ai.google.dev/aistudio', '');
        await GM.setValue("GEMINI_API_KEY", GEMINI_API_KEY);
    }
    const apiUrl = `https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash-latest:generateContent?key=${GEMINI_API_KEY}`;
    const delay = (ms) => new Promise(resolve => setTimeout(resolve, ms));
    let consecutive429Count = 0;
    let token = '';
    let timestamp = '';
    let signature = '';

    // ########## Header ##########
    function insertHeaderStyle() {
        const $headerStyle = document.createElement('style');
        const header = document.querySelector('header[role="banner"]');
        $headerStyle.innerText = `
            @media screen and (max-height: 860px) {
                header[role="banner"] {
                    position: absolute!important;
                    margin-bottom : -${header.clientHeight}px;
                }
            }`;
        document.querySelector('head').appendChild($headerStyle);
    }

    // ########## Load continuous page sections ##########

    const loadContinuous = async () => {
        for (let i = 0; i < 20; i++) {
            await delay(100);
            let intersectionObservedElement = document.querySelector('main c-wiz > c-wiz ~ div[jsname]');
            if (!intersectionObservedElement) break;
            intersectionObservedElement.style.position = 'fixed' ;
            intersectionObservedElement.style.top = '0';
        }
        console.log(`loaded: ${document.querySelectorAll('main c-wiz > c-wiz').length} pages`);
    };

    // ########## Extract URL ##########
    const fetchRedirectPage = async (href) => {
        try {
            const response = await fetch(`${window.location.origin}${window.location.pathname.substring(0, window.location.pathname.indexOf('/', 3))}/rss/articles/${href}`);
            if (!response.ok) throw new Error(`HTTP error! Status: ${response.status}`);
            const responseText = await response.text();
            const parser = new DOMParser();
            return parser.parseFromString(responseText, 'text/html');
        } catch (error) {
            console.error('Error fetching redirect:', error);
        }
    };

    function sendPostRequest(endPoint, param) {
        return new Promise((resolve, reject) => {
            var xhr = new XMLHttpRequest();

            xhr.open("POST", endPoint, true);
            xhr.setRequestHeader("Content-Type", "application/x-www-form-urlencoded");
            xhr.onreadystatechange = function() {
                if (xhr.readyState === 4) {
                    if (xhr.status === 200) {
                        resolve(xhr.responseText.replace('httprm', ''));
                    } else if (xhr.status === 400) {
                        reject(Error(xhr.responseText));
                    } else {
                        reject(new Error("Request failed with status " + xhr.status + ": " + xhr.statusText));
                    }
                }
            };
            xhr.send(param);
        });
    }

    const getExtractedURL = async (href) => {
        href = href.replace('./read/', '').split('?')[0].split('_')[0];
        try {
            for (let i = 0; i < 3; i++) {
                if(!token || !timestamp || !signature) {
                    const redirectPage = await fetchRedirectPage(href);
                    if (!redirectPage.querySelector('script#_ij') || !redirectPage.querySelector('c-wiz>div')) continue;
                    token = redirectPage.querySelector('script#_ij').textContent.match(/[A-Za-z0-9]{25,35}:[0-9]{10,15}/);
                    if (!token) continue;
                    token = token[0];
                    signature = redirectPage.querySelector('c-wiz>div').getAttribute('data-n-a-sg');
                    timestamp = token.split(':')[1].substring(0,10);
                    console.log(`Tokens: ${timestamp} / ${signature} / ${token}`);
                }
                const endPoint = `/_/DotsSplashUi/data/batchexecute?source-path=%2Fread%2F${href}`;
                const param = `f.req=%5B%5B%5B%22Fbv4je%22%2C%22%5B%5C%22garturlreq%5C%22%2C%5B%5B%5C%22ja%5C%22%2C%5C%22JP%5C%22%2C%5B%5C%22FINANCE_TOP_INDICES%5C%22%2C%5C%22WEB_TEST_1_0_0%5C%22%5D%2Cnull%2Cnull%2C1%2C1%2C%5C%22JP%3Aja%5C%22%2Cnull%2C540%2Cnull%2Cnull%2Cnull%2Cnull%2Cnull%2C0%2Cnull%2Cnull%2C%5B1529283084%2C281000000%5D%5D%2C%5C%22ja%5C%22%2C%5C%22JP%5C%22%2C1%2C%5B2%2C3%2C4%2C8%5D%2C1%2C0%2C%5C%22668194412%5C%22%2C0%2C0%2Cnull%2C0%5D%2C%5C%22${href}%5C%22%2C${timestamp}%2C%5C%22${signature}%5C%22%5D%22%2Cnull%2C%22generic%22%5D%5D%5D`//&at=${token}&`
                const response = await sendPostRequest(endPoint, param);
                const indexOfStartString = response.replace('httprm', '').indexOf('http');
                if (indexOfStartString == -1) continue;
                const lengthOfURL = response.substring(indexOfStartString).indexOf('\",') - 1;
                return response.substring(indexOfStartString, indexOfStartString + lengthOfURL)
                    .replace(/\\\\u([a-fA-F0-9]{4})/g, (s, g) => String.fromCharCode(parseInt(g, 16)))
                    .replace(/\\u([a-fA-F0-9]{4})/g, (s, g) => String.fromCharCode(parseInt(g, 16)));
            }
        } catch (error) {
            document.querySelector('#gemini-ticker').style.opacity = '0';
            console.error("URL decode error", error);
            return null;
        }
    };

    // ########## Forecast ##########
    function getCurrentPosition() {
        return new Promise((resolve, reject) => {
            if (navigator.geolocation) {
                navigator.geolocation.getCurrentPosition(resolve, reject);
            } else {
                reject(new Error("Geolocation is not supported by this browser."));
            }
        });
    }

    function getCityFromCoordinates(latitude, longitude) {
        const apiUrl = (new URL(location.href).searchParams.get('hl') == 'ja') ?
              `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${latitude}&longitude=${longitude}&localityLanguage=ja`:
            `https://api.bigdatacloud.net/data/reverse-geocode-client?latitude=${latitude}&longitude=${longitude}&localityLanguage=en`;
        return fetch(apiUrl)
            .then(response => response.json())
            .then(data => data.city)
            .catch(error => {
                console.error('Error fetching the city data:', error);
                throw error;
            });
    }

    async function getCity(position) {
        try {
            const latitude = position.coords.latitude;
            const longitude = position.coords.longitude;
            const city = await getCityFromCoordinates(latitude, longitude);
            return city;
        } catch (error) {
            document.querySelector('#gemini-ticker').style.opacity = '0';
            console.error('Error getting position or city:', error);
            throw error;
        }
    }

    const insertForecastElement = async (forecastLink) => {
        if (forecastLink) {
            const forecast = document.createElement('div');
            forecast.id = 'gemini-forecast';
            forecast.style.maxWidth = '320px';
            forecast.style.marginLeft = '16px';
            forecastLink.parentElement.parentElement.appendChild(forecast);
        }
    };

    const processForecast = async () => {
        const forecastLink = document.querySelector('a[href*="https://weathernews.jp/"]') || 
            document.querySelector('a[href*="https://weather.com/"]');
        if (!forecastLink) return;
        let geo = '全国' ;
        let latitude = null;
        let longitude = null;
        try {
            const position = await getCurrentPosition();
            if (position && position.coords && position.coords.latitude && position.coords.longitude) {
                latitude = position.coords.latitude;
                longitude = position.coords.longitude;
            }
            geo = await getCity(position);
        } catch (error) {
            geo = '全国' ;
        }
        console.log(`forecast: ${geo}`);
        for (let attempt = 0; attempt < 3; attempt++) {
            try {
                document.querySelector('#gemini-ticker').style.opacity = '1';
                const response = (new URL(location.href).searchParams.get('hl') == 'ja') ? 
                    await fetch(apiUrl, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            contents: [{
                                parts: [{
                                    text: `私: URLに対し、次の手順に従ってステップバイステップで実行してください。
                                1 URLにアクセス出来なかった場合、結果を出力しない
                                2 ${(new Date).toString()}の天気に関する情報を抽出
                                3 どのように過ごすべきかを含め、200字程度に具体的に要約
                                4 タイトルや見出しを含めず、結果のみ出力
                                ${geo}の情報: https://weathernews.jp/onebox/${latitude}/${longitude}/
                                あなた:`
                                }],
                            }]
                        }),
                    }):
                    await fetch(apiUrl, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            contents: [{
                                parts: [{
                                    text: `Me: Follow the steps below to execute step by step for each URL.
                                1 If the URL cannot be accessed, do not output the results
                                2 Extract weather information from ${(new Date).toString()}
                                3 Summarize in detail (about 200 characters) including how to spend the day
                                4 Output only the results, without titles or headings
                                About ${geo}: https://weathernews.jp/onebox/${latitude}/${longitude}/
                                You:`
                                }],
                            }]
                        }),
                    });

                if (!response.ok) {
                    if (response.status === 429) {
                        consecutive429Count++;
                        if (consecutive429Count >= 3) {
                            console.warn("Too many requests. Pausing for a while...");
                            await delay(10000);
                            consecutive429Count = 0;
                            continue;
                        }
                    } else {
                        throw new Error('Network response was not ok');
                    }
                } else {
                    consecutive429Count = 0;
                }

                const reader = response.body.getReader();
                let result = '', done = false, decoder = new TextDecoder();
                while (!done) {
                    const { value, done: doneReading } = await reader.read();
                    done = doneReading;
                    if (value) result += decoder.decode(value, { stream: true });
                }
                result += decoder.decode();

                const data = JSON.parse(result);
                if (!data.candidates[0]?.content?.parts[0]?.text) continue;
                let summary = data.candidates[0].content.parts[0].text.replace(/\*\*/g, '').replace(/##/g, '');
                if (summary.length < 80) {
                    console.error('Summary is too short');
                    return;
                } 
                console.log(`forecast: ${summary}`);

                insertForecastElement(forecastLink);
                let targetElement = document.querySelector('#gemini-forecast');
                if (!targetElement) {
                    console.error('No target element found for summary insertion');
                    return;
                }

                let displayText = targetElement.textContent + ' ';
                for (const char of summary) {
                    document.querySelector('#gemini-ticker').style.opacity = '1';
                    displayText += char + '●';
                    targetElement.textContent = displayText;
                    await delay(1);
                    displayText = displayText.slice(0, -1);
                    document.querySelector('#gemini-ticker').style.opacity = '0';
                }
                targetElement.textContent = displayText;
                return;
            } catch (error) {
                document.querySelector('#gemini-ticker').style.opacity = '0';
                await delay(5000);
                console.error('Error:', error);
            }
        }
    };

    // ########## Highlight ##########
    const insertHighlightElement = () => {
        const cWizElements = document.querySelector('aside>c-wiz') ?
            document.querySelectorAll('aside>c-wiz>*'):
            document.querySelector('main>c-wiz>c-wiz>c-wiz') ?
                document.querySelectorAll('main>c-wiz>*'):
                document.querySelectorAll('main>c-wiz>c-wiz, main>div>c-wiz, main>div>div>c-wiz');
        const validHolders = Array.from(document.querySelectorAll('c-wiz>section, c-wiz>section>div>div, main>div>c-wiz>c-wiz, main>c-wiz>c-wiz>c-wiz')).filter(element => {
            const backgroundColor = getComputedStyle(element).backgroundColor;
            return backgroundColor !== 'rgba(0, 0, 0, 0)' && backgroundColor !== 'transparent';         
        });
        if (cWizElements.length >= 2) {
            const targetInsertPosition = cWizElements[1];
            const backgroundColor = getComputedStyle(validHolders[0]).backgroundColor;
            const cWizElement = document.createElement('c-wiz');
            cWizElement.id = 'gemini-highlight';
            cWizElement.style.marginBottom = '50px';
            cWizElement.style.width = '100%';
            cWizElement.innerHTML = (new URL(location.href).searchParams.get('hl') == 'ja') ? 
                `<section style='margin-top: 20px'>
                    <div style='
                        font-size: 1.5em; 
                        margin-bottom: 10px; 
                        -webkit-background-clip: text!important; 
                        -webkit-text-fill-color: transparent; 
                        background: linear-gradient(to right, #4698e2, #c6657b); 
                        width: fit-content;' id='gemini-highlight-header'>
                        ✦ Geminiによるハイライト
                    </div>
                     <div style='
                        background-color: ${backgroundColor}; 
                        padding: 16px; 
                        border-radius: 15px;' id='gemini-highlight-content'>
                    </div>
                </section>`:
                `<section style='margin-top: 20px'>
                    <div style='
                        font-size: 1.5em; 
                        margin-bottom: 10px; 
                        -webkit-background-clip: text!important; 
                        -webkit-text-fill-color: transparent; 
                        background: linear-gradient(to right, #4698e2, #c6657b); 
                        width: fit-content;' id='gemini-highlight-header'>
                        ✦ Highlight via Gemini
                    </div>
                     <div style='
                        background-color: ${backgroundColor}; 
                        padding: 16px; 
                        border-radius: 15px;' id='gemini-highlight-content'>
                    </div>
                </section>`;
            targetInsertPosition.parentElement.insertBefore(cWizElement, targetInsertPosition);
        }
    };

    const processHighlight = async (urls) => {
        for (let attempt = 0; attempt < 3; attempt++) {
            try {
                document.querySelector('#gemini-ticker').style.opacity = '1';
                const response = (new URL(location.href).searchParams.get('hl') == 'ja') ?
                    await fetch(apiUrl, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            contents: [{
                                parts: [{
                                    text: `次に示す最新のニュースの中から最も重要なニュース1つに対し5文で深堀りをどうぞ。 ${urls}`
                                }],
                            }]
                        }),
                    }):
                    await fetch(apiUrl, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            contents: [{
                                parts: [{
                                    text: `Below, please take a eight-sentence in-depth look at one of the most important recent news stories. ${urls}`
                                }],
                            }]
                        }),
                    });

                if (!response.ok) {
                    if (response.status === 429) {
                        consecutive429Count++;
                        if (consecutive429Count >= 3) {
                            console.warn("Too many requests. Pausing for a while...");
                            await delay(10000);
                            consecutive429Count = 0;
                            continue;
                        }
                    } else {
                        throw new Error('Network response was not ok');
                    }
                } else {
                    consecutive429Count = 0;
                }

                const reader = response.body.getReader();
                let result = '', done = false, decoder = new TextDecoder();
                while (!done) {
                    const { value, done: doneReading } = await reader.read();
                    done = doneReading;
                    if (value) result += decoder.decode(value, { stream: true });
                }
                result += decoder.decode();

                const data = JSON.parse(result);
                if (!data.candidates[0]?.content?.parts[0]?.text) continue;
                let summary = data.candidates[0].content.parts[0].text.replace(/\*\*/g, '').replace(/##/g, '');
                console.log(`highlights: ${summary}`);

                insertHighlightElement();
                let targetElement = document.querySelector('#gemini-highlight-content');
                if (!targetElement) {
                    console.error('No target element found for summary insertion');
                    return;
                }

                let displayText = targetElement.textContent + ' ';
                for (const char of summary) {
                    document.querySelector('#gemini-ticker').style.opacity = '1';
                    displayText += char + '●';
                    targetElement.textContent = displayText;
                    await delay(1);
                    displayText = displayText.slice(0, -1);
                    document.querySelector('#gemini-ticker').style.opacity = '0';
                }
                targetElement.textContent = displayText;
                return;
            } catch (error) {
                document.querySelector('#gemini-ticker').style.opacity = '0';
                await delay(5000);
                console.error('Error:', error);
            }
        }
    };

    // ########## Article ##########
    const processArticle = async (article, links, title, url) => {
        try {
            document.querySelector('#gemini-ticker').style.opacity = '1';
            let summary = await GM.getValue(url);
            if (!summary || !Object.keys(summary).length) {
                const response = (new URL(location.href).searchParams.get('hl') == 'ja') ?
                    await fetch(apiUrl, {
                        method: 'POST',
                        headers: { 'Content-Type': 'application/json' },
                        body: JSON.stringify({
                            contents: [{
                                parts: [{
                                    text: `私: URLに対し、次の手順に従ってステップバイステップで実行してください。
                                    1 URLにアクセス出来なかった場合、結果を出力しない
                                    2 200字程度に学者のように具体的に要約
                                    3 タイトルや見出しを含めず、結果のみを出力
                                    ${title}のURL: ${url}
                                    あなた:`
                                }],
                            }]
                        }),
                    }):
                    await fetch(apiUrl, {
                         method: 'POST',
                         headers: { 'Content-Type': 'application/json' },
                         body: JSON.stringify({
                             contents: [{
                                 parts: [{
                                    text: `Me: Follow the steps below to execute step by step for each URL.
                                    1 If the URL cannot be accessed, do not output the results
                                    2 Summarize in 400 characters or so like an academic
                                    3 Output only the results, without titles or headings
                                    ${title} with URL: ${url}
                                    You:`
                                 }],
                             }]
                         }),
                     });

                if (!response.ok) {
                    if (response.status === 429) {
                        consecutive429Count++;
                        if (consecutive429Count >= 3) {
                            console.warn("Too many requests. Pausing for a while...");
                            await delay(30000);
                            consecutive429Count = 0;
                            return Promise.resolve();
                        }
                    } else {
                        throw new Error('Network response was not ok');
                    }
                } else {
                    consecutive429Count = 0;
                }

                const reader = response.body.getReader();
                let result = '', done = false, decoder = new TextDecoder();
                while (!done) {
                    const { value, done: doneReading } = await reader.read();
                    done = doneReading;
                    if (value) result += decoder.decode(value, { stream: true });
                }
                result += decoder.decode();

                const data = JSON.parse(result);
                if (!data.candidates[0]?.content?.parts[0]?.text) return Promise.resolve();
                summary = data.candidates[0].content.parts[0].text.replace(/\*\*/g, '').replace(/##/g, '');

                if (summary.length >= 180) await GM.setValue(url, summary);
            }
            console.log(`summary: ${summary}`);

            let targetElement = article.querySelector('time') || article.querySelector('span') || null;
            if (!targetElement || !targetElement.tagName) {
                const targetLinks = article.querySelectorAll('a[href*="./read/"]');
                const targetLink = targetLinks.length > 1 ? targetLinks[targetLinks.length - 1] : targetLinks[0];
                targetElement = document.createElement('span');
                targetElement.style.fontSize = '12px';
                targetElement.style.fontWeight = '200';
                targetElement.style.marginRight = '-90px';
                targetLink.parentElement.appendChild(targetElement);
            }
            
            if (targetElement.tagName === 'TIME') {
                targetElement.style.whiteSpace = 'pre-wrap';
                targetElement.style.alignSelf = 'end';
                targetElement.style.marginRight = '3px';
                targetElement.parentElement.style.height = 'auto';
            } else {
                targetElement.style.marginRight = '-60px';
                targetElement.style.whiteSpace = 'pre-wrap';
            }
            links.forEach(link => link.setAttribute('href', url));

            let displayText = targetElement.textContent + ' ';
            const author = targetElement.parentElement.querySelector('hr ~ div > span');
            if (author) {
                const hr = targetElement.parentElement.querySelector('hr');
                if (hr) hr.remove();
                displayText += '• ' + author.textContent + '  ';
                author.remove();
            }
            for (const char of summary) {
                document.querySelector('#gemini-ticker').style.opacity = '1';
                displayText += char + '●';
                targetElement.textContent = displayText;
                await delay(1);
                displayText = displayText.slice(0, -1);
                document.querySelector('#gemini-ticker').style.opacity = '0';
            }
            targetElement.textContent = displayText;
        } catch (error) {
            document.querySelector('#gemini-ticker').style.opacity = '0';
            await delay(5000);
            console.error('Error:', error);
        }
    };

    const throttledProcessArticle = async (article, links, title, url, interval) => {
        await delay(interval);
        return processArticle(article, links, title, url);
    };

    // ########## Ticker ##########
    const insertTickerElement = () => {
        if (document.querySelector('#gemini-ticker')) return;
        const ticker = document.createElement('div');
        ticker.id = 'gemini-ticker';
        ticker.style.position = 'fixed';
        ticker.style.right = '20px';
        ticker.style.bottom = '10px';
        ticker.style.fontSize = '1.5em';
        ticker.style.color = '#77777777';
        ticker.style.transition = 'opacity .3s';
        ticker.style.zIndex = '100';
        ticker.innerHTML = '✦';
        document.querySelector('body').appendChild(ticker);
    };

    // ########## Settings ##########
    const insertSettingsElement = () => {
        if (document.querySelector('#gemini-api-settings') || !document.querySelector('a[href*="./settings/"]')) return;
        const settingsLink = document.createElement('div');
        settingsLink.id = 'gemini-api-settings';
        settingsLink.style.height = '64px';
        settingsLink.style.alignContent = 'center';
        settingsLink.innerHTML = (new URL(location.href).searchParams.get('hl') == 'ja') ? 
            `<a style="height: 34px; font-size: 14px;">Google News Enhanced: Gemini APIキーの設定</a>`:
            `<a style="height: 34px; font-size: 14px;">Google News Enhanced: Setting for Gemini API key</a>`;
        document.querySelector('a[href*="./settings/"]').closest('main > div > div > div').appendChild(settingsLink);
        settingsLink.querySelector('a').addEventListener('click', async () => {
            const GEMINI_API_KEY = window.prompt('Get Generative Language Client API key from Google AI Studio\nhttps://ai.google.dev/aistudio', '');
            if (GEMINI_API_KEY != null) await GM.setValue("GEMINI_API_KEY", GEMINI_API_KEY);
        }, false);
    };

    // ########## Main ##########
    insertHeaderStyle();
    insertTickerElement();
    await loadContinuous();
    for (let j = 0; j < 30 ; j++) {
        console.log(`######## attempt: ${j+1} ########`)
        insertSettingsElement();
        document.querySelector('#gemini-ticker').style.opacity = '1';
        const articles = Array.from(document.querySelectorAll('article'));
        const allLinks = Array.from(document.querySelectorAll('a[href*="./read/"]'));
        if (allLinks.length == 0) break;

       const promiseArticles = articles.map(async (article, i) => {
            const links = Array.from(article.querySelectorAll('a[href*="./read/"]'));
            const targetLink = links.length > 1 ? links[links.length - 1] : links[0];
            if (!targetLink) return Promise.resolve();
            const href = targetLink.getAttribute('href');
            const title = targetLink.textContent;
            const url = await getExtractedURL(href);
            console.log(`title: ${title}`);
            console.log(`url: ${url}`);
            if (!url) return Promise.resolve();

            return throttledProcessArticle(article, links, title, url, i * 500);
        });

        await Promise.all(promiseArticles);
        token = '';
        timestamp = '';
        signature = '';

        insertSettingsElement();

        if (!document.querySelector('#gemini-forecast')) {
            await processForecast();
            await delay(1000);
        }

        if (!document.querySelector('#gemini-highlight')) {
            const urls = articles.map(article => {
                const links = Array.from(article.querySelectorAll('a'));
                const targetLink = links.length > 1 ? links[links.length - 1] : links[0];
                if (!targetLink) return null;
                const href = targetLink.getAttribute('href');
                const title = targetLink.textContent;
                return `${title}: ${href}`;
            }).filter(Boolean).join(' ');
            console.log(`highlight: ${urls}`)
            await processHighlight(urls);
            await delay(1000);
        }

        document.querySelector('#gemini-ticker').style.opacity = '0';
        await delay(1000);
    }
    document.querySelector('#gemini-ticker').style.opacity = '0';
    console.log('######## Ended up all ########')
})();