/**
* Publishing service for XHS MCP Server
*/
import { Page } from 'puppeteer';
import { Config, PublishResult } from '../../shared/types';
import { PublishError, InvalidImageError } from '../../shared/errors';
import { BaseService } from '../../shared/base.service';
import { existsSync, statSync } from 'fs';
import { join } from 'path';
import { logger } from '../../shared/logger';
import { sleep } from '../../shared/utils';
import { ImageDownloader } from '../../shared/image-downloader';
import { assertTitleWidthValid, getTitleWidth } from '../../shared/title-validator';
import { COMMON_STATUS_SELECTORS, COMMON_TEXT_PATTERNS, COMMON_FILE_SELECTORS } from '../../shared/selectors';
// Constants for video publishing
const VIDEO_TIMEOUTS = {
PAGE_LOAD: 3000,
TAB_SWITCH: 2000,
VIDEO_PROCESSING: 10000,
CONTENT_WAIT: 1000,
UPLOAD_READY: 1000,
UPLOAD_START: 3000,
PROCESSING_CHECK: 3000,
COMPLETION_CHECK: 2000,
PROCESSING_TIMEOUT: 120000, // 2 minutes
COMPLETION_TIMEOUT: 300000, // 5 minutes
} as const;
const SELECTORS = {
FILE_INPUT: COMMON_FILE_SELECTORS.FILE_INPUT,
SUCCESS_INDICATORS: COMMON_STATUS_SELECTORS.SUCCESS,
ERROR_INDICATORS: COMMON_STATUS_SELECTORS.ERROR,
PROCESSING_INDICATORS: COMMON_STATUS_SELECTORS.PROCESSING,
COMPLETION_INDICATORS: [
'.upload-complete',
'.processing-complete',
'.video-ready',
'[class*="complete"]',
'[class*="ready"]',
],
TOAST_SELECTORS: COMMON_STATUS_SELECTORS.TOAST,
PUBLISH_PAGE_INDICATORS: [
'div.upload-content',
'div.submit',
'.creator-editor',
'.video-upload-container',
'input[type="file"]',
],
} as const;
const TEXT_PATTERNS = COMMON_TEXT_PATTERNS;
export class PublishService extends BaseService {
private imageDownloader: ImageDownloader;
constructor(config: Config) {
super(config);
this.imageDownloader = new ImageDownloader('./temp_images');
}
// Helper methods for element detection and text matching
private async findElementBySelectors(
page: Page,
selectors: readonly string[]
): Promise<any | null> {
for (const selector of selectors) {
const element = await page.$(selector);
if (element) {
logger.debug(`Found element with selector: ${selector}`);
return element;
}
}
return null;
}
private async getElementText(element: Element): Promise<string | null> {
try {
return await (element as any).page().evaluate((el: Element) => el.textContent, element);
} catch (error) {
logger.warn(`Failed to get element text: ${error}`);
return null;
}
}
private async checkTextPatterns(
text: string | null,
patterns: readonly string[]
): Promise<boolean> {
if (!text) return false;
return patterns.some((pattern) => text.includes(pattern));
}
private async checkElementForPatterns(
page: Page,
selectors: readonly string[],
patterns: readonly string[]
): Promise<{ found: boolean; text?: string; element?: any }> {
for (const selector of selectors) {
const element = await page.$(selector);
if (element) {
const text = await this.getElementText(element as unknown as Element);
if (text && (await this.checkTextPatterns(text, patterns))) {
return { found: true, text, element };
}
}
}
return { found: false };
}
private async waitForCondition(
condition: () => Promise<boolean>,
timeout: number,
checkInterval: number = 1000,
errorMessage: string
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < timeout) {
if (await condition()) {
return;
}
await sleep(checkInterval);
}
throw new PublishError(errorMessage);
}
async publishNote(
title: string,
content: string,
imagePaths: string[],
tags: string = '',
browserPath?: string
): Promise<PublishResult> {
// Validate inputs
if (!title?.trim()) {
throw new PublishError('Note title cannot be empty');
}
// Validate title width (CJK characters count as 2 units, ASCII as 1)
assertTitleWidthValid(title);
logger.debug(`Title width validation passed: "${title}" (${getTitleWidth(title)} units)`);
if (!content?.trim()) {
throw new PublishError('Note content cannot be empty');
}
if (!imagePaths || imagePaths.length === 0) {
throw new PublishError('At least one image is required');
}
// Process image paths - download URLs and validate local paths
const resolvedPaths = await this.validateAndResolveImagePaths(imagePaths);
// Wait for upload container selector
const uploadSelector = 'div.upload-content';
try {
const page = await this.getBrowserManager().createPage(false, browserPath, true);
try {
await this.getBrowserManager().navigateWithRetry(
page,
this.getConfig().xhs.creatorPublishUrl
);
// Wait for page to load
await sleep(3000);
// First, try to switch to the image/text upload tab
await this.clickUploadTab(page);
// Wait for tab switch to complete
await sleep(3000);
// Check if tab switch was successful and retry if needed
const pageState = await page.evaluate(() => {
return {
buttonTexts: Array.from(document.querySelectorAll('button, div[role="button"]'))
.map((el: Element) => el.textContent?.trim())
.filter((t: string | undefined) => t),
};
});
// If still showing video upload, try clicking the tab again
if (
pageState.buttonTexts.includes('上传视频') &&
!pageState.buttonTexts.includes('上传图文')
) {
await this.clickUploadTab(page);
await sleep(3000);
}
let hasUploadContainer = await this.getBrowserManager().tryWaitForSelector(
page,
uploadSelector,
30000
);
if (!hasUploadContainer) {
// Try alternative selectors for upload container
const alternativeSelectors = [
'div.upload-content',
'.upload-content',
'div[class*="upload"]',
'div[class*="image"]',
'input[type="file"]',
];
for (const selector of alternativeSelectors) {
hasUploadContainer = await this.getBrowserManager().tryWaitForSelector(
page,
selector,
10000
);
if (hasUploadContainer) {
break;
}
}
}
if (!hasUploadContainer) {
throw new PublishError('Could not find upload container on publish page');
}
// Upload images
await this.uploadImages(page, resolvedPaths);
// Wait for images to be processed
await sleep(3000);
// Wait for page to transition to edit mode (check for title or content input)
try {
await page.waitForSelector(
'input[placeholder*="标题"], div[contenteditable="true"], .tiptap.ProseMirror',
{ timeout: 15000 }
);
} catch (error) {
// Continue without waiting
}
// Wait a bit for the page to settle after image upload
await sleep(2000);
// Fill in title
await this.fillTitle(page, title);
// Wait a bit more for content area to appear
await sleep(2000);
// Fill in content
await this.fillContent(page, content);
// Add tags if provided
if (tags) {
await this.addTags(page, tags);
}
// Submit the note
await this.submitPost(page);
// Wait for completion and check result
const noteId = await this.waitForPublishCompletion(page);
// Save cookies
await this.getBrowserManager().saveCookiesFromPage(page);
return {
success: true,
message: 'Note published successfully',
title,
content,
imageCount: resolvedPaths.length,
tags,
url: this.getConfig().xhs.creatorPublishUrl,
noteId: noteId || undefined,
};
} finally {
await page.close();
}
} catch (error) {
logger.error(`Publish error: ${error}`);
throw error;
}
}
/**
* Process image paths - download URLs and validate local paths
*/
private async validateAndResolveImagePaths(imagePaths: string[]): Promise<string[]> {
// Use ImageDownloader to process paths (downloads URLs, validates local paths)
const resolvedPaths = await this.imageDownloader.processImagePaths(imagePaths);
// Validate resolved paths
for (const resolvedPath of resolvedPaths) {
// For local paths that aren't absolute, resolve them
const fullPath =
resolvedPath.startsWith('/') || resolvedPath.match(/^[a-zA-Z]:/)
? resolvedPath
: join(process.cwd(), resolvedPath);
if (!existsSync(fullPath)) {
throw new InvalidImageError(`Image file not found: ${resolvedPath}`);
}
const stats = statSync(fullPath);
if (!stats.isFile()) {
throw new InvalidImageError(`Path is not a file: ${resolvedPath}`);
}
// Check file extension
const ext = resolvedPath.toLowerCase().split('.').pop();
const allowedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp'];
if (!ext || !allowedExtensions.includes(ext)) {
throw new InvalidImageError(
`Unsupported image format: ${resolvedPath}. Supported: ${allowedExtensions.join(', ')}`
);
}
}
if (resolvedPaths.length > 18) {
throw new PublishError('Maximum 18 images allowed');
}
return resolvedPaths;
}
private async clickUploadTab(page: Page): Promise<void> {
try {
// Try multiple selectors for tabs
const tabSelectors = [
'div.creator-tab',
'.creator-tab',
'[role="tab"]',
'.tab',
'div[class*="tab"]',
];
let tabs: any[] = [];
for (const selector of tabSelectors) {
const foundTabs = await page.$$(selector);
if (foundTabs.length > 0) {
tabs = foundTabs;
break;
}
}
if (tabs.length === 0) {
// Try to find all clickable elements that might be tabs
const allClickable = await page.$$('*');
const possibleTabs: any[] = [];
for (const element of allClickable.slice(0, 50)) {
// Limit to first 50 elements
try {
const tagName = await page.evaluate((el) => el.tagName, element);
const text = await page.evaluate((el) => el.textContent, element);
const isVisible = await element.isIntersectingViewport();
if (
isVisible &&
text &&
(text.includes('上传视频') ||
text.includes('上传图文') ||
text.includes('写长文') ||
text.includes('视频') ||
text.includes('图文') ||
text.includes('图片'))
) {
possibleTabs.push({ element, text: text.trim() });
}
} catch (error) {
// Ignore errors
}
}
// Look for image/text upload tab
for (const tab of possibleTabs) {
if (
tab.text.includes('上传图文') ||
tab.text.includes('图文') ||
tab.text.includes('图片')
) {
await tab.element.click();
await sleep(2000);
return;
}
}
return;
}
// Look for the "上传图文" (upload image/text) tab specifically
let imageTextTab: any = null;
const tabTexts: string[] = [];
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
try {
const isVisible = await tab.isIntersectingViewport();
if (!isVisible) continue;
const text = await page.evaluate((el) => el.textContent, tab);
if (text) {
tabTexts.push(text.trim());
}
// Check if this is the image/text upload tab
if (
text &&
(text.includes('上传图文') || text.includes('图文') || text.includes('图片'))
) {
imageTextTab = tab;
break;
}
} catch (error) {
// Ignore individual tab errors
}
}
if (imageTextTab) {
await imageTextTab.click();
await sleep(2000); // Wait for tab switch
} else {
// Fallback: click the second tab (usually image/text upload)
const visibleTabs: any[] = [];
for (const tab of tabs) {
const isVisible = await tab.isIntersectingViewport();
if (isVisible) {
visibleTabs.push(tab);
}
}
if (visibleTabs.length > 1) {
await visibleTabs[1].click(); // Usually the second tab is image/text
await sleep(2000);
} else if (visibleTabs.length > 0) {
await visibleTabs[0].click();
await sleep(2000);
}
}
} catch (error) {
logger.warn(`Failed to click upload tab: ${error}`);
}
}
private async uploadImages(page: Page, imagePaths: string[]): Promise<void> {
// Try primary file input selector
let fileInput = await page.$('input[type=file]') as any;
if (!fileInput) {
// Fallback to alternative selector
fileInput = await page.$('.upload-input') as any;
if (!fileInput) {
throw new PublishError('Could not find file upload input on page');
}
}
// Upload each image
for (const imagePath of imagePaths) {
try {
await fileInput.uploadFile(imagePath);
await sleep(1500); // Wait between uploads
} catch (error) {
throw new PublishError(`Failed to upload image ${imagePath}: ${error}`);
}
}
}
private async fillTitle(page: Page, title: string): Promise<void> {
const titleSelectors = [
'input[placeholder*="标题"]',
'input[placeholder*="title"]',
'input[data-placeholder*="标题"]',
'.title-input input',
'input[type="text"]',
'input[placeholder*="请输入标题"]',
'input[placeholder*="标题"]',
'input[name="title"]',
'input[id*="title"]',
'input[class*="title"]',
'div[contenteditable="true"]',
'textarea[placeholder*="标题"]',
'textarea[placeholder*="title"]',
];
for (const selector of titleSelectors) {
try {
const titleInput = await page.$(selector);
if (titleInput) {
const isVisible = await titleInput.isIntersectingViewport();
if (isVisible) {
await titleInput.click();
await sleep(500); // Wait for focus
await titleInput.type(title);
return;
}
}
} catch (error) {
// Continue to next selector
}
}
// If no input found, try to find any input or textarea on the page
try {
const allInputs = await page.$$('input, textarea, [contenteditable="true"]');
for (let i = 0; i < allInputs.length; i++) {
const input = allInputs[i];
try {
const isVisible = await input.isIntersectingViewport();
const tagName = await page.evaluate((el) => el.tagName, input);
if (isVisible && (tagName === 'INPUT' || tagName === 'TEXTAREA')) {
await input.click();
await sleep(500);
await input.type(title);
return;
}
} catch (error) {
// Continue to next input
}
}
} catch (error) {
// Fall through to error
}
throw new PublishError('Could not find title input field');
}
private async findContentElement(page: Page): Promise<any | null> {
try {
// Strategy 1: Try div[contenteditable="true"] (simple and direct)
const contentEditable = await page.$('div[contenteditable="true"]');
if (contentEditable) {
const isVisible = await contentEditable.isIntersectingViewport();
if (isVisible) {
return contentEditable;
}
}
// Strategy 2: Try div.tiptap.ProseMirror (primary selector for creator platform)
const tiptapEditor = await page.$('div.tiptap.ProseMirror');
if (tiptapEditor) {
const isVisible = await tiptapEditor.isIntersectingViewport();
if (isVisible) {
return tiptapEditor;
}
}
// Strategy 3: Try div[role="textbox"][contenteditable="true"] (specific selector)
const roleTextbox = await page.$('div[role="textbox"][contenteditable="true"]');
if (roleTextbox) {
const isVisible = await roleTextbox.isIntersectingViewport();
if (isVisible) {
return roleTextbox;
}
}
// Strategy 4: Try div.ql-editor (legacy selector)
const qlEditor = await page.$('div.ql-editor');
if (qlEditor) {
return qlEditor;
}
// Strategy 5: Try to find textarea or contenteditable
const contentSelectors = [
'.tiptap.ProseMirror',
'textarea[placeholder*="正文"]',
'textarea[multiline]',
'div[data-placeholder*="正文"]',
'.content-editor',
'div[role="textbox"]',
'textbox[role="textbox"]',
'textbox[multiline]',
];
for (const selector of contentSelectors) {
const element = await page.$(selector);
if (element) {
return element;
}
}
// Strategy 6: Try to find any multiline textbox (fallback)
const multilineTextboxes = await page.$$('textbox[multiline]');
for (const element of multilineTextboxes) {
const isVisible = await element.isIntersectingViewport();
if (isVisible) {
return element;
}
}
return null;
} catch (error) {
return null;
}
}
private async findTextboxByPlaceholder(page: Page): Promise<any | null> {
try {
// Find all p elements
const pElements = await page.$$('p');
// Look for element with data-placeholder containing "输入正文描述"
for (const p of pElements) {
const placeholder = await page.evaluate((el) => el.getAttribute('data-placeholder'), p);
if (placeholder?.includes('输入正文描述')) {
return p;
}
}
return null;
} catch (error) {
return null;
}
}
private async findTextboxParent(page: Page, element: Element): Promise<any> {
try {
return await page.evaluateHandle((el) => el.parentElement, element);
} catch (error) {
return null;
}
}
private async fillContent(page: Page, content: string): Promise<void> {
// Wait for content area to appear
try {
await page.waitForSelector(
'div[role="textbox"][contenteditable="true"], .tiptap.ProseMirror, div[contenteditable="true"], textarea, [role="textbox"], .ql-editor, textbox[multiline]',
{ timeout: 10000 }
);
} catch (error) {
// Continue without waiting
}
let contentElement = await this.findContentElement(page);
if (!contentElement) {
// Try alternative approach
const textboxElement = await this.findTextboxByPlaceholder(page);
if (textboxElement) {
contentElement = await this.findTextboxParent(page, textboxElement);
}
}
if (!contentElement) {
// Try to find any contenteditable or textarea element
try {
const allContentElements = await page.$$(
'div[role="textbox"][contenteditable="true"], .tiptap.ProseMirror, div[contenteditable="true"], textarea, [role="textbox"], .ql-editor, p[contenteditable="true"], textbox[multiline]'
);
for (let i = 0; i < allContentElements.length; i++) {
const element = allContentElements[i];
try {
const isVisible = await element.isIntersectingViewport();
const tagName = await page.evaluate((el) => el.tagName, element);
const contentEditable = await page.evaluate(
(el) => el.getAttribute('contenteditable'),
element
);
const role = await page.evaluate((el) => el.getAttribute('role'), element);
const className = await page.evaluate((el) => el.className, element);
const multiline = await page.evaluate((el) => el.getAttribute('multiline'), element);
if (
isVisible &&
(contentEditable === 'true' ||
tagName === 'TEXTAREA' ||
role === 'textbox' ||
className.includes('ql-editor') ||
className.includes('tiptap') ||
multiline === '')
) {
contentElement = element;
break;
}
} catch (error) {
// Continue to next element
}
}
} catch (error) {
// Continue to next strategy
}
}
if (!contentElement) {
// Last resort: try to find any element that might be for content
try {
const allElements = await page.$$('*');
for (let i = 0; i < Math.min(allElements.length, 50); i++) {
// Limit to first 50 elements
const element = allElements[i];
try {
const isVisible = await element.isIntersectingViewport();
const tagName = await page.evaluate((el) => el.tagName, element);
const contentEditable = await page.evaluate(
(el) => el.getAttribute('contenteditable'),
element
);
const className = await page.evaluate((el) => el.className, element);
const placeholder = await page.evaluate(
(el) => el.getAttribute('placeholder'),
element
);
if (
isVisible &&
(contentEditable === 'true' ||
tagName === 'TEXTAREA' ||
className.includes('content') ||
className.includes('editor') ||
placeholder?.includes('内容') ||
placeholder?.includes('正文'))
) {
contentElement = element;
break;
}
} catch (error) {
// Ignore errors for individual elements
}
}
} catch (error) {
// Fall through to error
}
}
if (!contentElement) {
throw new PublishError('Could not find content input field');
}
try {
await contentElement.click();
await sleep(500); // Wait for focus
await (contentElement as any).type(content);
} catch (error) {
throw new PublishError(`Failed to fill content: ${error}`);
}
}
private async inputTags(contentElement: Element, tags: string): Promise<void> {
const tagList = tags
.split(',')
.map((tag) => tag.trim())
.filter((tag) => tag.length > 0);
for (const tag of tagList) {
await this.inputTag(contentElement, tag);
}
}
private async inputTag(contentElement: Element, tag: string): Promise<void> {
try {
// Get the page from the content element's context
const page = await (contentElement as any).page();
// Try to find topic suggestion container
const topicContainer = await page.$('#creator-editor-topic-container');
if (topicContainer) {
const firstItem = await topicContainer.$('.item');
if (firstItem) {
await firstItem.click();
await sleep(500);
}
}
// Type the tag
await (contentElement as any).type(`#${tag}`);
await sleep(500);
// Press Enter to confirm the tag
await (contentElement as any).press('Enter');
await sleep(500);
} catch (error) {
logger.warn(`Failed to add tag ${tag}: ${error}`);
}
}
private async addTags(page: Page, tags: string): Promise<void> {
try {
const contentElement = await this.findContentElement(page);
if (contentElement) {
await this.inputTags(contentElement, tags);
}
} catch (error) {
logger.warn(`Failed to add tags: ${error}`);
}
}
private async submitPost(page: Page): Promise<void> {
const submitSelector = 'div.submit div.d-button-content';
const submitButton = await page.$(submitSelector);
if (!submitButton) {
throw new PublishError('Could not find submit button');
}
// Wait for submit button to be visible
await page.waitForSelector(submitSelector, { visible: true, timeout: 10000 });
try {
await submitButton.click();
await sleep(2000);
} catch (error) {
throw new PublishError(`Failed to click submit button: ${error}`);
}
}
private async isElementVisible(element: Element): Promise<boolean> {
try {
return await (element as any).isIntersectingViewport();
} catch (error) {
return false;
}
}
private async waitForPublishCompletion(page: Page): Promise<string | null> {
const maxWaitTime = 60000; // 60 seconds
const startTime = Date.now();
while (Date.now() - startTime < maxWaitTime) {
// Check for success indicators
const successIndicators = [
'.success-message',
'.publish-success',
'[data-testid="publish-success"]',
'.toast-success',
];
for (const selector of successIndicators) {
const element = await page.$(selector);
if (element) {
await sleep(2000); // Wait a bit more for any final processing
return await this.extractNoteIdFromPage(page);
}
}
// Check for error indicators
const errorIndicators = [
'.error-message',
'.publish-error',
'[data-testid="publish-error"]',
'.toast-error',
'.error-toast',
];
for (const selector of errorIndicators) {
const element = await page.$(selector);
if (element) {
const errorText = await page.evaluate((el) => el.textContent, element);
throw new PublishError(`Publish failed with error: ${errorText}`);
}
}
// Check if we're still on the publish page
const publishPageIndicators = ['div.upload-content', 'div.submit', '.creator-editor'];
let stillOnPublishPage = false;
for (const selector of publishPageIndicators) {
const element = await page.$(selector);
if (element) {
stillOnPublishPage = true;
break;
}
}
if (!stillOnPublishPage) {
// We've left the publish page, likely successful
logger.debug('Left publish page, assuming success');
return await this.extractNoteIdFromPage(page);
}
// Check for toast messages
const toastSelectors = ['.toast', '.message', '.notification', '[role="alert"]'];
for (const selector of toastSelectors) {
const element = await page.$(selector);
if (element) {
const toastText = await page.evaluate((el) => el.textContent, element);
if (toastText) {
if (toastText.includes('成功') || toastText.includes('success')) {
logger.debug(`Found success toast: ${toastText}`);
return await this.extractNoteIdFromPage(page);
} else if (
toastText.includes('失败') ||
toastText.includes('error') ||
toastText.includes('错误')
) {
throw new PublishError(`Publish failed: ${toastText}`);
}
}
}
}
await sleep(1000); // Wait before next check
}
throw new PublishError('Publish completion timeout - could not determine result');
}
private async extractNoteIdFromPage(page: Page): Promise<string | null> {
try {
// Method 1: Try to extract from URL if redirected to note page
const currentUrl = page.url();
logger.debug(`Current URL after publish: ${currentUrl}`);
// Check if we're on a note page (URL contains /explore/ or /discovery/)
const noteIdMatch = currentUrl.match(/\/explore\/([a-f0-9]+)/i) ||
currentUrl.match(/\/discovery\/([a-f0-9]+)/i);
if (noteIdMatch && noteIdMatch[1]) {
const noteId = noteIdMatch[1];
logger.debug(`Extracted note ID from URL: ${noteId}`);
return noteId;
}
// Method 2: Try to find note ID in page content or data attributes
const noteIdFromPage = await page.evaluate(() => {
// Look for data attributes that might contain note ID
const elementsWithData = document.querySelectorAll('[data-note-id], [data-id], [data-impression]');
for (let i = 0; i < elementsWithData.length; i++) {
const element = elementsWithData[i];
const noteId = element.getAttribute('data-note-id') ||
element.getAttribute('data-id') ||
element.getAttribute('data-impression');
if (noteId && noteId.length > 10) { // Note IDs are typically long
return noteId;
}
}
// Look for links to note pages
const noteLinks = document.querySelectorAll('a[href*="/explore/"], a[href*="/discovery/"]');
for (let i = 0; i < noteLinks.length; i++) {
const link = noteLinks[i];
const href = link.getAttribute('href');
if (href) {
const match = href.match(/\/explore\/([a-f0-9]+)/i) ||
href.match(/\/discovery\/([a-f0-9]+)/i);
if (match && match[1]) {
return match[1];
}
}
}
// Look for any text that looks like a note ID (long hex string)
const textContent = document.body.textContent || '';
const noteIdPattern = /[a-f0-9]{20,}/gi;
const matches = textContent.match(noteIdPattern);
if (matches && matches.length > 0) {
// Return the first long hex string found
return matches[0];
}
return null;
});
if (noteIdFromPage) {
logger.debug(`Extracted note ID from page content: ${noteIdFromPage}`);
return noteIdFromPage;
}
// Method 3: Try to get the latest note ID using NoteService (fallback)
logger.debug('Could not extract note ID from page, trying fallback method');
try {
// Wait a bit for the note to be processed
await sleep(5000);
// Use NoteService to get the latest note ID
const { NoteService } = await import('../notes/note.service');
const noteService = new NoteService(this.getConfig());
const userNotes = await noteService.getUserNotes(1); // Get only the latest note
if (userNotes.success && userNotes.data && userNotes.data.length > 0) {
const latestNoteId = userNotes.data[0].id;
logger.debug(`Extracted note ID from NoteService: ${latestNoteId}`);
return latestNoteId;
}
} catch (error) {
logger.warn(`Fallback note ID extraction failed: ${error}`);
}
logger.debug('Could not extract note ID from any method');
return null;
} catch (error) {
logger.warn(`Failed to extract note ID: ${error}`);
return null;
}
}
private async waitForVideoPublishCompletion(page: Page): Promise<string | null> {
logger.debug('Waiting for video publish completion...');
let isProcessing = false;
await this.waitForCondition(
async () => {
// Check for success indicators
const successResult = await this.checkElementForPatterns(
page,
SELECTORS.SUCCESS_INDICATORS,
TEXT_PATTERNS.SUCCESS
);
if (successResult.found) {
logger.debug(`Found success indicator: ${successResult.text}`);
await sleep(VIDEO_TIMEOUTS.COMPLETION_CHECK);
return true;
}
// Check for error indicators
const errorResult = await this.checkElementForPatterns(
page,
SELECTORS.ERROR_INDICATORS,
TEXT_PATTERNS.ERROR
);
if (errorResult.found) {
throw new PublishError(`Video publish failed with error: ${errorResult.text}`);
}
// Check if we've left the publish page (likely success)
const stillOnPage = await this.findElementBySelectors(
page,
SELECTORS.PUBLISH_PAGE_INDICATORS
);
if (!stillOnPage) {
logger.debug('Left publish page, assuming video publish success');
return true;
}
// Check for processing status
const processingResult = await this.checkElementForPatterns(
page,
SELECTORS.PROCESSING_INDICATORS,
TEXT_PATTERNS.PROCESSING
);
isProcessing = processingResult.found;
if (isProcessing) {
logger.debug(`Video still processing: ${processingResult.text}`);
}
// Check for toast messages
const toastResult = await this.checkElementForPatterns(
page,
SELECTORS.TOAST_SELECTORS,
TEXT_PATTERNS.SUCCESS
);
if (toastResult.found) {
logger.debug(`Found success toast: ${toastResult.text}`);
return true;
}
const errorToastResult = await this.checkElementForPatterns(
page,
SELECTORS.TOAST_SELECTORS,
TEXT_PATTERNS.ERROR
);
if (errorToastResult.found) {
throw new PublishError(`Video publish failed: ${errorToastResult.text}`);
}
return false;
},
VIDEO_TIMEOUTS.COMPLETION_TIMEOUT,
isProcessing ? 5000 : VIDEO_TIMEOUTS.COMPLETION_CHECK,
'Video publish completion timeout - could not determine result after 5 minutes'
);
// Extract note ID after successful completion
return await this.extractNoteIdFromPage(page);
}
// Unified publish method for both images and videos
async publishContent(
type: 'image' | 'video',
title: string,
content: string,
mediaPaths: string[],
tags: string = '',
browserPath?: string
): Promise<PublishResult> {
// Validate inputs
this.validateContentInputs(type, title, content, mediaPaths);
if (type === 'image') {
return await this.publishNote(title, content, mediaPaths, tags, browserPath);
} else {
// For videos, only take the first path
const videoPath = mediaPaths[0];
return await this.publishVideo(title, content, videoPath, tags, browserPath);
}
}
async publishVideo(
title: string,
content: string,
videoPath: string,
tags: string = '',
browserPath?: string
): Promise<PublishResult> {
// Validate inputs
this.validateVideoInputs(title, content, videoPath);
// Validate and resolve video path
const resolvedVideoPath = this.validateAndResolveVideoPath(videoPath);
try {
const page = await this.getBrowserManager().createPage(false, browserPath, true);
try {
const noteId = await this.executeVideoPublishWorkflow(page, title, content, resolvedVideoPath, tags);
// Save cookies
await this.getBrowserManager().saveCookiesFromPage(page);
return {
success: true,
message: 'Video published successfully',
title,
content,
imageCount: 0, // Videos don't have image count
tags,
url: this.getConfig().xhs.creatorVideoPublishUrl,
noteId: noteId || undefined,
};
} finally {
await page.close();
}
} catch (error) {
logger.error(`Video publish error: ${error}`);
throw error;
}
}
private validateContentInputs(
type: 'image' | 'video',
title: string,
content: string,
mediaPaths: string[]
): void {
if (!title?.trim()) {
throw new PublishError(`${type === 'image' ? 'Image' : 'Video'} title cannot be empty`);
}
if (!content?.trim()) {
throw new PublishError(`${type === 'image' ? 'Image' : 'Video'} content cannot be empty`);
}
if (!mediaPaths || mediaPaths.length === 0) {
throw new PublishError(`${type === 'image' ? 'Image' : 'Video'} paths are required`);
}
if (type === 'image' && mediaPaths.length > 18) {
throw new PublishError('Maximum 18 images allowed for image posts');
}
if (type === 'video' && mediaPaths.length !== 1) {
throw new PublishError('Video publishing requires exactly one video file');
}
}
private validateVideoInputs(title: string, content: string, videoPath: string): void {
if (!title?.trim()) {
throw new PublishError('Video title cannot be empty');
}
// Validate title width for video posts too
assertTitleWidthValid(title);
logger.debug(`Video title width validation passed: "${title}" (${getTitleWidth(title)} units)`);
if (!content?.trim()) {
throw new PublishError('Video content cannot be empty');
}
if (!videoPath?.trim()) {
throw new PublishError('Video path is required');
}
}
private async executeVideoPublishWorkflow(
page: Page,
title: string,
content: string,
videoPath: string,
tags: string
): Promise<string | null> {
// Navigate to video upload page
await this.getBrowserManager().navigateWithRetry(
page,
this.getConfig().xhs.creatorVideoPublishUrl
);
// Wait for page to load
await sleep(VIDEO_TIMEOUTS.PAGE_LOAD);
// Switch to video upload tab if needed
await this.clickVideoUploadTab(page);
// Wait for tab switch to complete
await sleep(VIDEO_TIMEOUTS.TAB_SWITCH);
// Upload video
await this.uploadVideo(page, videoPath);
// Wait for video to be processed (videos take longer than images)
await sleep(VIDEO_TIMEOUTS.VIDEO_PROCESSING);
// Fill in title
await this.fillTitle(page, title);
// Wait a bit for content area to appear
await sleep(VIDEO_TIMEOUTS.CONTENT_WAIT);
// Fill in content
await this.fillContent(page, content);
// Add tags if provided
if (tags) {
await this.addTags(page, tags);
}
// Submit the video
await this.submitPost(page);
// Wait for completion and check result (videos need longer timeout)
return await this.waitForVideoPublishCompletion(page);
}
private validateAndResolveVideoPath(videoPath: string): string {
const resolvedPath = join(process.cwd(), videoPath);
if (!existsSync(resolvedPath)) {
throw new PublishError(`Video file not found: ${videoPath}`);
}
const stats = statSync(resolvedPath);
if (!stats.isFile()) {
throw new PublishError(`Path is not a file: ${videoPath}`);
}
// Check file extension
const ext = videoPath.toLowerCase().split('.').pop();
const allowedExtensions = ['mp4', 'mov', 'avi', 'mkv', 'webm', 'flv', 'wmv'];
if (!ext || !allowedExtensions.includes(ext)) {
throw new PublishError(
`Unsupported video format: ${videoPath}. Supported: ${allowedExtensions.join(', ')}`
);
}
// Check file size (XHS typically has limits)
const maxSizeInMB = 500; // 500MB limit
const fileSizeInMB = stats.size / (1024 * 1024);
if (fileSizeInMB > maxSizeInMB) {
throw new PublishError(
`Video file too large: ${fileSizeInMB.toFixed(2)}MB. Maximum allowed: ${maxSizeInMB}MB`
);
}
return resolvedPath;
}
private async clickVideoUploadTab(page: Page): Promise<void> {
try {
// Try multiple selectors for video tab
const videoTabSelectors = [
'div.creator-tab',
'.creator-tab',
'[role="tab"]',
'.tab',
'div[class*="tab"]',
];
let tabs: any[] = [];
for (const selector of videoTabSelectors) {
const foundTabs = await page.$$(selector);
if (foundTabs.length > 0) {
tabs = foundTabs;
break;
}
}
if (tabs.length === 0) {
logger.warn('No tabs found for video upload');
return;
}
// Look for the video upload tab specifically
let videoTab: any = null;
for (let i = 0; i < tabs.length; i++) {
const tab = tabs[i];
try {
const isVisible = await tab.isIntersectingViewport();
if (!isVisible) continue;
const text = await page.evaluate((el) => el.textContent, tab);
// Check if this is the video upload tab
if (
text &&
(text.includes('上传视频') || text.includes('视频') || text.includes('video'))
) {
videoTab = tab;
break;
}
} catch (error) {
// Ignore individual tab errors
}
}
if (videoTab) {
await videoTab.click();
await sleep(2000); // Wait for tab switch
} else {
// Fallback: click the first tab (usually video upload)
const visibleTabs: any[] = [];
for (const tab of tabs) {
const isVisible = await tab.isIntersectingViewport();
if (isVisible) {
visibleTabs.push(tab);
}
}
if (visibleTabs.length > 0) {
await visibleTabs[0].click(); // Usually the first tab is video upload
await sleep(2000);
}
}
} catch (error) {
logger.warn(`Failed to click video upload tab: ${error}`);
}
}
private async uploadVideo(page: Page, videoPath: string): Promise<void> {
logger.debug(`Uploading video: ${videoPath}`);
// Find file input element
const fileInput = await this.findElementBySelectors(page, SELECTORS.FILE_INPUT);
if (!fileInput) {
throw new PublishError('Could not find file upload input on video upload page');
}
try {
// Wait for the input to be ready
await sleep(VIDEO_TIMEOUTS.UPLOAD_READY);
// Upload the video file
await fileInput.uploadFile(videoPath);
logger.debug('Video file uploaded, waiting for processing...');
// Wait for upload to start and show progress
await sleep(VIDEO_TIMEOUTS.UPLOAD_START);
// Wait for video processing to complete (this can take a while)
await this.waitForVideoProcessing(page);
} catch (error) {
throw new PublishError(`Failed to upload video ${videoPath}: ${error}`);
}
}
private async waitForVideoProcessing(page: Page): Promise<void> {
logger.debug('Waiting for video processing to complete...');
try {
await this.waitForCondition(
async () => {
// Check if processing is complete
const completeResult = await this.checkElementForPatterns(
page,
SELECTORS.COMPLETION_INDICATORS,
TEXT_PATTERNS.SUCCESS
);
if (completeResult.found) {
logger.debug(`Video processing complete: ${completeResult.text}`);
return true;
}
// Check if still processing
const processingResult = await this.checkElementForPatterns(
page,
SELECTORS.PROCESSING_INDICATORS,
TEXT_PATTERNS.PROCESSING
);
if (processingResult.found) {
logger.debug(`Video processing: ${processingResult.text}`);
return false; // Still processing, continue waiting
}
// If not processing and no completion indicator, assume it's done
logger.debug('No processing indicators found, assuming video processing complete');
return true;
},
VIDEO_TIMEOUTS.PROCESSING_TIMEOUT,
VIDEO_TIMEOUTS.PROCESSING_CHECK,
'Video processing timeout'
);
} catch (error) {
logger.warn('Video processing timeout, continuing anyway...');
}
}
}