diff --git a/src/clis/xiaohongshu/publish.test.ts b/src/clis/xiaohongshu/publish.test.ts index 579273ff..9fcc0326 100644 --- a/src/clis/xiaohongshu/publish.test.ts +++ b/src/clis/xiaohongshu/publish.test.ts @@ -51,9 +51,10 @@ describe('xiaohongshu publish', () => { const page = createPageMock([ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left', { ok: true, target: '上传图文', text: '上传图文' }, - { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false }, + { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false }, { ok: true, count: 1 }, false, + true, // waitForEditForm: editor appeared { ok: true, sel: 'input[maxlength="20"]' }, { ok: true, sel: '[contenteditable="true"][class*="content"]' }, true, @@ -84,18 +85,23 @@ describe('xiaohongshu publish', () => { const cmd = getRegistry().get('xiaohongshu/publish'); expect(cmd?.func).toBeTypeOf('function'); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-')); + const imagePath = path.join(tempDir, 'demo.jpg'); + fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + const page = createPageMock([ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left', { ok: false, visibleTexts: ['上传视频', '上传图文'] }, - { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, - { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, - { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, - { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, + { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, + { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, + { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, + { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, ]); await expect(cmd!.func!(page, { title: 'DeepSeek别乱问', content: '一篇真实一点的小红书正文', + images: imagePath, topics: '', draft: false, })).rejects.toThrow('Still on the video publish page after trying to select 图文'); @@ -107,11 +113,18 @@ describe('xiaohongshu publish', () => { const cmd = getRegistry().get('xiaohongshu/publish'); expect(cmd?.func).toBeTypeOf('function'); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'opencli-xhs-publish-')); + const imagePath = path.join(tempDir, 'demo.jpg'); + fs.writeFileSync(imagePath, Buffer.from([0xff, 0xd8, 0xff, 0xd9])); + const page = createPageMock([ 'https://creator.xiaohongshu.com/publish/publish?from=menu_left', { ok: true, target: '上传图文', text: '上传图文' }, - { hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, - { hasTitleInput: true, hasImageInput: true, hasVideoSurface: false }, + { state: 'video_surface', hasTitleInput: false, hasImageInput: false, hasVideoSurface: true }, + { state: 'editor_ready', hasTitleInput: true, hasImageInput: true, hasVideoSurface: false }, + { ok: true, count: 1 }, // injectImages + false, // waitForUploads: no progress indicator + true, // waitForEditForm: editor appeared { ok: true, sel: 'input[maxlength="20"]' }, { ok: true, sel: '[contenteditable="true"][class*="content"]' }, true, @@ -122,6 +135,7 @@ describe('xiaohongshu publish', () => { const result = await cmd!.func!(page, { title: '延迟切换也能过', content: '图文页切换慢一点也继续等', + images: imagePath, topics: '', draft: false, }); @@ -130,7 +144,7 @@ describe('xiaohongshu publish', () => { expect(result).toEqual([ { status: '✅ 发布成功', - detail: '"延迟切换也能过" · 无图 · 发布成功', + detail: '"延迟切换也能过" · 1张图片 · 发布成功', }, ]); }); diff --git a/src/clis/xiaohongshu/publish.ts b/src/clis/xiaohongshu/publish.ts index 1b85ff37..e5c7c544 100644 --- a/src/clis/xiaohongshu/publish.ts +++ b/src/clis/xiaohongshu/publish.ts @@ -27,6 +27,22 @@ const MAX_IMAGES = 9; const MAX_TITLE_LEN = 20; const UPLOAD_SETTLE_MS = 3000; +/** Selectors for the title field, ordered by priority (new UI first). */ +const TITLE_SELECTORS = [ + // New creator center (2026-03) uses contenteditable for the title field. + // Placeholder observed: "填写标题会有更多赞哦" + '[contenteditable="true"][placeholder*="标题"]', + '[contenteditable="true"][placeholder*="赞"]', + '[contenteditable="true"][class*="title"]', + 'input[maxlength="20"]', + 'input[class*="title"]', + 'input[placeholder*="标题"]', + 'input[placeholder*="title" i]', + '.title-input input', + '.note-title input', + 'input[maxlength]', +]; + type ImagePayload = { name: string; mimeType: string; base64: string }; /** @@ -205,12 +221,19 @@ async function selectImageTextTab( return result; } -async function inspectPublishSurface( - page: IPage, -): Promise<{ hasTitleInput: boolean; hasImageInput: boolean; hasVideoSurface: boolean }> { +type PublishSurfaceState = 'video_surface' | 'image_surface' | 'editor_ready'; + +type PublishSurfaceInspection = { + state: PublishSurfaceState; + hasTitleInput: boolean; + hasImageInput: boolean; + hasVideoSurface: boolean; +}; + +async function inspectPublishSurfaceState(page: IPage): Promise { return page.evaluate(` () => { - const text = (document.body?.innerText || '').replace(/\\s+/g, ' ').trim(); + const text = (document.body?.innerText || '').replace(/\s+/g, ' ').trim(); const hasTitleInput = !!Array.from(document.querySelectorAll('input, textarea')).find((el) => { if (!el || el.offsetParent === null) return false; const placeholder = (el.getAttribute('placeholder') || '').trim(); @@ -234,36 +257,57 @@ async function inspectPublishSurface( accept.includes('.webp') ); }); - return { - hasTitleInput, - hasImageInput, - hasVideoSurface: text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'), - }; + const hasVideoSurface = text.includes('拖拽视频到此处点击上传') || text.includes('上传视频'); + const state = hasTitleInput ? 'editor_ready' : hasImageInput || !hasVideoSurface ? 'image_surface' : 'video_surface'; + return { state, hasTitleInput, hasImageInput, hasVideoSurface }; } `); } -async function waitForImageTextSurface( +async function waitForPublishSurfaceState( page: IPage, maxWaitMs = 5_000, -): Promise<{ hasTitleInput: boolean; hasImageInput: boolean; hasVideoSurface: boolean }> { +): Promise { const pollMs = 500; const maxAttempts = Math.max(1, Math.ceil(maxWaitMs / pollMs)); - let surface = await inspectPublishSurface(page); + let surface = await inspectPublishSurfaceState(page); for (let i = 0; i < maxAttempts; i++) { - if (surface.hasTitleInput || surface.hasImageInput || !surface.hasVideoSurface) { + if (surface.state !== 'video_surface') { return surface; } if (i < maxAttempts - 1) { await page.wait({ time: pollMs / 1_000 }); - surface = await inspectPublishSurface(page); + surface = await inspectPublishSurfaceState(page); } } return surface; } +/** + * Poll until the title/content editing form appears on the page. + * The new creator center UI only renders the editor after images are uploaded. + */ +async function waitForEditForm(page: IPage, maxWaitMs = 10_000): Promise { + const pollMs = 1_000; + const maxAttempts = Math.ceil(maxWaitMs / pollMs); + for (let i = 0; i < maxAttempts; i++) { + const found: boolean = await page.evaluate(` + (() => { + const sels = ${JSON.stringify(TITLE_SELECTORS)}; + for (const sel of sels) { + const el = document.querySelector(sel); + if (el && el.offsetParent !== null) return true; + } + return false; + })()`); + if (found) return true; + if (i < maxAttempts - 1) await page.wait({ time: pollMs / 1_000 }); + } + return false; +} + cli({ site: 'xiaohongshu', name: 'publish', @@ -274,7 +318,7 @@ cli({ args: [ { name: 'title', required: true, help: '笔记标题 (最多20字)' }, { name: 'content', required: true, positional: true, help: '笔记正文' }, - { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' }, + { name: 'images', required: true, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' }, { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' }, { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' }, ], @@ -297,6 +341,8 @@ cli({ if (title.length > MAX_TITLE_LEN) throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`); if (!content) throw new Error('Positional argument is required'); + if (imagePaths.length === 0) + throw new Error('At least one --images path is required. The creator center now requires images before showing the editor.'); if (imagePaths.length > MAX_IMAGES) throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`); @@ -318,8 +364,8 @@ cli({ // ── Step 2: Select 图文 (image+text) note type if tabs are present ───────── const tabResult = await selectImageTextTab(page); - const surface = await waitForImageTextSurface(page, tabResult?.ok ? 5_000 : 2_000); - if (!surface.hasTitleInput && !surface.hasImageInput && surface.hasVideoSurface) { + const surface = await waitForPublishSurfaceState(page, tabResult?.ok ? 5_000 : 2_000); + if (surface.state === 'video_surface') { await page.screenshot({ path: '/tmp/xhs_publish_tab_debug.png' }); const detail = tabResult?.ok ? `clicked "${tabResult.text}"` @@ -331,35 +377,30 @@ cli({ } // ── Step 3: Upload images ────────────────────────────────────────────────── - if (imageData.length > 0) { - const upload = await injectImages(page, imageData); - if (!upload.ok) { - await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' }); - throw new Error( - `Image injection failed: ${upload.error ?? 'unknown'}. ` + - 'Debug screenshot: /tmp/xhs_publish_upload_debug.png' - ); - } - // Allow XHS to process and upload images to its CDN - await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 }); - await waitForUploads(page); + const upload = await injectImages(page, imageData); + if (!upload.ok) { + await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' }); + throw new Error( + `Image injection failed: ${upload.error ?? 'unknown'}. ` + + 'Debug screenshot: /tmp/xhs_publish_upload_debug.png' + ); + } + // Allow XHS to process and upload images to its CDN + await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 }); + await waitForUploads(page); + + // ── Step 3b: Wait for editor form to render ─────────────────────────────── + const formReady = await waitForEditForm(page); + if (!formReady) { + await page.screenshot({ path: '/tmp/xhs_publish_form_debug.png' }); + throw new Error( + 'Editing form did not appear after image upload. The page layout may have changed. ' + + 'Debug screenshot: /tmp/xhs_publish_form_debug.png' + ); } // ── Step 4: Fill title ───────────────────────────────────────────────────── - await fillField( - page, - [ - 'input[maxlength="20"]', - 'input[class*="title"]', - 'input[placeholder*="标题"]', - 'input[placeholder*="title" i]', - '.title-input input', - '.note-title input', - 'input[maxlength]', - ], - title, - 'title' - ); + await fillField(page, TITLE_SELECTORS, title, 'title'); await page.wait({ time: 0.5 }); // ── Step 5: Fill content / body ──────────────────────────────────────────── @@ -374,7 +415,7 @@ cli({ '.note-content [contenteditable="true"]', '.editor-content [contenteditable="true"]', // Broad fallback — last resort; filter out any title contenteditable - '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])', + '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="赞"]):not([placeholder*="title" i])', ], content, 'content' @@ -438,14 +479,14 @@ cli({ } // ── Step 7: Publish or save draft ───────────────────────────────────────── - const actionLabel = isDraft ? '存草稿' : '发布'; + const actionLabels = isDraft ? ['暂存离开', '存草稿'] : ['发布', '发布笔记']; const btnClicked: boolean = await page.evaluate(` - (label => { + (labels => { const buttons = document.querySelectorAll('button, [role="button"]'); for (const btn of buttons) { const text = (btn.innerText || btn.textContent || '').trim(); if ( - (text === label || text.includes(label) || text === '发布笔记') && + labels.some(l => text === l || text.includes(l)) && btn.offsetParent !== null && !btn.disabled ) { @@ -454,13 +495,13 @@ cli({ } } return false; - })(${JSON.stringify(actionLabel)}) + })(${JSON.stringify(actionLabels)}) `); if (!btnClicked) { await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' }); throw new Error( - `Could not find "${actionLabel}" button. ` + + `Could not find "${actionLabels[0]}" button. ` + 'Debug screenshot: /tmp/xhs_publish_submit_debug.png' ); } @@ -475,7 +516,7 @@ cli({ const text = (el.innerText || '').trim(); if ( el.children.length === 0 && - (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功')) + (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('暂存成功') || text.includes('上传成功')) ) return text; } return ''; @@ -484,14 +525,14 @@ cli({ const navigatedAway = !finalUrl.includes('/publish/publish'); const isSuccess = successMsg.length > 0 || navigatedAway; - const verb = isDraft ? '草稿已保存' : '发布成功'; + const verb = isDraft ? '暂存成功' : '发布成功'; return [ { status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认', detail: [ `"${title}"`, - imageData.length ? `${imageData.length}张图片` : '无图', + `${imageData.length}张图片`, topics.length ? `话题: ${topics.join(' ')}` : '', successMsg || finalUrl || '', ]