修复图文发布:创作中心导航、图文标签切换及表单选择器
- 发布前先访问主站建立 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:
@@ -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); });
|
||||||
@@ -7,7 +7,8 @@ import { XHS_SELECTORS } from './selectors.js';
|
|||||||
// Constants
|
// 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). */
|
/** Maximum time to wait for image uploads to finish (60 seconds). */
|
||||||
const UPLOAD_WAIT_TIMEOUT_MS = 60_000;
|
const UPLOAD_WAIT_TIMEOUT_MS = 60_000;
|
||||||
@@ -65,16 +66,56 @@ export async function publishImageNote(
|
|||||||
// 1. Navigate to the creator publish page
|
// 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' });
|
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);
|
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, {
|
const fileInput = await page.waitForSelector(sel.imageFileInput, {
|
||||||
timeout: 10_000,
|
timeout: 10_000,
|
||||||
|
state: 'attached',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Playwright's setInputFiles supports multiple files at once.
|
// Playwright's setInputFiles supports multiple files at once.
|
||||||
@@ -93,7 +134,7 @@ export async function publishImageNote(
|
|||||||
log.debug('All images uploaded successfully');
|
log.debug('All images uploaded successfully');
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// 3. Fill in the title
|
// 4. Fill in the title
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
const titleInput = await page.waitForSelector(sel.titleInput, {
|
const titleInput = await page.waitForSelector(sel.titleInput, {
|
||||||
@@ -105,7 +146,7 @@ export async function publishImageNote(
|
|||||||
await page.waitForTimeout(FIELD_SETTLE_MS);
|
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, {
|
const contentEditor = await page.waitForSelector(sel.contentEditor, {
|
||||||
@@ -116,7 +157,7 @@ export async function publishImageNote(
|
|||||||
await page.waitForTimeout(FIELD_SETTLE_MS);
|
await page.waitForTimeout(FIELD_SETTLE_MS);
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// 5. Add tags (optional)
|
// 6. Add tags (optional)
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
if (options?.tags && options.tags.length > 0) {
|
if (options?.tags && options.tags.length > 0) {
|
||||||
|
|||||||
@@ -134,17 +134,17 @@ export const XHS_SELECTORS = {
|
|||||||
/** The file input element for uploading images on the creator publish page. */
|
/** The file input element for uploading images on the creator publish page. */
|
||||||
imageFileInput: 'input[type="file"]',
|
imageFileInput: 'input[type="file"]',
|
||||||
/** Title input field on the publish form. */
|
/** Title input field on the publish form. */
|
||||||
titleInput: '#note-title',
|
titleInput: 'input.d-text[placeholder*="标题"]',
|
||||||
/** Content / body editor area on the publish form (contenteditable). */
|
/** Content / body editor area on the publish form (contenteditable ProseMirror). */
|
||||||
contentEditor: '#note-content',
|
contentEditor: '.tiptap.ProseMirror',
|
||||||
/** The tag / topic button that opens the topic input. */
|
/** The tag / topic button that opens the topic input. */
|
||||||
tagButton: '#topicBtn',
|
tagButton: 'button.contentBtn.topic-btn',
|
||||||
/** Tag / topic input field for typing hashtags. */
|
/** Tag / topic input field for typing hashtags. */
|
||||||
tagInput: '#topicBtn input',
|
tagInput: 'button.contentBtn.topic-btn input',
|
||||||
/** Topic / hashtag suggestion dropdown item. */
|
/** Topic / hashtag suggestion dropdown item. */
|
||||||
tagSuggestionItem: '.publish-topic-item, .topic-item',
|
tagSuggestionItem: '.publish-topic-item, .topic-item',
|
||||||
/** "Publish" / submit button. */
|
/** "Publish" / submit button. */
|
||||||
publishButton: '.publishBtn',
|
publishButton: 'button.d-button:has-text("发布")',
|
||||||
/** Schedule / timing selector button. */
|
/** Schedule / timing selector button. */
|
||||||
scheduleButton: '.timing-btn, button:has-text("定时")',
|
scheduleButton: '.timing-btn, button:has-text("定时")',
|
||||||
/** Schedule date/time input field. */
|
/** Schedule date/time input field. */
|
||||||
@@ -160,7 +160,7 @@ export const XHS_SELECTORS = {
|
|||||||
/** Visibility option for friends only. */
|
/** Visibility option for friends only. */
|
||||||
visibilityFriends: '.permission-option:has-text("好友"), .visibility-option:has-text("好友")',
|
visibilityFriends: '.permission-option:has-text("好友"), .visibility-option:has-text("好友")',
|
||||||
/** Upload complete indicator (images uploaded and thumbnails visible). */
|
/** 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). */
|
/** Video upload complete indicator (video thumbnail visible). */
|
||||||
uploadedVideoItem: '.upload-video video, .video-item video, .video-container video',
|
uploadedVideoItem: '.upload-video video, .video-item video, .video-container video',
|
||||||
/** Success indicator shown after publish completes. */
|
/** Success indicator shown after publish completes. */
|
||||||
|
|||||||
Reference in New Issue
Block a user