Files
note2any/src/xiaohongshu/api.ts
2025-10-08 17:32:31 +08:00

794 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 文件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<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();
// 下次获取时会用新的调试设置创建实例
}
}
}