/** * 文件:api.ts * 功能:小红书网页自动化 API 封装(模拟 / 原型阶段版本)。 * * 主要职责: * - 提供基于 webview / executeScript 的 DOM 操作能力 * - 模拟:登录状态检测、内容填写、图片/视频上传触发、发布按钮点击 * - 统一:错误处理、调试日志、发布流程封装(publishViaAutomation) * - 附加:cookies 简易持久化(localStorage 方式,非生产级) * * 设计理念: * - 抽象层:XiaohongshuWebAPI → 提供面向“动作”级别的方法(open / selectTab / fill / publish) * - 扩展层:XiaohongshuAPIManager → 单例管理与调试模式开关 * - 低侵入:不直接耦合业务数据结构,可与适配器/转换器组合 * * 重要限制(当前阶段): * - 未接入真实文件上传与后端接口; * - 登录凭证恢复仅限非 HttpOnly Cookie; * - DOM 选择器依赖页面稳定性,需后续做多策略降级; * - 未实现对发布后结果弹窗/状态的二次确认。 * * 后续可改进: * - 使用 Electron session.cookies 增强会话持久化; * - 引入 MutationObserver 优化上传完成检测; * - 抽象行为脚本 DSL,支持可配置流程; * - 接入真实 API 进行更稳定的内容发布链路。 */ import { Notice } from 'obsidian'; import { XiaohongshuAPI, XiaohongshuPost, XiaohongshuResponse, PostStatus, XiaohongshuErrorCode, XIAOHONGSHU_CONSTANTS } from './types'; import { XHS_SELECTORS } from './selectors'; /** * XiaohongshuWebAPI * * 说明(中文注释): * 基于模拟网页操作的小红书API实现类。 * 通过操作网页DOM元素和模拟用户行为来实现小红书内容发布功能。 * * 主要功能: * - 自动登录小红书创作者中心 * - 填写发布表单并提交内容 * - 上传图片到小红书平台 * - 查询发布状态和结果 * * 技术方案: * 使用Electron的webContents API来操作内嵌的网页视图, * 通过JavaScript代码注入的方式模拟用户操作。 * * 注意事项: * - 网页结构可能随时变化,需要容错处理 * - 需要处理反爬虫检测,添加随机延迟 * - 保持登录状态,处理会话过期 */ export class XiaohongshuWebAPI implements XiaohongshuAPI { private isLoggedIn: boolean = false; private webview: any | null = null; // Electron webview element private debugMode: boolean = false; constructor(debugMode: boolean = false) { this.debugMode = debugMode; this.initializeWebview(); } /** * 初始化Webview * 创建隐藏的webview用于网页操作 */ private initializeWebview(): void { // 创建隐藏的webview元素 this.webview = document.createElement('webview'); this.webview.addClass('xhs-webview'); // 设置webview属性 this.webview.setAttribute('nodeintegration', 'false'); this.webview.setAttribute('websecurity', 'false'); this.webview.setAttribute('partition', 'xiaohongshu'); // 添加到DOM document.body.appendChild(this.webview); // 监听webview事件 this.setupWebviewListeners(); this.debugLog('Webview initialized'); } /** * 设置webview事件监听器 */ private setupWebviewListeners(): void { if (!this.webview) return; this.webview.addEventListener('dom-ready', () => { this.debugLog('Webview DOM ready'); }); this.webview.addEventListener('did-fail-load', (event: any) => { this.debugLog('Webview load failed:', event.errorDescription); }); this.webview.addEventListener('console-message', (event: any) => { if (this.debugMode) { console.log('Webview console:', event.message); } }); } /** * 调试日志输出 */ private debugLog(message: string, ...args: any[]): void { if (this.debugMode) { console.log(`[XiaohongshuAPI] ${message}`, ...args); } } /** * 等待指定时间 */ private async delay(ms: number): Promise { return new Promise(resolve => setTimeout(resolve, ms)); } /** * 在webview中执行JavaScript代码 */ private async executeScript(script: string): Promise { return new Promise((resolve, reject) => { if (!this.webview) { reject(new Error('Webview not initialized')); return; } this.webview.executeJavaScript(script) .then(resolve) .catch(reject); }); } /** * 导航到指定URL */ private async navigateToUrl(url: string): Promise { return new Promise((resolve, reject) => { if (!this.webview) { reject(new Error('Webview not initialized')); return; } const onDidFinishLoad = () => { this.webview!.removeEventListener('did-finish-load', onDidFinishLoad); resolve(); }; this.webview.addEventListener('did-finish-load', onDidFinishLoad); this.webview.src = url; }); } /** * 检查登录状态 */ async checkLoginStatus(): Promise { try { this.debugLog('Checking login status...'); // 导航到小红书创作者中心 await this.navigateToUrl(XIAOHONGSHU_CONSTANTS.PUBLISH_URL); await this.delay(2000); // 检查是否显示登录表单 const loginFormExists = await this.executeScript(` (function() { // 查找登录相关的元素 const loginSelectors = [ '.login-form', '.auth-form', 'input[type="password"]', 'input[placeholder*="密码"]', 'input[placeholder*="手机"]', '.login-container' ]; for (const selector of loginSelectors) { if (document.querySelector(selector)) { return true; } } return false; })() `); this.isLoggedIn = !loginFormExists; this.debugLog('Login status:', this.isLoggedIn); return this.isLoggedIn; } catch (error) { this.debugLog('Error checking login status:', error); return false; } } /** * 使用用户名密码登录 */ async loginWithCredentials(username: string, password: string): Promise { try { this.debugLog('Attempting login with credentials...'); // 确保在登录页面 const isLoggedIn = await this.checkLoginStatus(); if (isLoggedIn) { this.debugLog('Already logged in'); return true; } // 填写登录表单 const loginSuccess = await this.executeScript(` (function() { try { // 查找用户名/手机号输入框 const usernameSelectors = [ 'input[type="text"]', 'input[placeholder*="手机"]', 'input[placeholder*="用户"]', '.username-input', '.phone-input' ]; let usernameInput = null; for (const selector of usernameSelectors) { usernameInput = document.querySelector(selector); if (usernameInput) break; } if (!usernameInput) { console.log('Username input not found'); return false; } // 查找密码输入框 const passwordInput = document.querySelector('input[type="password"]'); if (!passwordInput) { console.log('Password input not found'); return false; } // 填写表单 usernameInput.value = '${username}'; passwordInput.value = '${password}'; // 触发输入事件 usernameInput.dispatchEvent(new Event('input', { bubbles: true })); passwordInput.dispatchEvent(new Event('input', { bubbles: true })); // 查找并点击登录按钮 const loginButtonSelectors = [ 'button[type="submit"]', '.login-btn', '.submit-btn', 'button:contains("登录")', 'button:contains("登陆")' ]; let loginButton = null; for (const selector of loginButtonSelectors) { loginButton = document.querySelector(selector); if (loginButton) break; } if (loginButton) { loginButton.click(); return true; } console.log('Login button not found'); return false; } catch (error) { console.error('Login script error:', error); return false; } })() `); if (!loginSuccess) { throw new Error('Failed to fill login form'); } // 等待登录完成 await this.delay(3000); // 验证登录状态 const finalLoginStatus = await this.checkLoginStatus(); this.isLoggedIn = finalLoginStatus; if (this.isLoggedIn) { new Notice('小红书登录成功'); this.debugLog('Login successful'); } else { new Notice('小红书登录失败,请检查用户名和密码'); this.debugLog('Login failed'); } return this.isLoggedIn; } catch (error) { this.debugLog('Login error:', error); new Notice('小红书登录失败: ' + error.message); return false; } } /** * 上传单张图片 */ async uploadImage(imageBlob: Blob): Promise { try { this.debugLog('Uploading single image...'); if (!this.isLoggedIn) { throw new Error('Not logged in'); } // 导航到发布页面 await this.navigateToUrl(XIAOHONGSHU_CONSTANTS.PUBLISH_URL); await this.delay(2000); // TODO: 实现图片上传逻辑 // 这里需要将Blob转换为File并通过文件选择器上传 const imageUrl = await this.executeScript(` (function() { // 查找图片上传区域 const uploadSelectors = [ '.image-upload', '.photo-upload', 'input[type="file"]', '.upload-area' ]; let uploadElement = null; for (const selector of uploadSelectors) { uploadElement = document.querySelector(selector); if (uploadElement) break; } if (!uploadElement) { throw new Error('Upload element not found'); } // TODO: 实际的图片上传逻辑 // 暂时返回占位符 return 'placeholder-image-url'; })() `); this.debugLog('Image uploaded:', imageUrl); return imageUrl; } catch (error) { this.debugLog('Image upload error:', error); throw new Error('图片上传失败: ' + error.message); } } /** * 批量上传图片 */ async uploadImages(imageBlobs: Blob[]): Promise { const results: string[] = []; for (const blob of imageBlobs) { const url = await this.uploadImage(blob); results.push(url); // 添加延迟避免过快的请求 await this.delay(1000); } return results; } /** * 发布内容到小红书 */ async createPost(content: XiaohongshuPost): Promise { try { this.debugLog('Creating post...', content); if (!this.isLoggedIn) { return { success: false, message: '未登录,请先登录小红书', errorCode: XiaohongshuErrorCode.AUTH_FAILED }; } // 导航到发布页面 await this.navigateToUrl(XIAOHONGSHU_CONSTANTS.PUBLISH_URL); await this.delay(2000); // 填写发布表单 const publishResult = await this.executeScript(` (function() { try { // 查找标题输入框 const titleSelectors = [ 'input[placeholder*="标题"]', '.title-input', 'input.title' ]; let titleInput = null; for (const selector of titleSelectors) { titleInput = document.querySelector(selector); if (titleInput) break; } if (titleInput) { titleInput.value = '${content.title}'; titleInput.dispatchEvent(new Event('input', { bubbles: true })); } // 查找内容输入框 const contentSelectors = [ 'textarea[placeholder*="内容"]', '.content-textarea', 'textarea.content' ]; let contentTextarea = null; for (const selector of contentSelectors) { contentTextarea = document.querySelector(selector); if (contentTextarea) break; } if (contentTextarea) { contentTextarea.value = '${content.content}'; contentTextarea.dispatchEvent(new Event('input', { bubbles: true })); } // 查找发布按钮 const publishButtonSelectors = [ 'button:contains("发布")', '.publish-btn', '.submit-btn' ]; let publishButton = null; for (const selector of publishButtonSelectors) { publishButton = document.querySelector(selector); if (publishButton) break; } if (publishButton) { publishButton.click(); return { success: true, message: '发布请求已提交' }; } else { return { success: false, message: '未找到发布按钮' }; } } catch (error) { return { success: false, message: '发布失败: ' + error.message }; } })() `); // 等待发布完成 await this.delay(3000); this.debugLog('Publish result:', publishResult); return { success: publishResult.success, message: publishResult.message, postId: publishResult.success ? 'generated-post-id' : undefined, errorCode: publishResult.success ? undefined : XiaohongshuErrorCode.PUBLISH_FAILED }; } catch (error) { this.debugLog('Create post error:', error); return { success: false, message: '发布失败: ' + error.message, errorCode: XiaohongshuErrorCode.PUBLISH_FAILED, errorDetails: error.message }; } } /** * 查询发布状态 */ async getPostStatus(postId: string): Promise { try { this.debugLog('Getting post status for:', postId); // TODO: 实现状态查询逻辑 // 暂时返回已发布状态 return PostStatus.PUBLISHED; } catch (error) { this.debugLog('Get post status error:', error); return PostStatus.FAILED; } } /** * 注销登录 */ async logout(): Promise { try { this.debugLog('Logging out...'); const logoutSuccess = await this.executeScript(` (function() { // 查找注销按钮或用户菜单 const logoutSelectors = [ '.logout-btn', 'button:contains("退出")', 'button:contains("注销")', '.user-menu .logout' ]; for (const selector of logoutSelectors) { const element = document.querySelector(selector); if (element) { element.click(); return true; } } return false; })() `); if (logoutSuccess) { this.isLoggedIn = false; new Notice('已退出小红书登录'); } return logoutSuccess; } catch (error) { this.debugLog('Logout error:', error); return false; } } /** * 销毁webview并清理资源 */ destroy(): void { if (this.webview) { document.body.removeChild(this.webview); this.webview = null; } this.isLoggedIn = false; this.debugLog('XiaohongshuWebAPI destroyed'); } /** * 打开发布入口页面(发布视频/图文) */ async openPublishEntry(): Promise { await this.navigateToUrl(XIAOHONGSHU_CONSTANTS.PUBLISH_URL); await this.delay(1500); } /** * 选择发布 Tab:视频 或 图文 */ async selectPublishTab(type: 'video' | 'image'): Promise { const selector = type === 'video' ? XHS_SELECTORS.PUBLISH_TAB.TAB_VIDEO : XHS_SELECTORS.PUBLISH_TAB.TAB_IMAGE; const ok = await this.executeScript(`(function(){ const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`); this.debugLog('Select tab', type, ok); return ok; } /** * 上传媒体:视频或图片(入口点击,不处理文件系统对话框) */ async triggerMediaUpload(type: 'video' | 'image'): Promise { const selector = type === 'video' ? XHS_SELECTORS.PUBLISH_TAB.UPLOAD_BUTTON : XHS_SELECTORS.IMAGE.IMAGE_UPLOAD_ENTRY; const ok = await this.executeScript(`(function(){ const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`); this.debugLog('Trigger upload', type, ok); return ok; } /** * 并行填写标题与内容 */ async fillTitleAndContent(type: 'video' | 'image', title: string, content: string): Promise { const titleSelector = type === 'video' ? XHS_SELECTORS.VIDEO.TITLE_INPUT : XHS_SELECTORS.IMAGE.TITLE_INPUT; const contentSelector = type === 'video' ? XHS_SELECTORS.VIDEO.CONTENT_EDITOR : XHS_SELECTORS.IMAGE.CONTENT_EDITOR; await this.executeScript(`(function(){ const t = document.querySelector('${titleSelector}'); if (t) { t.value = ${JSON.stringify(title)}; t.dispatchEvent(new Event('input',{bubbles:true})); } const c = document.querySelector('${contentSelector}'); if (c) { c.innerHTML = ${JSON.stringify(content)}; c.dispatchEvent(new Event('input',{bubbles:true})); } })()`); } /** * 选择立即发布 / 定时发布 (暂仅实现立即发布) */ async choosePublishMode(immediate: boolean = true, scheduleTime?: string): Promise { await this.executeScript(`(function(){ const radioImmediate = document.querySelector('${XHS_SELECTORS.VIDEO.RADIO_IMMEDIATE}'); const radioSchedule = document.querySelector('${XHS_SELECTORS.VIDEO.RADIO_SCHEDULE}'); if (${immediate}) { if (radioImmediate) { radioImmediate.click(); } } else { if (radioSchedule) { radioSchedule.click(); } const timeInput = document.querySelector('${XHS_SELECTORS.VIDEO.SCHEDULE_TIME_INPUT}') as HTMLInputElement; if (timeInput && ${JSON.stringify(scheduleTime)} ) { timeInput.value = ${JSON.stringify(scheduleTime)}; timeInput.dispatchEvent(new Event('input',{bubbles:true})); } } })()`); } /** * 异步等待上传完成(检测文字“上传成功”或元素出现) */ async waitForUploadSuccess(type: 'video' | 'image', timeoutMs: number = 180000): Promise { const successSelector = type === 'video' ? XHS_SELECTORS.VIDEO.UPLOAD_SUCCESS_STAGE : XHS_SELECTORS.IMAGE.IMAGE_UPLOAD_ENTRY; // 图文等待入口变化可后续细化 const start = Date.now(); while (Date.now() - start < timeoutMs) { const ok = await this.executeScript(`(function(){ const el = document.querySelector('${successSelector}'); if (!el) return false; const text = el.textContent || ''; if (text.includes('上传成功') || text.includes('完成') ) return true; return false; })()`); if (ok) return true; await this.delay(1500); } return false; } /** * 点击发布按钮 */ async clickPublishButton(type: 'video' | 'image'): Promise { const selector = type === 'video' ? XHS_SELECTORS.VIDEO.PUBLISH_BUTTON : XHS_SELECTORS.IMAGE.PUBLISH_BUTTON; const ok = await this.executeScript(`(function(){ const el = document.querySelector('${selector}'); if (el) { el.click(); return true; } return false; })()`); this.debugLog('Click publish', type, ok); return ok; } /** * 高层封装:发布视频或图文 */ async publishViaAutomation(params: {type: 'video' | 'image'; title: string; content: string; immediate?: boolean; scheduleTime?: string;}): Promise { try { await this.openPublishEntry(); await this.selectPublishTab(params.type); await this.triggerMediaUpload(params.type); // 不阻塞:并行填写标题和内容 await this.fillTitleAndContent(params.type, params.title, params.content); await this.choosePublishMode(params.immediate !== false, params.scheduleTime); const success = await this.waitForUploadSuccess(params.type); if (!success) { return { success: false, message: '媒体上传超时', errorCode: XiaohongshuErrorCode.IMAGE_UPLOAD_FAILED }; } const clicked = await this.clickPublishButton(params.type); if (!clicked) { return { success: false, message: '未能点击发布按钮', errorCode: XiaohongshuErrorCode.PUBLISH_FAILED }; } // 发布流程点击后尝试保存 cookies(保持会话) this.saveCookies().catch(()=>{}); return { success: true, message: '发布流程已触发' }; } catch (e:any) { return { success: false, message: e?.message || '发布异常', errorCode: XiaohongshuErrorCode.PUBLISH_FAILED }; } } /** * 保存当前页面 cookies 到 localStorage(在浏览器上下文内执行) */ async saveCookies(): Promise { try { const result = await this.executeScript(`(async function(){ try { const all = document.cookie; // 简单方式:获取所有 cookie 串 if (!all) return false; localStorage.setItem('__xhs_cookies_backup__', all); return true; } catch(e){ return false; } })()`); this.debugLog('saveCookies result', result); return !!result; } catch (e) { this.debugLog('saveCookies error', e); return false; } } /** * 恢复 cookies:将 localStorage 中保存的 cookie 串重新写回 document.cookie * 注意:有些带 HttpOnly/Domain/Path/Expires 的 cookie 无法直接还原,此方式只适合临时会话维持。 */ async restoreCookies(): Promise { try { const result = await this.executeScript(`(function(){ try { const data = localStorage.getItem('__xhs_cookies_backup__'); if (!data) return false; const parts = data.split(';'); for (const p of parts) { // 仅还原简单 key=value const kv = p.trim(); if (!kv) continue; if (kv.includes('=')) { document.cookie = kv; // 可能丢失附加属性 } } return true; } catch(e){ return false; } })()`); this.debugLog('restoreCookies result', result); return !!result; } catch (e) { this.debugLog('restoreCookies error', e); return false; } } /** * 确保会话:尝试恢复 cookies,再检测登录;若失败则返回 false */ async ensureSession(): Promise { // 先尝试恢复 await this.navigateToUrl(XIAOHONGSHU_CONSTANTS.PUBLISH_URL); await this.restoreCookies(); await this.delay(1200); const ok = await this.checkLoginStatus(); return ok; } } /** * 小红书API实例管理器 * * 提供单例模式的API实例管理 */ export class XiaohongshuAPIManager { private static instance: XiaohongshuWebAPI | null = null; private static debugMode: boolean = false; /** * 获取API实例 */ static getInstance(debugMode: boolean = false): XiaohongshuWebAPI { if (!this.instance) { this.debugMode = debugMode; this.instance = new XiaohongshuWebAPI(debugMode); } return this.instance; } /** * 销毁API实例 */ static destroyInstance(): void { if (this.instance) { this.instance.destroy(); this.instance = null; } } /** * 设置调试模式 */ static setDebugMode(enabled: boolean): void { this.debugMode = enabled; if (this.instance) { this.destroyInstance(); // 下次获取时会用新的调试设置创建实例 } } }