9b395f5cc3
E2E Headed Chrome / e2e-headed (macos-15) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (ubuntu-latest) (push) Has been cancelled
E2E Headed Chrome / e2e-headed (windows-latest) (push) Has been cancelled
CI / build (macos-latest) (push) Has been cancelled
CI / build (ubuntu-latest) (push) Has been cancelled
CI / build (windows-latest) (push) Has been cancelled
CI / unit-test (push) Has been cancelled
CI / bun-test (push) Has been cancelled
CI / adapter-test (push) Has been cancelled
CI / smoke-test (macos-latest) (push) Has been cancelled
CI / smoke-test (ubuntu-latest) (push) Has been cancelled
Security Audit / audit (push) Has been cancelled
Build Chrome Extension / build (push) Has been cancelled
Trigger Website Rebuild (Docs Updated) / dispatch (push) Has been cancelled
307 lines
13 KiB
JavaScript
307 lines
13 KiB
JavaScript
/**
|
|
* Weibo publish — post a new Weibo update via browser UI automation.
|
|
*
|
|
* Flow:
|
|
* 1. Navigate to weibo.com and wait for the feed
|
|
* 2. Check login state (getSelfUid)
|
|
* 3. Click "发微博" button to open the inline compose editor
|
|
* 4. Wait for textarea editor to appear
|
|
* 5. Fill text content via CDP type
|
|
* 6. Optionally upload images via CDP setFileInput
|
|
* 7. Click the publish button
|
|
* 8. Poll for success/failure feedback
|
|
*
|
|
* Usage:
|
|
* opencli weibo publish "Hello from OpenCLI! #opencli" # publishes immediately
|
|
* opencli weibo publish "Check this out" --images /path/a.jpg,/path/b.jpg
|
|
*/
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
import { ArgumentError, AuthRequiredError, CommandExecutionError } from '@jackwener/opencli/errors';
|
|
import { getSelfUid } from './utils.js';
|
|
|
|
const MAX_IMAGES = 9;
|
|
const UPLOAD_POLL_MS = 1500;
|
|
const UPLOAD_TIMEOUT_MS = 30_000;
|
|
const COMPOSE_POLL_MS = 300;
|
|
const COMPOSE_TIMEOUT_MS = 10_000;
|
|
const SUBMIT_POLL_MS = 500;
|
|
const SUBMIT_TIMEOUT_MS = 20_000;
|
|
const SUPPORTED_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp']);
|
|
|
|
// Weibo PC UI selectors. The CSS-module hash drifts on every frontend
|
|
// rebuild (#1602), so match on the stable placeholder text and keep the
|
|
// legacy hash as a last-resort fallback. Callers pick the LAST visible
|
|
// match because the compose modal renders on top of the home-feed strip.
|
|
const TEXTAREA_SELECTORS = [
|
|
'textarea[placeholder*="有什么新鲜事"]',
|
|
'textarea[placeholder*="新鲜事"]',
|
|
'textarea._input_13iqr_8',
|
|
];
|
|
const FILE_INPUT_SELECTOR = 'input[type="file"][class*="_file_"]';
|
|
|
|
function validateText(text) {
|
|
const t = String(text ?? '').trim();
|
|
if (!t) throw new ArgumentError('weibo publish text cannot be empty');
|
|
if (t.length > 2000) throw new ArgumentError('weibo publish text exceeds 2000 characters');
|
|
return t;
|
|
}
|
|
|
|
function validateImagePaths(raw) {
|
|
if (!raw) return [];
|
|
const paths = raw.split(',').map(s => s.trim()).filter(Boolean);
|
|
if (paths.length > MAX_IMAGES) {
|
|
throw new ArgumentError(`Too many images: ${paths.length} (max ${MAX_IMAGES})`);
|
|
}
|
|
return paths.map(p => {
|
|
const absPath = path.resolve(p);
|
|
const ext = path.extname(absPath).toLowerCase();
|
|
if (!SUPPORTED_EXTENSIONS.has(ext)) {
|
|
throw new ArgumentError(`Unsupported image format "${ext}". Supported: jpg, png, gif, webp`);
|
|
}
|
|
const stat = fs.statSync(absPath, { throwIfNoEntry: false });
|
|
if (!stat || !stat.isFile()) {
|
|
throw new ArgumentError(`Not a valid file: ${absPath}`);
|
|
}
|
|
return absPath;
|
|
});
|
|
}
|
|
|
|
cli({
|
|
site: 'weibo',
|
|
name: 'publish',
|
|
access: 'write',
|
|
description: 'Publish a new Weibo post immediately',
|
|
domain: 'weibo.com',
|
|
strategy: Strategy.UI,
|
|
browser: true,
|
|
args: [
|
|
{
|
|
name: 'text',
|
|
type: 'string',
|
|
required: true,
|
|
positional: true,
|
|
help: 'Weibo text content (max 2000 chars)',
|
|
},
|
|
{
|
|
name: 'images',
|
|
type: 'string',
|
|
required: false,
|
|
help: `Image paths, comma-separated, max ${MAX_IMAGES} (jpg/png/gif/webp)`,
|
|
},
|
|
],
|
|
columns: ['status', 'message', 'text'],
|
|
func: async (page, kwargs) => {
|
|
if (!page) throw new CommandExecutionError('Browser session required for weibo publish');
|
|
|
|
const text = validateText(kwargs.text);
|
|
const absPaths = validateImagePaths(kwargs.images);
|
|
|
|
// Step 1: Navigate to weibo.com and wait for feed to load
|
|
await page.goto('https://weibo.com', { waitUntil: 'load', settleMs: 2000 });
|
|
await page.wait({ time: 2 });
|
|
|
|
// Step 2: Check login
|
|
try {
|
|
await getSelfUid(page);
|
|
} catch (err) {
|
|
if (err instanceof AuthRequiredError) throw err;
|
|
throw new CommandExecutionError('Not logged into Weibo. Please login at weibo.com in your Chrome browser.');
|
|
}
|
|
|
|
// Step 3: Click "发微博" button to open inline compose editor
|
|
const clickResult = await page.evaluate(`
|
|
() => {
|
|
const visible = el => !!el && el.offsetParent !== null && !el.disabled;
|
|
const buttons = document.querySelectorAll('button[title="发微博"], button[title="写微博"]');
|
|
for (const btn of buttons) {
|
|
if (visible(btn)) {
|
|
btn.click();
|
|
return { ok: true };
|
|
}
|
|
}
|
|
return { ok: false, message: 'Could not find 发微博 button' };
|
|
}
|
|
`);
|
|
if (!clickResult?.ok) {
|
|
throw new CommandExecutionError(clickResult?.message ?? 'Could not open compose editor.');
|
|
}
|
|
|
|
// Step 4: Wait for the textarea editor to appear (visible, not just in DOM)
|
|
let editorFound = false;
|
|
for (let i = 0; i < Math.ceil(COMPOSE_TIMEOUT_MS / COMPOSE_POLL_MS); i++) {
|
|
const result = await page.evaluate(`
|
|
(selectors => {
|
|
// Pick the LAST visible match across all selectors so
|
|
// the modal (rendered on top of the home-feed strip)
|
|
// wins over earlier matches. See TEXTAREA_SELECTORS.
|
|
let last = null;
|
|
for (const sel of selectors) {
|
|
for (const t of document.querySelectorAll(sel)) {
|
|
if (t.offsetParent !== null) last = t;
|
|
}
|
|
}
|
|
if (!last) return { found: false };
|
|
return { found: true, visible: true, rectTop: last.getBoundingClientRect().top };
|
|
})(${JSON.stringify(TEXTAREA_SELECTORS)})
|
|
`);
|
|
if (result?.found && result.visible && result.rectTop >= 0) {
|
|
editorFound = true;
|
|
break;
|
|
}
|
|
await page.wait({ time: COMPOSE_POLL_MS / 1000 });
|
|
}
|
|
if (!editorFound) {
|
|
throw new CommandExecutionError('Weibo compose editor did not appear');
|
|
}
|
|
|
|
// Step 5: Upload images first (before text to avoid editor reset)
|
|
if (absPaths.length > 0) {
|
|
if (!page.setFileInput) {
|
|
throw new CommandExecutionError('Browser extension does not support file upload. Please update the extension.');
|
|
}
|
|
|
|
// Find the file input
|
|
const fileInputFound = await page.evaluate(`
|
|
() => {
|
|
const input = document.querySelector('input[type="file"][class*="_file_"]');
|
|
return !!input;
|
|
}
|
|
`);
|
|
if (!fileInputFound) {
|
|
throw new CommandExecutionError('Could not find image file input on Weibo compose page. UI may have changed.');
|
|
}
|
|
|
|
await page.setFileInput(absPaths, FILE_INPUT_SELECTOR);
|
|
|
|
// Wait for upload to complete
|
|
let uploadResult = null;
|
|
for (let i = 0; i < Math.ceil(UPLOAD_TIMEOUT_MS / UPLOAD_POLL_MS); i++) {
|
|
await page.wait({ time: UPLOAD_POLL_MS / 1000 });
|
|
uploadResult = await page.evaluateWithArgs(`
|
|
(() => {
|
|
const expectedCount = expected;
|
|
const uploading = document.querySelector('[class*="upload"], [class*="progress"]');
|
|
if (uploading && uploading.offsetParent !== null) return null;
|
|
const pics = document.querySelectorAll('img[class*="pic"], [class*="imgItem"], [class*="picture"] img');
|
|
if (pics.length >= expectedCount) return { ok: true, count: pics.length };
|
|
return null;
|
|
})()
|
|
`, { expected: absPaths.length });
|
|
if (uploadResult !== null) break;
|
|
}
|
|
|
|
if (!uploadResult?.ok) {
|
|
throw new CommandExecutionError(uploadResult?.message ?? 'Image upload did not complete before timeout');
|
|
}
|
|
}
|
|
|
|
// Step 6: Insert text using native DOM setter (preserves Weibo internal state)
|
|
// IMPORTANT: Using nativeSetter preserves the textarea's reactive/internal state.
|
|
// Direct ta.value= assignment bypasses Weibo's Vue reactivity and causes "undefined" content.
|
|
const insertResult = await page.evaluateWithArgs(`
|
|
((selectors) => {
|
|
let ta = null;
|
|
for (const sel of selectors) {
|
|
for (const t of document.querySelectorAll(sel)) {
|
|
if (t.offsetParent !== null) ta = t;
|
|
}
|
|
}
|
|
if (!ta) return { ok: false, message: 'textarea not visible' };
|
|
ta.focus();
|
|
const nativeSetter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')?.set;
|
|
if (nativeSetter) {
|
|
nativeSetter.call(ta, textContent);
|
|
} else {
|
|
ta.value = textContent;
|
|
}
|
|
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
ta.dispatchEvent(new Event('change', { bubbles: true }));
|
|
return { ok: true, valueLength: ta.value.length };
|
|
})(${JSON.stringify(TEXTAREA_SELECTORS)})
|
|
`, { textContent: text });
|
|
|
|
if (!insertResult?.ok) {
|
|
throw new CommandExecutionError(insertResult?.message ?? 'Could not insert text.');
|
|
}
|
|
|
|
|
|
// Step 7: Click the send button inside the compose editor
|
|
// Try 发送 first (compose editor's submit), then 发布 (fallback)
|
|
await page.wait({ time: 0.5 });
|
|
const publishResult = await page.evaluate(`
|
|
() => {
|
|
const visible = el => !!el && el.offsetParent !== null && !el.disabled;
|
|
const labels = ['发送', '发布'];
|
|
for (const label of labels) {
|
|
const allBtns = document.querySelectorAll('button, [role="button"]');
|
|
for (const btn of allBtns) {
|
|
const t = (btn.innerText || btn.textContent || '').trim();
|
|
if (t === label && visible(btn)) {
|
|
btn.click();
|
|
return { ok: true, label };
|
|
}
|
|
}
|
|
}
|
|
return { ok: false, message: 'Could not find send button' };
|
|
}
|
|
`);
|
|
if (!publishResult?.ok) {
|
|
throw new CommandExecutionError(publishResult?.message ?? 'Could not click publish.');
|
|
}
|
|
|
|
// Step 8: Wait for success/failure result
|
|
// Use page.evaluate (not evaluateWithArgs): the IIFE doesn't reference
|
|
// any outer args, and evaluateWithArgs would re-declare its const
|
|
// bindings each loop iteration in the same page context, throwing
|
|
// "Identifier already declared" after the first iteration.
|
|
let finalResult = null;
|
|
for (let i = 0; i < Math.ceil(SUBMIT_TIMEOUT_MS / SUBMIT_POLL_MS); i++) {
|
|
await page.wait({ time: SUBMIT_POLL_MS / 1000 });
|
|
finalResult = await page.evaluate(`
|
|
(() => {
|
|
const successMarkers = ['发布成功', '已发布', '发送成功'];
|
|
const errorMarkers = ['发布失败', '发送失败', '内容违规', '请稍后再试', '频繁'];
|
|
for (const el of document.querySelectorAll('*')) {
|
|
if (el.children.length > 3) continue;
|
|
const txt = (el.innerText || '').trim();
|
|
if (!txt || txt.length > 100) continue;
|
|
for (const m of successMarkers) {
|
|
if (txt.includes(m) && (txt.includes('成功') || txt.includes('微博'))) {
|
|
return { ok: true, message: txt };
|
|
}
|
|
}
|
|
for (const m of errorMarkers) {
|
|
if (txt.includes(m)) {
|
|
return { ok: false, message: txt };
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
})()
|
|
`);
|
|
if (finalResult !== null) break;
|
|
}
|
|
|
|
if (!finalResult) {
|
|
throw new CommandExecutionError('Publish button clicked but result was unclear. Check Weibo manually.');
|
|
}
|
|
|
|
if (!finalResult.ok) {
|
|
throw new CommandExecutionError(finalResult.message || 'Weibo publish failed');
|
|
}
|
|
|
|
return [{
|
|
status: 'success',
|
|
message: finalResult.message || 'Published successfully',
|
|
text,
|
|
}];
|
|
},
|
|
});
|
|
|
|
export const __test__ = {
|
|
validateText,
|
|
validateImagePaths,
|
|
};
|