修复图文发布:创作中心导航、图文标签切换及表单选择器

- 发布前先访问主站建立 session,再跳转创作中心(直接导航会 401)
- 切换「上传图文」标签:跳过 style.left=-9999px 的隐藏副本,不用 getBoundingClientRect
- 修复上传缩略图选择器:.upload-item img → .img-upload-area .img-container
- 修复标题输入框:#note-title → input.d-text[placeholder*="标题"]
- 修复内容编辑器:#note-content → .tiptap.ProseMirror
- 修复发布按钮:.publishBtn → button.d-button:has-text("发布")
- 新增 debug-publish.ts 调试脚本
This commit is contained in:
2026-03-02 01:08:03 +08:00
parent 9d0a9c93f4
commit 7661a723ea
3 changed files with 142 additions and 13 deletions
+47 -6
View File
@@ -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) {
+7 -7
View File
@@ -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. */