Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:39:48 +08:00

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,
};