Greasy Fork

Greasy Fork is available in English.

小红书全量数据采集 (Source标识 + 服务器回显)

采集 InitialState 和 Feed 流,标识数据来源,并在气泡中显示服务器返回的具体消息。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         小红书全量数据采集 (Source标识 + 服务器回显)
// @namespace    http://tampermonkey.net/
// @version      1.5
// @description  采集 InitialState 和 Feed 流,标识数据来源,并在气泡中显示服务器返回的具体消息。
// @author       Gemini
// @match        https://www.xiaohongshu.com/*
// @match        https://edith.xiaohongshu.com/*
// @grant        GM_xmlhttpRequest
// @grant        unsafeWindow
// @connect      192.168.2.114
// @run-at       document-start
// @license MIT
// ==/UserScript==

(function() {
    'use strict';

    // =========================================================
    // 配置区域
    // =========================================================
    // ⚠️ 请确保此IP和端口与你的Python服务器一致
    const RECEIVE_SERVER_URL = 'http://192.168.2.114:8000/receive_feed';

    // ⚠️ 红色警告:此API路径尚未经过官方文档验证,基于经验分析得出。
    // 如果小红书更新接口版本(如改为 v2),此处必须手动更新。
    const TARGET_API_PART = '/api/sns/web/v1/feed';

    console.log('🛡️ 小红书采集 Hook (V1.5) 已注入');

    // =========================================================
    // 模块 0: UI 气泡提示系统
    // =========================================================

    const style = document.createElement('style');
    style.innerHTML = `
        #xhs-toast-container {
            position: fixed; top: 20px; right: 20px; z-index: 999999;
            display: flex; flex-direction: column; gap: 10px; pointer-events: none;
        }
        .xhs-toast {
            min-width: 250px; max-width: 400px; padding: 12px 20px;
            border-radius: 8px; color: #fff; font-size: 14px; font-family: sans-serif;
            box-shadow: 0 4px 12px rgba(0,0,0,0.15); opacity: 0;
            transform: translateX(20px); transition: all 0.3s ease;
            display: flex; align-items: center; word-break: break-all;
        }
        .xhs-toast.show { opacity: 1; transform: translateX(0); }
        .xhs-toast-success { background-color: #52c41a; }
        .xhs-toast-error { background-color: #ff4d4f; }
        .xhs-toast-info { background-color: #1890ff; }
        .xhs-toast-icon { margin-right: 8px; font-size: 16px; flex-shrink: 0; }
    `;
    (document.head || document.documentElement).appendChild(style);

    function showToast(message, type = 'info') {
        let container = document.getElementById('xhs-toast-container');
        if (!container) {
            container = document.createElement('div');
            container.id = 'xhs-toast-container';
            (document.body || document.documentElement).appendChild(container);
        }

        const toast = document.createElement('div');
        toast.className = `xhs-toast xhs-toast-${type}`;
        const icons = { success: '✅', error: '❌', info: 'ℹ️' };

        // 允许消息中包含 HTML (用于显示服务器返回的复杂信息)
        toast.innerHTML = `<span class="xhs-toast-icon">${icons[type]}</span><span>${message}</span>`;
        container.appendChild(toast);

        void toast.offsetWidth;
        toast.classList.add('show');

        setTimeout(() => {
            toast.classList.remove('show');
            setTimeout(() => {
                if (toast.parentElement) toast.parentElement.removeChild(toast);
            }, 300);
        }, 5000); // 5秒后消失
    }

    // =========================================================
    // 模块 1: 数据发送逻辑 (核心修改)
    // =========================================================

    /**
     * 发送数据并处理服务器返回的消息
     * @param {string} source - 数据来源标识
     * @param {object} payload - 原始数据
     */
    function sendData(source, payload) {
        // 1. 构造数据包,增加 source 字段
        const wrapper = {
            source: source,           // <--- 新增字段
            capture_url: location.href,
            timestamp: new Date().getTime(),
            payload: payload
        };

        GM_xmlhttpRequest({
            method: "POST",
            url: RECEIVE_SERVER_URL,
            headers: { "Content-Type": "application/json" },
            data: JSON.stringify(wrapper),
            onload: function(res) {
                if (res.status === 200) {
                    // 2. 解析服务器返回值
                    let serverMsg = 'OK';
                    try {
                        const jsonRes = JSON.parse(res.responseText);
                        // 优先显示服务器返回的 msg 或 message 字段
                        serverMsg = jsonRes.msg || jsonRes.message || JSON.stringify(jsonRes);
                    } catch (e) {
                        // 如果不是JSON,直接显示文本 (截取前100字防止过长)
                        serverMsg = res.responseText.substring(0, 100);
                    }

                    console.log(`✅ [${source}] 上传成功:`, serverMsg);
                    // 在气泡中显示服务器返回的内容
                    showToast(`上传成功<br/><small style="opacity:0.8">服务端: ${serverMsg}</small>`, 'success');
                } else {
                    console.error(`❌ [${source}] 上传失败: ${res.status}`);
                    showToast(`上传失败 (${res.status})`, 'error');
                }
            },
            onerror: function(err) {
                console.error(`❌ [${source}] 连接失败`, err);
                showToast(`无法连接服务器`, 'error');
            }
        });
    }

    // =========================================================
    // 模块 2: 初始数据采集
    // =========================================================
    function captureInitialState() {
        let checkCount = 0;
        const timer = setInterval(() => {
            checkCount++;
            if (unsafeWindow.__INITIAL_STATE__) {
                clearInterval(timer);

                showToast('捕获到 Initial State', 'info');

                try {
                    const stateData = JSON.parse(JSON.stringify(unsafeWindow.__INITIAL_STATE__));
                    // 传递具体的 source 标识
                    sendData('window.__INITIAL_STATE__', stateData);
                } catch (e) {
                    console.error('❌ 解析失败', e);
                }
            } else if (checkCount >= 50) {
                clearInterval(timer);
            }
        }, 100);
    }
    captureInitialState();

    // =========================================================
    // 模块 3: XHR 流 Hook
    // =========================================================
    const globalObj = unsafeWindow;
    const OriginalXHR = globalObj.XMLHttpRequest;

    class ProxyXHR extends OriginalXHR {
        constructor() {
            super();
            this._url = '';
        }
        open(method, url, async, user, password) {
            this._url = url;
            return super.open(method, url, async, user, password);
        }
        send(body) {
            if (this._url && this._url.includes(TARGET_API_PART)) {
                this.addEventListener('readystatechange', () => {
                    if (this.readyState === 4 && this.status === 200) {
                        try {
                            const originalResp = this.responseText;
                            const jsonResp = JSON.parse(originalResp);

                            // 传递具体的 source 标识 (即 API 路径)
                            sendData('/api/sns/web/v1/feed', jsonResp);

                            Object.defineProperty(this, 'responseText', { get: () => originalResp });
                            Object.defineProperty(this, 'response', { get: () => originalResp });
                        } catch (e) {
                            console.error('❌ Hook Error:', e);
                        }
                    }
                });
            }
            return super.send(body);
        }
    }
    globalObj.XMLHttpRequest = ProxyXHR;

})();