Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 22 additions & 8 deletions src/clis/xiaohongshu/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 ๅ›พๆ–‡');
Expand All @@ -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,
Expand All @@ -122,6 +135,7 @@ describe('xiaohongshu publish', () => {
const result = await cmd!.func!(page, {
title: 'ๅปถ่ฟŸๅˆ‡ๆขไนŸ่ƒฝ่ฟ‡',
content: 'ๅ›พๆ–‡้กตๅˆ‡ๆขๆ…ขไธ€็‚นไนŸ็ปง็ปญ็ญ‰',
images: imagePath,
topics: '',
draft: false,
});
Expand All @@ -130,7 +144,7 @@ describe('xiaohongshu publish', () => {
expect(result).toEqual([
{
status: 'โœ… ๅ‘ๅธƒๆˆๅŠŸ',
detail: '"ๅปถ่ฟŸๅˆ‡ๆขไนŸ่ƒฝ่ฟ‡" ยท ๆ— ๅ›พ ยท ๅ‘ๅธƒๆˆๅŠŸ',
detail: '"ๅปถ่ฟŸๅˆ‡ๆขไนŸ่ƒฝ่ฟ‡" ยท 1ๅผ ๅ›พ็‰‡ ยท ๅ‘ๅธƒๆˆๅŠŸ',
},
]);
});
Expand Down
145 changes: 93 additions & 52 deletions src/clis/xiaohongshu/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };

/**
Expand Down Expand Up @@ -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<PublishSurfaceInspection> {
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();
Expand All @@ -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<PublishSurfaceInspection> {
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<boolean> {
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',
Expand All @@ -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: 'ไฟๅญ˜ไธบ่‰็จฟ๏ผŒไธ็›ดๆŽฅๅ‘ๅธƒ' },
],
Expand All @@ -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 <content> 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})`);

Expand All @@ -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}"`
Expand All @@ -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 โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
Expand All @@ -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'
Expand Down Expand Up @@ -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
) {
Expand All @@ -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'
);
}
Expand All @@ -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 '';
Expand All @@ -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 || '',
]
Expand Down