diff --git a/scripts/debug-publish.ts b/scripts/debug-publish.ts new file mode 100644 index 0000000..38beeed --- /dev/null +++ b/scripts/debug-publish.ts @@ -0,0 +1,88 @@ +import { chromium } from 'rebrowser-playwright'; +import { readFileSync } from 'node:fs'; + +const COOKIE_FILE = `${process.env.HOME}/.social-mcp/xiaohongshu/cookies.json`; + +async function main() { + const raw = JSON.parse(readFileSync(COOKIE_FILE, 'utf-8')); + const browser = await chromium.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-gpu'] }); + const ctx = await browser.newContext({ storageState: raw }); + const page = await ctx.newPage(); + + console.log('Visiting main site...'); + await page.goto('https://www.xiaohongshu.com/explore', { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(2000); + + // Find creator center link on the main site + const creatorLinks = await page.$$eval('a[href*="creator"]', els => + els.map(el => ({ href: el.getAttribute('href'), text: el.textContent?.trim() })).slice(0, 5) + ); + console.log('Creator links on main site:', creatorLinks); + + // The creator link opens in a new tab — capture it + const [creatorPage] = await Promise.all([ + ctx.waitForEvent('page'), + page.click('a[href*="creator.xiaohongshu.com/publish"]'), + ]); + await creatorPage.waitForLoadState('domcontentloaded'); + await creatorPage.waitForTimeout(3000); + + console.log('\nCreator page URL:', creatorPage.url()); + console.log('Creator page title:', await creatorPage.title()); + + console.log('Title:', await page.title()); + console.log('URL:', page.url()); + + const pg = creatorPage; + + // Switch to image tab — skip off-screen duplicate + const switched = await pg.$$eval('.creator-tab', (tabs) => { + for (const tab of tabs) { + if (!tab.textContent?.includes('图文')) continue; + const style = (tab as HTMLElement).style; + if (style.left && parseInt(style.left) < -100) continue; + const rect = tab.getBoundingClientRect(); + if (rect.width === 0 && rect.height === 0) continue; + (tab as HTMLElement).click(); + return true; + } + return false; + }); + console.log('Switched to image tab:', switched); + await pg.waitForTimeout(1000); + + // Set the file on the hidden input + const fileInput = await pg.waitForSelector('input[type="file"]', { state: 'attached', timeout: 10000 }); + console.log('File input found, setting file...'); + await fileInput.setInputFiles('/Users/xd/Downloads/IMG_1479.png'); + console.log('File set. Waiting 5s for upload...'); + await pg.waitForTimeout(5000); + + // Check what appeared after upload + const allImgEls = await pg.$$eval('img', els => + els.filter(el => el.src && !el.src.startsWith('data:') && el.offsetParent !== null) + .map(el => ({ src: el.src.slice(0, 80), cls: el.className })) + ); + console.log('\nVisible imgs after upload:', allImgEls); + + // Check fixed selector + const imgContainers = await pg.$$('.img-upload-area .img-container'); + console.log('\n.img-upload-area .img-container count:', imgContainers.length); + + // Verify updated selectors + const imgContainerCount = await pg.$$('.img-upload-area .img-container').then(els => els.length); + console.log('\n[selector check] .img-upload-area .img-container:', imgContainerCount); + + const titleInput = await pg.$('input.d-text[placeholder*="标题"]'); + console.log('[selector check] title input:', !!titleInput); + + const contentEditor = await pg.$('.tiptap.ProseMirror'); + console.log('[selector check] content editor:', !!contentEditor); + + const publishBtn = await pg.$('button.d-button:has-text("发布")'); + console.log('[selector check] publish button:', !!publishBtn, publishBtn ? await publishBtn.textContent() : ''); + + await browser.close(); +} + +main().catch(e => { console.error(e); process.exit(1); }); diff --git a/src/platforms/xiaohongshu/publish.ts b/src/platforms/xiaohongshu/publish.ts index ddea568..a04e55e 100644 --- a/src/platforms/xiaohongshu/publish.ts +++ b/src/platforms/xiaohongshu/publish.ts @@ -7,7 +7,8 @@ import { XHS_SELECTORS } from './selectors.js'; // Constants // --------------------------------------------------------------------------- -const CREATOR_PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish'; +const MAIN_EXPLORE_URL = 'https://www.xiaohongshu.com/explore'; +const CREATOR_PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?source=official'; /** Maximum time to wait for image uploads to finish (60 seconds). */ const UPLOAD_WAIT_TIMEOUT_MS = 60_000; @@ -65,16 +66,56 @@ export async function publishImageNote( // 1. Navigate to the creator publish page // ------------------------------------------------------------------------- + // creator.xiaohongshu.com returns 401 when navigated to directly (headless). + // Visiting the main site first establishes the session context that allows + // the creator center to accept the shared .xiaohongshu.com cookies. + await page.goto(MAIN_EXPLORE_URL, { waitUntil: 'domcontentloaded' }); + await page.waitForTimeout(1_000); + await page.goto(CREATOR_PUBLISH_URL, { waitUntil: 'domcontentloaded' }); - // Allow the SPA to hydrate. + // Allow the SPA to hydrate and tabs to render. await page.waitForTimeout(2_000); + // Verify we're not on the login page. + if (page.url().includes('/login')) { + throw new Error('Creator center redirected to login — cookies may be expired'); + } + // ------------------------------------------------------------------------- - // 2. Upload images via the file input + // 2. Switch to the image-note tab (page defaults to video upload) // ------------------------------------------------------------------------- + // The publish page has tabs: 上传视频, 上传图文, 写长文. + // One of the 上传图文 tabs is rendered off-screen (left:-9999px) as a + // hidden duplicate — we must click only the visible (on-screen) one. + const clicked = await page.$$eval('.creator-tab', (tabs) => { + for (const tab of tabs) { + if (!tab.textContent?.includes('图文')) continue; + // Skip the off-screen duplicate (style="position:absolute;left:-9999px") + const styleLeft = (tab as HTMLElement).style.left; + if (styleLeft && parseInt(styleLeft, 10) < -100) continue; + (tab as HTMLElement).click(); + return true; + } + return false; + }); + + if (clicked) { + await page.waitForTimeout(1_000); + log.debug('Switched to image note tab'); + } else { + log.debug('Image tab not found — assuming page is already in image mode'); + } + + // ------------------------------------------------------------------------- + // 3. Upload images via the file input + // ------------------------------------------------------------------------- + + // The file input is hidden (display:none) by design; Playwright's + // setInputFiles works on hidden inputs without needing visibility. const fileInput = await page.waitForSelector(sel.imageFileInput, { timeout: 10_000, + state: 'attached', }); // Playwright's setInputFiles supports multiple files at once. @@ -93,7 +134,7 @@ export async function publishImageNote( log.debug('All images uploaded successfully'); // ------------------------------------------------------------------------- - // 3. Fill in the title + // 4. Fill in the title // ------------------------------------------------------------------------- const titleInput = await page.waitForSelector(sel.titleInput, { @@ -105,7 +146,7 @@ export async function publishImageNote( await page.waitForTimeout(FIELD_SETTLE_MS); // ------------------------------------------------------------------------- - // 4. Fill in the content / description + // 5. Fill in the content / description // ------------------------------------------------------------------------- const contentEditor = await page.waitForSelector(sel.contentEditor, { @@ -116,7 +157,7 @@ export async function publishImageNote( await page.waitForTimeout(FIELD_SETTLE_MS); // ------------------------------------------------------------------------- - // 5. Add tags (optional) + // 6. Add tags (optional) // ------------------------------------------------------------------------- if (options?.tags && options.tags.length > 0) { diff --git a/src/platforms/xiaohongshu/selectors.ts b/src/platforms/xiaohongshu/selectors.ts index 9d49c61..f653596 100644 --- a/src/platforms/xiaohongshu/selectors.ts +++ b/src/platforms/xiaohongshu/selectors.ts @@ -134,17 +134,17 @@ export const XHS_SELECTORS = { /** The file input element for uploading images on the creator publish page. */ imageFileInput: 'input[type="file"]', /** Title input field on the publish form. */ - titleInput: '#note-title', - /** Content / body editor area on the publish form (contenteditable). */ - contentEditor: '#note-content', + titleInput: 'input.d-text[placeholder*="标题"]', + /** Content / body editor area on the publish form (contenteditable ProseMirror). */ + contentEditor: '.tiptap.ProseMirror', /** The tag / topic button that opens the topic input. */ - tagButton: '#topicBtn', + tagButton: 'button.contentBtn.topic-btn', /** Tag / topic input field for typing hashtags. */ - tagInput: '#topicBtn input', + tagInput: 'button.contentBtn.topic-btn input', /** Topic / hashtag suggestion dropdown item. */ tagSuggestionItem: '.publish-topic-item, .topic-item', /** "Publish" / submit button. */ - publishButton: '.publishBtn', + publishButton: 'button.d-button:has-text("发布")', /** Schedule / timing selector button. */ scheduleButton: '.timing-btn, button:has-text("定时")', /** Schedule date/time input field. */ @@ -160,7 +160,7 @@ export const XHS_SELECTORS = { /** Visibility option for friends only. */ visibilityFriends: '.permission-option:has-text("好友"), .visibility-option:has-text("好友")', /** Upload complete indicator (images uploaded and thumbnails visible). */ - uploadedImageItem: '.upload-item img, .img-item img, .image-item img', + uploadedImageItem: '.img-upload-area .img-container', /** Video upload complete indicator (video thumbnail visible). */ uploadedVideoItem: '.upload-video video, .video-item video, .video-container video', /** Success indicator shown after publish completes. */