Skip to main content
Glama
publish.js6.59 kB
import { chromium } from "playwright"; import fs from "fs"; /* ====================== * 常量 * ====================== */ const URL_OF_PUBLISH = "https://creator.xiaohongshu.com/publish/publish?source=official"; /* ====================== * PublishAction * ====================== */ export class PublishAction { constructor(page) { this.page = page; } static async create(page) { await page.goto(URL_OF_PUBLISH, { timeout: 300_000, waitUntil: "networkidle", }); await page.waitForTimeout(1000); await mustClickPublishTab(page, "上传图文"); await page.waitForTimeout(1000); return new PublishAction(page); } async publish(content) { const { title, content: body, tags = [], imagePaths } = content; if (!imagePaths || imagePaths.length === 0) { throw new Error("图片不能为空"); } await uploadImages(this.page, imagePaths); let finalTags = tags; if (finalTags.length > 10) { console.warn("标签数量超过 10,已截断"); finalTags = finalTags.slice(0, 10); } console.info("发布内容", { title, images: imagePaths.length, tags: finalTags, }); await submitPublish(this.page, title, body, finalTags); } } /* ====================== * 图片上传 * eg: /Users/{user}/Desktop/xiaohongshu-mcp-js/maori_rock_carving.jpg * ====================== */ async function uploadImages(page, paths) { const validPaths = paths.filter((p) => { if (!fs.existsSync(p)) { console.warn("图片不存在:", p); return false; } return true; }); if (!validPaths.length) { throw new Error("没有有效图片"); } const input = await page.waitForSelector(".upload-input", { timeout: 30_000, }); await input.setInputFiles(validPaths); await waitForUploadComplete(page, validPaths.length); } async function waitForUploadComplete(page, expected) { const start = Date.now(); const timeout = 60_000; while (Date.now() - start < timeout) { const items = await page.$$(".img-preview-area .pr"); console.info(`已上传 ${items.length}/${expected}`); if (items.length >= expected) return; await page.waitForTimeout(500); } throw new Error("图片上传超时"); } /* ====================== * 发布提交 * ====================== */ async function submitPublish(page, title, body, tags) { const titleInput = await page.waitForSelector("div.d-input input"); await titleInput.fill(title); await page.waitForTimeout(500); await checkTitleMaxLength(page); const contentElem = await getContentElement(page); if (!contentElem) { throw new Error("未找到正文输入框"); } await contentElem.fill(body); await inputTags(page, contentElem, tags); await page.waitForTimeout(500); await checkContentMaxLength(page); const submitBtn = await page.waitForSelector( "div.submit div.d-button-content" ); await submitBtn.click(); await page.waitForTimeout(3000); } /* ====================== * 长度校验 * ====================== */ async function checkTitleMaxLength(page) { const elem = await page.$("div.title-container div.max_suffix"); if (!elem) return; const text = await elem.innerText(); throw makeMaxLengthError(text); } async function checkContentMaxLength(page) { const elem = await page.$("div.edit-container div.length-error"); if (!elem) return; const text = await elem.innerText(); throw makeMaxLengthError(text); } function makeMaxLengthError(text) { const parts = text.split("/"); if (parts.length !== 2) { return new Error(`长度超限: ${text}`); } return new Error(`当前长度 ${parts[0]},最大长度 ${parts[1]}`); } /* ====================== * 正文输入框 & 标签 * ====================== */ async function getContentElement(page) { const editor = await page.$("div.ql-editor"); if (editor) return editor; const ps = await page.$$("p[data-placeholder]"); for (const p of ps) { const placeholder = await p.getAttribute("data-placeholder"); if (placeholder && placeholder.includes("输入正文描述")) { let el = p; for (let i = 0; i < 5; i++) { el = await el.evaluateHandle((n) => n.parentElement); const role = await el.getAttribute("role"); if (role === "textbox") return el; } } } return null; } async function inputTags(page, elem, tags) { if (!tags.length) return; await elem.press("Enter"); await elem.press("Enter"); for (let tag of tags) { tag = tag.replace(/^#/, ""); await elem.type("#" + tag, { delay: 50 }); await page.waitForTimeout(500); const item = await page.$("#creator-editor-topic-container .item"); if (item) { await item.click(); } else { await elem.type(" "); } await page.waitForTimeout(300); } } /* ====================== * TAB 点击 & 防遮挡 * ====================== */ async function mustClickPublishTab(page, tabName) { await page.waitForSelector("div.upload-content"); const deadline = Date.now() + 15_000; while (Date.now() < deadline) { const tabs = await page.$$("div.creator-tab"); for (const tab of tabs) { const text = (await tab.innerText()).trim(); if (text !== tabName) continue; if (await isElementBlocked(page, tab)) { await removePopCover(page); await page.waitForTimeout(200); continue; } await tab.click(); return; } await page.waitForTimeout(200); } throw new Error(`未找到发布 TAB: ${tabName}`); } async function isElementBlocked(page, elem) { return page.evaluate((el) => { const rect = el.getBoundingClientRect(); const x = rect.left + rect.width / 2; const y = rect.top + rect.height / 2; const target = document.elementFromPoint(x, y); return !(target === el || el.contains(target)); }, elem); } async function removePopCover(page) { const pop = await page.$("div.d-popover"); if (pop) { await pop.evaluate((el) => el.remove()); } } export async function handlePublishContent(title, content, imagePaths, tags) { const browser = await chromium.launchPersistentContext(".chromiumTemp", { headless: false, }); const page = await browser.newPage(); const action = await PublishAction.create(page); try { await action.publish({ title, content, imagePaths, tags, }); await browser.close(); return true; } catch (error) { console.error(error); await browser.close(); throw error; } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/1uokun/xiaohongshu-mcp-js'

If you have feedback or need assistance with the MCP directory API, please join our Discord server