update at 2025-10-08 09:18:20

This commit is contained in:
douboer
2025-10-08 09:18:20 +08:00
parent a49e389fe2
commit 584d4151fc
67 changed files with 5363 additions and 892 deletions

796
src/xiaohongshu/api.ts Normal file
View File

@@ -0,0 +1,796 @@
/**
* 文件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.style.display = 'none';
this.webview.style.width = '1200px';
this.webview.style.height = '800px';
// 设置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<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 在webview中执行JavaScript代码
*/
private async executeScript(script: string): Promise<any> {
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<void> {
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<boolean> {
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<boolean> {
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<string> {
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<string[]> {
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<XiaohongshuResponse> {
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<PostStatus> {
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<boolean> {
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<void> {
await this.navigateToUrl(XIAOHONGSHU_CONSTANTS.PUBLISH_URL);
await this.delay(1500);
}
/**
* 选择发布 Tab视频 或 图文
*/
async selectPublishTab(type: 'video' | 'image'): Promise<boolean> {
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<boolean> {
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<void> {
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<void> {
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<boolean> {
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<boolean> {
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<XiaohongshuResponse> {
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<boolean> {
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<boolean> {
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<boolean> {
// 先尝试恢复
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();
// 下次获取时会用新的调试设置创建实例
}
}
}