UNPKG

rednote-mcp

Version:

A friendly tool to help you access and interact with Xiaohongshu (RedNote) content through Model Context Protocol.

262 lines (261 loc) 12 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.RedNoteTools = void 0; const authManager_1 = require("../auth/authManager"); const logger_1 = __importDefault(require("../utils/logger")); const noteDetail_1 = require("./noteDetail"); class RedNoteTools { constructor() { this.browser = null; this.page = null; logger_1.default.info('Initializing RedNoteTools'); this.authManager = new authManager_1.AuthManager(); } async initialize() { logger_1.default.info('Initializing browser and page'); this.browser = await this.authManager.getBrowser(); if (!this.browser) { throw new Error('Failed to initialize browser'); } try { this.page = await this.browser.newPage(); // Load cookies if available const cookies = await this.authManager.getCookies(); if (cookies.length > 0) { logger_1.default.info(`Loading ${cookies.length} cookies`); await this.page.context().addCookies(cookies); } // Check login status logger_1.default.info('Checking login status'); await this.page.goto('https://www.xiaohongshu.com'); const isLoggedIn = await this.page.evaluate(() => { const sidebarUser = document.querySelector('.user.side-bar-component .channel'); return sidebarUser?.textContent?.trim() === '我'; }); // If not logged in, perform login if (!isLoggedIn) { logger_1.default.error('Not logged in, please login first'); throw new Error('Not logged in'); } logger_1.default.info('Login status verified'); } catch (error) { // 初始化过程中出错,确保清理资源 await this.cleanup(); throw error; } } async cleanup() { logger_1.default.info('Cleaning up browser resources'); try { if (this.page) { await this.page.close().catch(err => logger_1.default.error('Error closing page:', err)); this.page = null; } if (this.browser) { await this.browser.close().catch(err => logger_1.default.error('Error closing browser:', err)); this.browser = null; } } catch (error) { logger_1.default.error('Error during cleanup:', error); } finally { this.page = null; this.browser = null; } } extractRedBookUrl(shareText) { // 匹配 http://xhslink.com/ 开头的链接 const xhslinkRegex = /(https?:\/\/xhslink\.com\/[a-zA-Z0-9\/]+)/i; const xhslinkMatch = shareText.match(xhslinkRegex); if (xhslinkMatch && xhslinkMatch[1]) { return xhslinkMatch[1]; } // 匹配 https://www.xiaohongshu.com/ 开头的链接 const xiaohongshuRegex = /(https?:\/\/(?:www\.)?xiaohongshu\.com\/[^,\s]+)/i; const xiaohongshuMatch = shareText.match(xiaohongshuRegex); if (xiaohongshuMatch && xiaohongshuMatch[1]) { return xiaohongshuMatch[1]; } return shareText; } async searchNotes(keywords, limit = 10) { logger_1.default.info(`Searching notes with keywords: ${keywords}, limit: ${limit}`); try { await this.initialize(); if (!this.page) throw new Error('Page not initialized'); // Navigate to search page logger_1.default.info('Navigating to search page'); await this.page.goto(`https://www.xiaohongshu.com/search_result?keyword=${encodeURIComponent(keywords)}`); // Wait for search results to load logger_1.default.info('Waiting for search results'); await this.page.waitForSelector('.feeds-container', { timeout: 30000 }); // Get all note items let noteItems = await this.page.$$('.feeds-container .note-item'); logger_1.default.info(`Found ${noteItems.length} note items`); const notes = []; // Process each note for (let i = 0; i < Math.min(noteItems.length, limit); i++) { logger_1.default.info(`Processing note ${i + 1}/${Math.min(noteItems.length, limit)}`); try { // Click on the note cover to open detail await noteItems[i].$eval('a.cover.mask.ld', (el) => el.click()); // Wait for the note page to load logger_1.default.info('Waiting for note page to load'); await this.page.waitForSelector('#noteContainer', { timeout: 30000 }); await this.randomDelay(0.5, 1.5); // Extract note content const note = await this.page.evaluate(() => { const article = document.querySelector('#noteContainer'); if (!article) return null; // Get title const titleElement = article.querySelector('#detail-title'); const title = titleElement?.textContent?.trim() || ''; // Get content const contentElement = article.querySelector('#detail-desc .note-text'); const content = contentElement?.textContent?.trim() || ''; // Get author info const authorElement = article.querySelector('.author-wrapper .username'); const author = authorElement?.textContent?.trim() || ''; // Get interaction counts from engage-bar const engageBar = document.querySelector('.engage-bar-style'); const likesElement = engageBar?.querySelector('.like-wrapper .count'); const likes = parseInt(likesElement?.textContent?.replace(/[^\d]/g, '') || '0'); const collectElement = engageBar?.querySelector('.collect-wrapper .count'); const collects = parseInt(collectElement?.textContent?.replace(/[^\d]/g, '') || '0'); const commentsElement = engageBar?.querySelector('.chat-wrapper .count'); const comments = parseInt(commentsElement?.textContent?.replace(/[^\d]/g, '') || '0'); return { title, content, url: window.location.href, author, likes, collects, comments }; }); if (note) { logger_1.default.info(`Extracted note: ${note.title}`); notes.push(note); } // Add random delay before closing await this.randomDelay(0.5, 1); // Close note by clicking the close button const closeButton = await this.page.$('.close-circle'); if (closeButton) { logger_1.default.info('Closing note dialog'); await closeButton.click(); // Wait for note dialog to disappear await this.page.waitForSelector('#noteContainer', { state: 'detached', timeout: 30000 }); } } catch (error) { logger_1.default.error(`Error processing note ${i + 1}:`, error); const closeButton = await this.page.$('.close-circle'); if (closeButton) { logger_1.default.info('Attempting to close note dialog after error'); await closeButton.click(); // Wait for note dialog to disappear await this.page.waitForSelector('#noteContainer', { state: 'detached', timeout: 30000 }); } } finally { // Add random delay before next note await this.randomDelay(0.5, 1.5); } } logger_1.default.info(`Successfully processed ${notes.length} notes`); return notes; } catch (error) { logger_1.default.error('Error searching notes:', error); throw error; } finally { await this.cleanup(); } } async getNoteContent(url) { logger_1.default.info(`Getting note content for URL: ${url}`); try { await this.initialize(); if (!this.page) throw new Error('Page not initialized'); const actualURL = this.extractRedBookUrl(url); await this.page.goto(actualURL); let note = await (0, noteDetail_1.GetNoteDetail)(this.page); note.url = url; logger_1.default.info(`Successfully extracted note: ${note.title}`); return note; } catch (error) { logger_1.default.error('Error getting note content:', error); throw error; } finally { await this.cleanup(); } } async getNoteComments(url) { logger_1.default.info(`Getting comments for URL: ${url}`); try { await this.initialize(); if (!this.page) throw new Error('Page not initialized'); await this.page.goto(url); // Wait for comments to load logger_1.default.info('Waiting for comments to load'); await this.page.waitForSelector('[role="dialog"] [role="list"]'); // Extract comments const comments = await this.page.evaluate(() => { const items = document.querySelectorAll('[role="dialog"] [role="list"] [role="listitem"]'); const results = []; items.forEach((item) => { const author = item.querySelector('[data-testid="user-name"]')?.textContent?.trim() || ''; const content = item.querySelector('[data-testid="comment-content"]')?.textContent?.trim() || ''; const likes = parseInt(item.querySelector('[data-testid="likes-count"]')?.textContent || '0'); const time = item.querySelector('time')?.textContent?.trim() || ''; results.push({ author, content, likes, time }); }); return results; }); logger_1.default.info(`Successfully extracted ${comments.length} comments`); return comments; } catch (error) { logger_1.default.error('Error getting note comments:', error); throw error; } finally { await this.cleanup(); } } /** * Wait for a random duration between min and max seconds * @param min Minimum seconds to wait * @param max Maximum seconds to wait */ async randomDelay(min, max) { const delay = Math.random() * (max - min) + min; logger_1.default.debug(`Adding random delay of ${delay.toFixed(2)} seconds`); await new Promise((resolve) => setTimeout(resolve, delay * 1000)); } } exports.RedNoteTools = RedNoteTools;