|
| 1 | +/** |
| 2 | + * Xiaohongshu 图文笔记 publisher — creator center UI automation. |
| 3 | + * |
| 4 | + * Flow: |
| 5 | + * 1. Navigate to creator publish page |
| 6 | + * 2. Upload images via DataTransfer injection into the file input |
| 7 | + * 3. Fill title and body text |
| 8 | + * 4. Add topic hashtags |
| 9 | + * 5. Publish (or save as draft) |
| 10 | + * |
| 11 | + * Requires: logged into creator.xiaohongshu.com in Chrome. |
| 12 | + * |
| 13 | + * Usage: |
| 14 | + * opencli xiaohongshu publish --title "标题" "正文内容" \ |
| 15 | + * --images /path/a.jpg,/path/b.jpg \ |
| 16 | + * --topics 生活,旅行 |
| 17 | + */ |
| 18 | + |
| 19 | +import * as fs from 'node:fs'; |
| 20 | +import * as path from 'node:path'; |
| 21 | + |
| 22 | +import { cli, Strategy } from '../../registry.js'; |
| 23 | +import type { IPage } from '../../types.js'; |
| 24 | + |
| 25 | +const PUBLISH_URL = 'https://creator.xiaohongshu.com/publish/publish?from=menu_left'; |
| 26 | +const MAX_IMAGES = 9; |
| 27 | +const MAX_TITLE_LEN = 20; |
| 28 | +const UPLOAD_SETTLE_MS = 3000; |
| 29 | + |
| 30 | +type ImagePayload = { name: string; mimeType: string; base64: string }; |
| 31 | + |
| 32 | +/** |
| 33 | + * Read a local image and return the name, MIME type, and base64 content. |
| 34 | + * Throws if the file does not exist or the extension is unsupported. |
| 35 | + */ |
| 36 | +function readImageFile(filePath: string): ImagePayload { |
| 37 | + const absPath = path.resolve(filePath); |
| 38 | + if (!fs.existsSync(absPath)) throw new Error(`Image file not found: ${absPath}`); |
| 39 | + const ext = path.extname(absPath).toLowerCase(); |
| 40 | + const mimeMap: Record<string, string> = { |
| 41 | + '.jpg': 'image/jpeg', |
| 42 | + '.jpeg': 'image/jpeg', |
| 43 | + '.png': 'image/png', |
| 44 | + '.gif': 'image/gif', |
| 45 | + '.webp': 'image/webp', |
| 46 | + }; |
| 47 | + const mimeType = mimeMap[ext]; |
| 48 | + if (!mimeType) throw new Error(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`); |
| 49 | + const base64 = fs.readFileSync(absPath).toString('base64'); |
| 50 | + return { name: path.basename(absPath), mimeType, base64 }; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * Inject images into the page's file input using DataTransfer. |
| 55 | + * Converts base64 payloads to File objects in the browser context, then dispatches |
| 56 | + * a synthetic 'change' event on the input element. |
| 57 | + * |
| 58 | + * Returns { ok, count, error }. |
| 59 | + */ |
| 60 | +async function injectImages(page: IPage, images: ImagePayload[]): Promise<{ ok: boolean; count: number; error?: string }> { |
| 61 | + const payload = JSON.stringify(images); |
| 62 | + return page.evaluate(` |
| 63 | + (async () => { |
| 64 | + const images = ${payload}; |
| 65 | +
|
| 66 | + // Prefer image/* file inputs; fall back to the first available input. |
| 67 | + const inputs = Array.from(document.querySelectorAll('input[type="file"]')); |
| 68 | + const input = inputs.find(el => { |
| 69 | + const accept = el.getAttribute('accept') || ''; |
| 70 | + return accept.includes('image') || accept.includes('.jpg') || accept.includes('.png'); |
| 71 | + }) || inputs[0]; |
| 72 | +
|
| 73 | + if (!input) return { ok: false, count: 0, error: 'No file input found on page' }; |
| 74 | +
|
| 75 | + const dt = new DataTransfer(); |
| 76 | + for (const img of images) { |
| 77 | + try { |
| 78 | + const binary = atob(img.base64); |
| 79 | + const bytes = new Uint8Array(binary.length); |
| 80 | + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); |
| 81 | + const blob = new Blob([bytes], { type: img.mimeType }); |
| 82 | + dt.items.add(new File([blob], img.name, { type: img.mimeType })); |
| 83 | + } catch (e) { |
| 84 | + return { ok: false, count: 0, error: 'Failed to create File: ' + e.message }; |
| 85 | + } |
| 86 | + } |
| 87 | +
|
| 88 | + Object.defineProperty(input, 'files', { value: dt.files, writable: false }); |
| 89 | + input.dispatchEvent(new Event('change', { bubbles: true })); |
| 90 | + input.dispatchEvent(new Event('input', { bubbles: true })); |
| 91 | +
|
| 92 | + return { ok: true, count: dt.files.length }; |
| 93 | + })() |
| 94 | + `); |
| 95 | +} |
| 96 | + |
| 97 | +/** |
| 98 | + * Wait until all upload progress indicators have disappeared (up to maxWaitMs). |
| 99 | + */ |
| 100 | +async function waitForUploads(page: IPage, maxWaitMs = 30_000): Promise<void> { |
| 101 | + const pollMs = 2_000; |
| 102 | + const maxAttempts = Math.ceil(maxWaitMs / pollMs); |
| 103 | + for (let i = 0; i < maxAttempts; i++) { |
| 104 | + const uploading: boolean = await page.evaluate(` |
| 105 | + () => !!document.querySelector( |
| 106 | + '[class*="upload"][class*="progress"], [class*="uploading"], [class*="loading"][class*="image"]' |
| 107 | + ) |
| 108 | + `); |
| 109 | + if (!uploading) return; |
| 110 | + await page.wait({ time: pollMs / 1_000 }); |
| 111 | + } |
| 112 | +} |
| 113 | + |
| 114 | +/** |
| 115 | + * Fill a visible text input or contenteditable with the given text. |
| 116 | + * Tries multiple selectors in priority order. |
| 117 | + * Returns { ok, sel }. |
| 118 | + */ |
| 119 | +async function fillField(page: IPage, selectors: string[], text: string, fieldName: string): Promise<void> { |
| 120 | + const result: { ok: boolean; sel?: string } = await page.evaluate(` |
| 121 | + (function(selectors, text) { |
| 122 | + for (const sel of selectors) { |
| 123 | + const candidates = document.querySelectorAll(sel); |
| 124 | + for (const el of candidates) { |
| 125 | + if (!el || el.offsetParent === null) continue; |
| 126 | + el.focus(); |
| 127 | + if (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA') { |
| 128 | + el.value = ''; |
| 129 | + document.execCommand('selectAll', false); |
| 130 | + document.execCommand('insertText', false, text); |
| 131 | + el.dispatchEvent(new Event('input', { bubbles: true })); |
| 132 | + el.dispatchEvent(new Event('change', { bubbles: true })); |
| 133 | + } else { |
| 134 | + // contenteditable |
| 135 | + el.textContent = ''; |
| 136 | + document.execCommand('selectAll', false); |
| 137 | + document.execCommand('insertText', false, text); |
| 138 | + el.dispatchEvent(new Event('input', { bubbles: true })); |
| 139 | + } |
| 140 | + return { ok: true, sel }; |
| 141 | + } |
| 142 | + } |
| 143 | + return { ok: false }; |
| 144 | + })(${JSON.stringify(selectors)}, ${JSON.stringify(text)}) |
| 145 | + `); |
| 146 | + if (!result.ok) { |
| 147 | + await page.screenshot({ path: `/tmp/xhs_publish_${fieldName}_debug.png` }); |
| 148 | + throw new Error( |
| 149 | + `Could not find ${fieldName} input. Debug screenshot: /tmp/xhs_publish_${fieldName}_debug.png` |
| 150 | + ); |
| 151 | + } |
| 152 | +} |
| 153 | + |
| 154 | +cli({ |
| 155 | + site: 'xiaohongshu', |
| 156 | + name: 'publish', |
| 157 | + description: '小红书发布图文笔记 (creator center UI automation)', |
| 158 | + domain: 'creator.xiaohongshu.com', |
| 159 | + strategy: Strategy.COOKIE, |
| 160 | + browser: true, |
| 161 | + args: [ |
| 162 | + { name: 'title', required: true, help: '笔记标题 (最多20字)' }, |
| 163 | + { name: 'content', required: true, positional: true, help: '笔记正文' }, |
| 164 | + { name: 'images', required: false, help: '图片路径,逗号分隔,最多9张 (jpg/png/gif/webp)' }, |
| 165 | + { name: 'topics', required: false, help: '话题标签,逗号分隔,不含 # 号' }, |
| 166 | + { name: 'draft', type: 'bool', default: false, help: '保存为草稿,不直接发布' }, |
| 167 | + ], |
| 168 | + columns: ['status', 'detail'], |
| 169 | + func: async (page: IPage | null, kwargs) => { |
| 170 | + if (!page) throw new Error('Browser page required'); |
| 171 | + |
| 172 | + const title = String(kwargs.title ?? '').trim(); |
| 173 | + const content = String(kwargs.content ?? '').trim(); |
| 174 | + const imagePaths: string[] = kwargs.images |
| 175 | + ? String(kwargs.images).split(',').map((s: string) => s.trim()).filter(Boolean) |
| 176 | + : []; |
| 177 | + const topics: string[] = kwargs.topics |
| 178 | + ? String(kwargs.topics).split(',').map((s: string) => s.trim()).filter(Boolean) |
| 179 | + : []; |
| 180 | + const isDraft = Boolean(kwargs.draft); |
| 181 | + |
| 182 | + // ── Validate inputs ──────────────────────────────────────────────────────── |
| 183 | + if (!title) throw new Error('--title is required'); |
| 184 | + if (title.length > MAX_TITLE_LEN) |
| 185 | + throw new Error(`Title is ${title.length} chars — must be ≤ ${MAX_TITLE_LEN}`); |
| 186 | + if (!content) throw new Error('Positional argument <content> is required'); |
| 187 | + if (imagePaths.length > MAX_IMAGES) |
| 188 | + throw new Error(`Too many images: ${imagePaths.length} (max ${MAX_IMAGES})`); |
| 189 | + |
| 190 | + // Read images in Node.js context before navigating (fast-fail on bad paths) |
| 191 | + const imageData: ImagePayload[] = imagePaths.map(readImageFile); |
| 192 | + |
| 193 | + // ── Step 1: Navigate to publish page ────────────────────────────────────── |
| 194 | + await page.goto(PUBLISH_URL); |
| 195 | + await page.wait({ time: 3 }); |
| 196 | + |
| 197 | + // Verify we landed on the creator site (not redirected to login) |
| 198 | + const pageUrl: string = await page.evaluate('() => location.href'); |
| 199 | + if (!pageUrl.includes('creator.xiaohongshu.com')) { |
| 200 | + throw new Error( |
| 201 | + 'Redirected away from creator center — session may have expired. ' + |
| 202 | + 'Re-capture browser login via: opencli xiaohongshu creator-profile' |
| 203 | + ); |
| 204 | + } |
| 205 | + |
| 206 | + // ── Step 2: Select 图文 (image+text) note type if tabs are present ───────── |
| 207 | + const tabClicked: boolean = await page.evaluate(` |
| 208 | + () => { |
| 209 | + const allEls = document.querySelectorAll('[class*="tab"], [class*="note-type"], [class*="type-item"]'); |
| 210 | + for (const el of allEls) { |
| 211 | + const text = el.innerText || el.textContent || ''; |
| 212 | + if ((text.includes('图文') || text.includes('图片')) && el.offsetParent !== null) { |
| 213 | + el.click(); |
| 214 | + return true; |
| 215 | + } |
| 216 | + } |
| 217 | + return false; |
| 218 | + } |
| 219 | + `); |
| 220 | + if (tabClicked) await page.wait({ time: 1 }); |
| 221 | + |
| 222 | + // ── Step 3: Upload images ────────────────────────────────────────────────── |
| 223 | + if (imageData.length > 0) { |
| 224 | + const upload = await injectImages(page, imageData); |
| 225 | + if (!upload.ok) { |
| 226 | + await page.screenshot({ path: '/tmp/xhs_publish_upload_debug.png' }); |
| 227 | + throw new Error( |
| 228 | + `Image injection failed: ${upload.error ?? 'unknown'}. ` + |
| 229 | + 'Debug screenshot: /tmp/xhs_publish_upload_debug.png' |
| 230 | + ); |
| 231 | + } |
| 232 | + // Allow XHS to process and upload images to its CDN |
| 233 | + await page.wait({ time: UPLOAD_SETTLE_MS / 1_000 }); |
| 234 | + await waitForUploads(page); |
| 235 | + } |
| 236 | + |
| 237 | + // ── Step 4: Fill title ───────────────────────────────────────────────────── |
| 238 | + await fillField( |
| 239 | + page, |
| 240 | + [ |
| 241 | + 'input[maxlength="20"]', |
| 242 | + 'input[class*="title"]', |
| 243 | + 'input[placeholder*="标题"]', |
| 244 | + 'input[placeholder*="title" i]', |
| 245 | + '.title-input input', |
| 246 | + '.note-title input', |
| 247 | + 'input[maxlength]', |
| 248 | + ], |
| 249 | + title, |
| 250 | + 'title' |
| 251 | + ); |
| 252 | + await page.wait({ time: 0.5 }); |
| 253 | + |
| 254 | + // ── Step 5: Fill content / body ──────────────────────────────────────────── |
| 255 | + await fillField( |
| 256 | + page, |
| 257 | + [ |
| 258 | + '[contenteditable="true"][class*="content"]', |
| 259 | + '[contenteditable="true"][class*="editor"]', |
| 260 | + '[contenteditable="true"][placeholder*="描述"]', |
| 261 | + '[contenteditable="true"][placeholder*="正文"]', |
| 262 | + '[contenteditable="true"][placeholder*="内容"]', |
| 263 | + '.note-content [contenteditable="true"]', |
| 264 | + '.editor-content [contenteditable="true"]', |
| 265 | + // Broad fallback — last resort; filter out any title contenteditable |
| 266 | + '[contenteditable="true"]:not([placeholder*="标题"]):not([placeholder*="title" i])', |
| 267 | + ], |
| 268 | + content, |
| 269 | + 'content' |
| 270 | + ); |
| 271 | + await page.wait({ time: 0.5 }); |
| 272 | + |
| 273 | + // ── Step 6: Add topic hashtags ───────────────────────────────────────────── |
| 274 | + for (const topic of topics) { |
| 275 | + // Click the "添加话题" button |
| 276 | + const btnClicked: boolean = await page.evaluate(` |
| 277 | + () => { |
| 278 | + const candidates = document.querySelectorAll('*'); |
| 279 | + for (const el of candidates) { |
| 280 | + const text = (el.innerText || el.textContent || '').trim(); |
| 281 | + if ( |
| 282 | + (text === '添加话题' || text === '# 话题' || text.startsWith('添加话题')) && |
| 283 | + el.offsetParent !== null && |
| 284 | + el.children.length === 0 |
| 285 | + ) { |
| 286 | + el.click(); |
| 287 | + return true; |
| 288 | + } |
| 289 | + } |
| 290 | + // fallback: look for a hashtag icon button |
| 291 | + const hashBtn = document.querySelector('[class*="topic"][class*="btn"], [class*="hashtag"][class*="btn"]'); |
| 292 | + if (hashBtn) { hashBtn.click(); return true; } |
| 293 | + return false; |
| 294 | + } |
| 295 | + `); |
| 296 | + |
| 297 | + if (!btnClicked) continue; // Skip topic if UI not found — non-fatal |
| 298 | + await page.wait({ time: 1 }); |
| 299 | + |
| 300 | + // Type into the topic search input |
| 301 | + const typed: boolean = await page.evaluate(` |
| 302 | + (topicName => { |
| 303 | + const input = document.querySelector( |
| 304 | + '[class*="topic"] input, [class*="hashtag"] input, input[placeholder*="搜索话题"]' |
| 305 | + ); |
| 306 | + if (!input || input.offsetParent === null) return false; |
| 307 | + input.focus(); |
| 308 | + document.execCommand('insertText', false, topicName); |
| 309 | + input.dispatchEvent(new Event('input', { bubbles: true })); |
| 310 | + return true; |
| 311 | + })(${JSON.stringify(topic)}) |
| 312 | + `); |
| 313 | + |
| 314 | + if (!typed) continue; |
| 315 | + await page.wait({ time: 1.5 }); // Wait for autocomplete suggestions |
| 316 | + |
| 317 | + // Click the first suggestion |
| 318 | + await page.evaluate(` |
| 319 | + () => { |
| 320 | + const item = document.querySelector( |
| 321 | + '[class*="topic-item"], [class*="hashtag-item"], [class*="suggest-item"], [class*="suggestion"] li' |
| 322 | + ); |
| 323 | + if (item) item.click(); |
| 324 | + } |
| 325 | + `); |
| 326 | + await page.wait({ time: 0.5 }); |
| 327 | + } |
| 328 | + |
| 329 | + // ── Step 7: Publish or save draft ───────────────────────────────────────── |
| 330 | + const actionLabel = isDraft ? '存草稿' : '发布'; |
| 331 | + const btnClicked: boolean = await page.evaluate(` |
| 332 | + (label => { |
| 333 | + const buttons = document.querySelectorAll('button, [role="button"]'); |
| 334 | + for (const btn of buttons) { |
| 335 | + const text = (btn.innerText || btn.textContent || '').trim(); |
| 336 | + if ( |
| 337 | + (text === label || text.includes(label) || text === '发布笔记') && |
| 338 | + btn.offsetParent !== null && |
| 339 | + !btn.disabled |
| 340 | + ) { |
| 341 | + btn.click(); |
| 342 | + return true; |
| 343 | + } |
| 344 | + } |
| 345 | + return false; |
| 346 | + })(${JSON.stringify(actionLabel)}) |
| 347 | + `); |
| 348 | + |
| 349 | + if (!btnClicked) { |
| 350 | + await page.screenshot({ path: '/tmp/xhs_publish_submit_debug.png' }); |
| 351 | + throw new Error( |
| 352 | + `Could not find "${actionLabel}" button. ` + |
| 353 | + 'Debug screenshot: /tmp/xhs_publish_submit_debug.png' |
| 354 | + ); |
| 355 | + } |
| 356 | + |
| 357 | + // ── Step 8: Verify success ───────────────────────────────────────────────── |
| 358 | + await page.wait({ time: 4 }); |
| 359 | + |
| 360 | + const finalUrl: string = await page.evaluate('() => location.href'); |
| 361 | + const successMsg: string = await page.evaluate(` |
| 362 | + () => { |
| 363 | + for (const el of document.querySelectorAll('*')) { |
| 364 | + const text = (el.innerText || '').trim(); |
| 365 | + if ( |
| 366 | + el.children.length === 0 && |
| 367 | + (text.includes('发布成功') || text.includes('草稿已保存') || text.includes('上传成功')) |
| 368 | + ) return text; |
| 369 | + } |
| 370 | + return ''; |
| 371 | + } |
| 372 | + `); |
| 373 | + |
| 374 | + const navigatedAway = !finalUrl.includes('/publish/publish'); |
| 375 | + const isSuccess = successMsg.length > 0 || navigatedAway; |
| 376 | + const verb = isDraft ? '草稿已保存' : '发布成功'; |
| 377 | + |
| 378 | + return [ |
| 379 | + { |
| 380 | + status: isSuccess ? `✅ ${verb}` : '⚠️ 操作完成,请在浏览器中确认', |
| 381 | + detail: [ |
| 382 | + `"${title}"`, |
| 383 | + imageData.length ? `${imageData.length}张图片` : '无图', |
| 384 | + topics.length ? `话题: ${topics.join(' ')}` : '', |
| 385 | + successMsg || finalUrl || '', |
| 386 | + ] |
| 387 | + .filter(Boolean) |
| 388 | + .join(' · '), |
| 389 | + }, |
| 390 | + ]; |
| 391 | + }, |
| 392 | +}); |
0 commit comments