GGather Bookmark Fixer

Intercept and log URL bookmark requests in GGather

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         GGather Bookmark Fixer
// @namespace    http://tampermonkey.net/
// @version      1.0
// @description  Intercept and log URL bookmark requests in GGather
// @author       dudematthew
// @match        https://web.ggather.com/*
// @match        https://core.ggather.com/*
// @grant        GM_xmlhttpRequest
// @connect      *
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function () {
    'use strict';

    // Styling for console logs
    const logStyle = {
        main: 'font-size: 16px; color: #4CAF50; font-weight: bold; padding: 5px; background: #f0f0f0;',
        url: 'font-size: 14px; color: #2196F3; padding: 3px; background: #f8f8f8;',
        warning: 'font-size: 14px; color: #FF5722; font-weight: bold; padding: 3px;',
        error: 'font-size: 14px; color: #FF0000; font-weight: bold; padding: 3px;',
        debug: 'font-size: 13px; color: #9C27B0; padding: 2px;'
    };

    function debugLog(title, data = null, style = logStyle.debug) {
        console.log('%c[DEBUG] ' + title, style);
        if (data) {
            console.log('Data:', data);
            if (data instanceof Error) {
                console.log('Stack:', data.stack);
            }
        }
    }

    // Store original XHR methods
    const originalOpen = XMLHttpRequest.prototype.open;
    const originalSend = XMLHttpRequest.prototype.send;

    // Track request URLs, methods and metadata
    const requestUrls = new WeakMap();
    const requestMethods = new WeakMap();

    // Function to extract metadata
    async function extractMetadata(targetUrl) {
        debugLog('Fetching metadata for', targetUrl);

        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: 'GET',
                url: targetUrl,
                headers: {
                    'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8',
                    'User-Agent': navigator.userAgent
                },
                onload: function (response) {
                    try {
                        const parser = new DOMParser();
                        const doc = parser.parseFromString(response.responseText, 'text/html');

                        // 1. Find best thumbnail first
                        let thumbnail =
                            doc.querySelector('meta[property="og:image"]')?.content ||
                            doc.querySelector('meta[name="twitter:image"]')?.content ||
                            doc.querySelector('link[rel*="icon"][sizes="192x192"]')?.href ||
                            doc.querySelector('link[rel*="icon"][sizes="128x128"]')?.href ||
                            Array.from(doc.querySelectorAll('img'))
                                .find(img => img.width > 100 && img.height > 100)?.src ||
                            null;

                        // 2. Get title
                        const title = doc.querySelector('title')?.textContent ||
                            doc.querySelector('meta[property="og:title"]')?.content ||
                            null;

                        // 3. Get description
                        const description = doc.querySelector('meta[name="description"]')?.content ||
                            doc.querySelector('meta[property="og:description"]')?.content ||
                            null;

                        // Collect additional metadata
                        const html_images = Array.from(doc.querySelectorAll('img')).map(img => ({
                            src: img.src,
                            width: img.width,
                            height: img.height,
                            alt: img.alt
                        }));

                        const html_icons = Array.from(doc.querySelectorAll('link[rel*="icon"]')).map(icon => ({
                            href: icon.href,
                            rel: icon.rel,
                            sizes: icon.sizes?.value
                        }));

                        const html_og = Array.from(doc.querySelectorAll('meta[property^="og:"]')).map(og => ({
                            property: og.getAttribute('property'),
                            content: og.content
                        }));

                        const html_meta = Array.from(doc.querySelectorAll('meta')).map(meta => ({
                            name: meta.name,
                            content: meta.content
                        }));

                        const metadata = {
                            url: targetUrl,
                            thumbnail: thumbnail,  // Thumbnail first
                            title: title,         // Title second
                            description: description,  // Description third
                            html_images: html_images,
                            html_icons: html_icons,
                            html_og: html_og,
                            html_meta: html_meta,
                            headers: response.responseHeaders,
                            is_webpage: true
                        };

                        debugLog('Extracted metadata', metadata);
                        resolve(metadata);
                    } catch (e) {
                        debugLog('Failed to extract metadata', e);
                        reject(e);
                    }
                },
                onerror: function (error) {
                    debugLog('Failed to fetch URL', error);
                    reject(error);
                }
            });
        });
    }

    // Add this at the top with other constants
    let lastSuccessfulEditRequest = null;

    // Remove the CSRF token capture from XHR since we can't access those headers
    XMLHttpRequest.prototype.open = function (method, url) {
        requestUrls.set(this, url);
        requestMethods.set(this, method);
        debugLog('XHR Open', {
            method,
            url,
            isBookmark: url.includes('urlbookmark'),
            isAuth: url.includes('auth') || url.includes('login')
        });
        return originalOpen.apply(this, arguments);
    };

    // Function to find Vue instance root
    function findVueRoot() {
        // Look for Vue instance in common root element IDs
        const rootElements = ['#app', '#__nuxt', '#__layout', '#root'];
        for (const selector of rootElements) {
            const el = document.querySelector(selector);
            if (el && el.__vue__) {
                return el.__vue__.$root;
            }
        }

        // Fallback: search all elements for Vue instance
        const elements = document.getElementsByTagName('*');
        for (const el of elements) {
            if (el.__vue__) {
                return el.__vue__.$root;
            }
        }

        return null;
    }

    // Modify the callOriginalUpdate function to handle thumbnails
    async function callOriginalUpdate(bookmarkId, metadata) {
        debugLog('🎯 Starting update attempt', { bookmarkId, metadata }, logStyle.warning);

        const saveurl = Array.from(document.querySelectorAll('*'))
            .find(el => el.__vue__?.$options?.name === 'saveurl')?.__vue__;

        if (!saveurl) {
            debugLog('❌ Could not find saveurl component', null, logStyle.error);
            return;
        }

        // Extract metadata from our fetch
        const urldata = await extractMetadata(metadata.url);
        debugLog('Extracted metadata for update', urldata);

        try {
            // First set the URL and trigger validation
            saveurl.url = metadata.url;
            await saveurl.validateBasicURL();

            // Update thumbnail first
            if (urldata.thumbnail) {
                debugLog('Updating thumbnail...');
                try {
                    await saveurl.$axios({
                        url: saveurl.$store.state.api + "/edit-urlbookmark-thumb/",
                        method: "post",
                        data: {
                            url: metadata.url,
                            image_url: urldata.thumbnail,
                            thumbnail_worn: 'self'
                        }
                    });

                    // Update the store after successful upload
                    saveurl.$store.commit("eventSaveThumbChange", {
                        thumbnail: urldata.thumbnail,
                        thumbnail_worn: 'self'
                    });

                    debugLog('✅ Thumbnail update completed');
                    // Add a small delay after thumbnail update
                    await new Promise(resolve => setTimeout(resolve, 1000));
                } catch (e) {
                    debugLog('❌ Failed to update thumbnail', e, logStyle.error);
                }
            }

            // Then update title
            if (urldata.title) {
                debugLog('Updating title...');
                // Set title in urlbookmark
                saveurl.urlbookmark = {
                    pk: bookmarkId,
                    url: metadata.url,
                    title: urldata.title,
                    rating: metadata.rating,
                    owner_notes: metadata.owner_notes
                };
                await saveurl.editURLBookmark('title');
                await new Promise(resolve => setTimeout(resolve, 1000));
                debugLog('Title update completed');
            }

            // Finally update description
            if (urldata.description) {
                debugLog('Updating description...');
                // Set description in urlbookmark
                saveurl.urlbookmark = {
                    ...saveurl.urlbookmark,  // Keep previous data
                    description: urldata.description
                };
                await saveurl.editURLBookmark('description');
                debugLog('Description update completed');
            }

            debugLog('✅ All updates completed', {
                thumbnail: urldata.thumbnail,
                title: urldata.title,
                description: urldata.description
            }, logStyle.success);

        } catch (e) {
            debugLog('❌ Update failed', e, logStyle.error);
            debugLog('Component state at failure', {
                url: saveurl.url,
                urlbookmark: saveurl.urlbookmark,
                methods: Object.keys(saveurl.$options.methods)
            });
        }
    }

    // Update the bookmark creation handler to use the original update function
    XMLHttpRequest.prototype.send = async function (data) {
        const url = requestUrls.get(this);
        const method = requestMethods.get(this);

        if (url.includes('edit-urlbookmark-thumb')) {
            debugLog('🖼️ Thumbnail request intercepted', {
                url,
                method,
                data: data instanceof FormData ?
                    Object.fromEntries(data.entries()) :
                    data,
                stack: new Error().stack  // This will show us the call stack
            }, logStyle.warning);
        }

        // Detailed logging for thumbnail requests
        if (url.includes('thumb')) {
            debugLog('🖼️ Thumbnail request intercepted', {
                url,
                method,
                data: data instanceof FormData ?
                    Object.fromEntries(data.entries()) :
                    data,
                headers: Array.from(arguments)
            });

            // Store successful thumbnail requests for analysis
            this.addEventListener('load', function () {
                if (this.status === 200) {
                    debugLog('🖼️ Successful thumbnail request', {
                        response: this.responseText,
                        response: this.responseText,
                        headers: this.getAllResponseHeaders()
                    });
                }
            });
        }

        // Log all requests related to thumbnails or bookmarks
        if (url.includes('thumb') || url.includes('bookmark')) {
            debugLog('Request intercepted', {
                url,
                method,
                data,
                headers: this.getAllResponseHeaders?.(),
                requestHeaders: Array.from(arguments)
            });
        }

        try {
            const urlObj = new URL(url);

            // Handle metadata requests (keep this part)
            if (urlObj.pathname === '/api/get-urldata/') {
                const targetUrl = urlObj.searchParams.get('url');
                debugLog('URL data request for', targetUrl);

                // Extract metadata
                const urldata = await extractMetadata(targetUrl);
                debugLog('Extracted metadata', urldata);

                // Create response
                const mockResponse = {
                    urldata: urldata
                };

                debugLog('Sending URL data response', mockResponse);

                // Set response properties
                Object.defineProperties(this, {
                    'status': { value: 200, writable: true },
                    'statusText': { value: 'OK', writable: true },
                    'responseText': { value: JSON.stringify(mockResponse), writable: true },
                    'readyState': { value: 4, writable: true },
                    'response': { value: JSON.stringify(mockResponse), writable: true }
                });

                // Set headers
                this.getAllResponseHeaders = () => 'content-type: application/json';
                this.getResponseHeader = (name) => name.toLowerCase() === 'content-type' ? 'application/json' : null;

                // Trigger events
                setTimeout(() => {
                    const readyStateEvent = new Event('readystatechange');
                    const loadEvent = new Event('load');
                    this.dispatchEvent(readyStateEvent);
                    this.dispatchEvent(loadEvent);
                    debugLog('URL data response sent');
                }, 0);

                return;
            }

            // Handle successful bookmark creation
            if (urlObj.pathname === '/api/add-urlbookmark/') {
                this.addEventListener('load', function () {
                    if (this.status === 200) {
                        try {
                            const response = JSON.parse(this.responseText);
                            debugLog('Bookmark saved', response);
                            // Use original update function instead of our own
                            callOriginalUpdate(response.pk, response);
                        } catch (e) {
                            debugLog('Failed to handle save response', e);
                        }
                    }
                });
            }

            // Capture edit requests only
            if (url.includes('edit-urlbookmark') || url.includes('urlbookmark') && (method === 'PATCH' || method === 'PUT')) {
                const headers = {};
                const originalSetRequestHeader = this.setRequestHeader;

                // Capture headers being set
                this.setRequestHeader = function (name, value) {
                    headers[name] = value;
                    return originalSetRequestHeader.apply(this, arguments);
                };

                debugLog('Edit request detected', {
                    url,
                    method,
                    headers,
                    data
                });

                // Add response handler
                this.addEventListener('load', function () {
                    if (this.status === 200) {
                        lastSuccessfulEditRequest = {
                            url,
                            method,
                            headers,
                            data,
                            response: this.responseText
                        };
                        debugLog('Captured successful edit request', lastSuccessfulEditRequest);
                    }
                });
            }

            return originalSend.apply(this, arguments);
        } catch (e) {
            debugLog('Error in send override', e);
            return originalSend.apply(this, arguments);
        }
    };

    // Also intercept fetch for auth requests
    const originalFetch = window.fetch;
    window.fetch = async function (url, options) {
        if (typeof url === 'string' && (url.includes('auth') || url.includes('login'))) {
            debugLog('Fetch auth request intercepted', {
                url,
                options
            });

            const response = await originalFetch.apply(this, arguments);
            const clonedResponse = response.clone();

            try {
                const data = await clonedResponse.json();
                debugLog('Fetch auth response', {
                    url,
                    data,
                    headers: Array.from(response.headers.entries())
                });
            } catch (e) {
                debugLog('Failed to parse fetch auth response', e);
            }

            return response;
        }
        return originalFetch.apply(this, arguments);
    };

    debugLog('GGather Bookmark Fixer is active', {
        version: GM_info.script.version,
        lastUpdate: new Date().toISOString()
    }, logStyle.main);

    // Add this function to find Nuxt/Axios config
    function findAxiosConfig() {
        // Try to find Nuxt instance
        const nuxtApp = window?.__NUXT__ || window?.$nuxt;
        if (nuxtApp) {
            debugLog('Found Nuxt app', nuxtApp);

            // Try to find Axios config
            const axiosConfig = nuxtApp?.$axios?.defaults ||
                nuxtApp?.context?.$axios?.defaults ||
                window?.$axios?.defaults;

            if (axiosConfig) {
                debugLog('Found Axios config', axiosConfig);
                return axiosConfig;
            }
        }

        // Try to find Vue instance and its Axios config
        const vueRoot = findVueRoot();
        if (vueRoot) {
            const axiosConfig = vueRoot?.$axios?.defaults ||
                vueRoot?.$options?.axios?.defaults;
            if (axiosConfig) {
                debugLog('Found Axios config in Vue root', axiosConfig);
                return axiosConfig;
            }
        }

        return null;
    }

    // Call this when page loads
    document.addEventListener('DOMContentLoaded', () => {
        const axiosConfig = findAxiosConfig();
        if (axiosConfig) {
            debugLog('Will use these Axios defaults for our requests', axiosConfig);
        }
    });

})();