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

733 lines
31 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* WeChat Channels (视频号) publish — UI automation for WeChat Video Channels creator center.
*
* Flow:
* 1. Navigate to https://channels.weixin.qq.com/platform/post/create
* 2. Upload video via CDP setFileInput (with shadow-DOM DataTransfer fallback)
* 3. Wait for upload + transcode completion
* 4. Fill title (主要内容) and description
* 5. Add hashtag tags (appended to description)
* 6. Set scheduled publish time (optional)
* 7. Click publish or save draft
*
* Note: The creator center renders inside a wujie micro-frontend shadow DOM.
* All form elements are inside wujie-app::shadow-root. The adapter handles
* shadow DOM traversal transparently for all interactions.
*
* Requires: logged into channels.weixin.qq.com in Chrome.
*/
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';
// ── Constants ──────────────────────────────────────────────────────────────
const PUBLISH_URL = 'https://channels.weixin.qq.com/platform/post/create';
const LOGIN_PATH_FRAGMENT = 'login';
// Title: "短标题" field visible in the form (from screenshot)
const TITLE_SELECTORS = [
'input[placeholder*="短标题"]',
'input[placeholder*="填写短标题"]',
'input.weui-desktop-form__input[placeholder*="短标题"]',
'input.weui-desktop-form__input',
];
// Description: "添加描述" contenteditable area (from screenshot)
const DESC_SELECTORS = [
'div[contenteditable][data-placeholder="添加描述"]',
'div.input-editor[contenteditable=""][data-placeholder="添加描述"]',
'div[data-placeholder*="描述"][contenteditable]',
'div.input-editor[contenteditable]',
];
// Upload trigger buttons (click to activate the hidden file input)
const UPLOAD_TRIGGER_SELECTORS = [
'span.add-icon.weui-icon-outlined-add',
'div.upload-content',
'.finder-video-upload-btn',
];
const VIDEO_EXTENSIONS = new Set(['.mp4', '.mov', '.avi', '.webm']);
function unwrapEvaluateResult(result) {
if (result && typeof result === 'object' && 'data' in result && 'session' in result) {
return result.data;
}
return result;
}
async function evalPage(page, script) {
return unwrapEvaluateResult(await page.evaluate(script));
}
function requireFilePath(filePath, label, allowedExts) {
const resolved = path.resolve(String(filePath));
let stat;
try {
stat = fs.statSync(resolved);
} catch {
throw new ArgumentError(`${label}文件不存在: ${resolved}`);
}
if (!stat.isFile()) {
throw new ArgumentError(`${label}路径不是文件: ${resolved}`);
}
const ext = path.extname(resolved).toLowerCase();
if (!allowedExts.has(ext)) {
throw new ArgumentError(`不支持的${label}格式: ${ext}(支持 ${Array.from(allowedExts).join('/')}`);
}
return resolved;
}
function parseTimeoutSeconds(raw) {
const timeout = raw == null || raw === '' ? 600 : Number(raw);
if (!Number.isInteger(timeout) || timeout < 30) {
throw new ArgumentError('--timeout must be an integer >= 30 seconds');
}
return timeout;
}
function parseScheduleDate(raw) {
if (!raw) return null;
const dt = typeof raw === 'number'
? new Date(raw < 1e12 ? raw * 1000 : raw)
: new Date(String(raw));
if (Number.isNaN(dt.getTime())) {
throw new ArgumentError(`无法解析定时时间: ${raw}`);
}
if (dt.getTime() <= Date.now()) {
throw new ArgumentError('定时时间必须晚于当前时间');
}
return dt;
}
function parseBooleanFlag(raw) {
return raw === true || raw === 'true' || raw === '1' || raw === 1;
}
function remainingMs(deadline, label) {
const left = deadline - Date.now();
if (left <= 0) {
throw new CommandExecutionError(`${label}超时,请增加 --timeout 后重试`);
}
return left;
}
function submitSucceeded({ isDraft, finalUrl, successMsg }) {
const msg = String(successMsg || '');
if (isDraft) {
return /草稿已保存|暂存成功|保存成功/.test(msg);
}
if (/已发表|发布成功|发表成功|审核中/.test(msg)) {
return true;
}
const url = String(finalUrl || '');
return /\/platform\/post\/list\b/.test(url);
}
export const __test__ = {
parseTimeoutSeconds,
parseScheduleDate,
parseBooleanFlag,
requireFilePath,
submitSucceeded,
};
// ── Shadow DOM utility (inlined into evaluate calls) ───────────────────────
// wujie creates exactly ONE shadow root on <wujie-app>; all creator-center UI
// lives inside it. We go directly there instead of recursing all elements,
// which avoids expensive querySelectorAll('*') traversals on a large page.
const DEEP_QUERY_FN = `
function wujieRoot() {
var w = document.querySelector('wujie-app');
return (w && w.shadowRoot) || null;
}
function deepQuery(selector) {
var el = document.querySelector(selector);
if (el) return el;
var sr = wujieRoot();
return sr ? sr.querySelector(selector) : null;
}
function deepQueryAll(selector) {
var results = [];
var main = document.querySelectorAll(selector);
for (var i = 0; i < main.length; i++) results.push(main[i]);
var sr = wujieRoot();
if (sr) {
var shadow = sr.querySelectorAll(selector);
for (var i = 0; i < shadow.length; i++) results.push(shadow[i]);
}
return results;
}
function isVisible(el) {
if (!el) return false;
var rect = el.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
}
`;
// ── Helper: click upload trigger ────────────────────────────────────────────
async function clickUploadTrigger(page) {
const clicked = await evalPage(page, `
(() => {
${DEEP_QUERY_FN}
var sels = ${JSON.stringify(UPLOAD_TRIGGER_SELECTORS)};
for (var i = 0; i < sels.length; i++) {
var el = deepQuery(sels[i]);
if (el && isVisible(el)) {
el.click();
return { ok: true, sel: sels[i] };
}
}
return { ok: false };
})()
`);
return clicked;
}
// ── Helper: upload video file ────────────────────────────────────────────────
async function uploadFile(page, absPath) {
// Strategy 1: page.setFileInput — works if input is in main document
if (page.setFileInput) {
await clickUploadTrigger(page);
await page.wait({ time: 1 });
for (const sel of ['input[type="file"][accept*="video"]', 'input[type="file"]']) {
try {
await page.setFileInput([absPath], sel);
return;
} catch (_) {}
}
}
// Strategy 2: DataTransfer injection via chunked base64.
// Splits the file into ~50KB chunks so no single evaluate call exceeds the
// bridge message limit. Works for shadow DOM file inputs where setFileInput fails.
const fileData = fs.readFileSync(absPath);
const base64Full = fileData.toString('base64');
const fileName = path.basename(absPath);
const ext = path.extname(absPath).toLowerCase();
const mimeMap = { '.mp4': 'video/mp4', '.mov': 'video/quicktime', '.avi': 'video/avi', '.webm': 'video/webm' };
const mimeType = mimeMap[ext] || 'video/mp4';
// Initialize accumulator in page context
await evalPage(page, '() => { window.__oc_chunks = []; }');
// Send in 50KB chunks to stay well under bridge message limits
const CHUNK = 50_000;
for (let i = 0; i < base64Full.length; i += CHUNK) {
const chunk = base64Full.slice(i, i + CHUNK);
await evalPage(page, `((c) => { window.__oc_chunks.push(c); })(${JSON.stringify(chunk)})`);
}
// Trigger click + assemble + set on shadow DOM input
await clickUploadTrigger(page);
await page.wait({ time: 0.5 });
const result = await evalPage(page, `
(function(params) {
${DEEP_QUERY_FN}
var inputSels = ['input[type="file"][accept*="video"]', 'input[type="file"]'];
var input = null;
for (var i = 0; i < inputSels.length; i++) {
input = deepQuery(inputSels[i]);
if (input) break;
}
if (!input) { window.__oc_chunks = []; return { ok: false, error: 'No file input found' }; }
try {
var b64 = window.__oc_chunks.join('');
window.__oc_chunks = [];
var binary = atob(b64);
var bytes = new Uint8Array(binary.length);
for (var i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
var dt = new DataTransfer();
dt.items.add(new File([bytes], params.fileName, { type: params.mimeType }));
Object.defineProperty(input, 'files', { value: dt.files, configurable: true });
input.dispatchEvent(new Event('change', { bubbles: true }));
input.dispatchEvent(new Event('input', { bubbles: true }));
return { ok: true };
} catch(e) {
window.__oc_chunks = [];
return { ok: false, error: e.message };
}
})(${JSON.stringify({ fileName, mimeType })})
`);
if (!result?.ok) {
await page.screenshot({ path: '/tmp/wechat-channels_publish_upload_debug.png' });
throw new CommandExecutionError(`视频文件注入失败: ${result?.error ?? 'unknown'}\n截图已保存到 /tmp/wechat-channels_publish_upload_debug.png`);
}
}
// ── Helper: wait for upload + transcode completion ───────────────────────────
async function waitForUploadDone(page, fileName, maxMs = 180_000) {
const pollMs = 3_000;
const maxAttempts = Math.ceil(maxMs / pollMs);
for (let i = 0; i < maxAttempts; i++) {
let done;
try {
done = await evalPage(page, `
((fileName) => {
${DEEP_QUERY_FN}
var root = wujieRoot() || document;
var bodyText = (root.innerText || root.textContent || '').trim();
var uploading = deepQuery('[class*="upload"][class*="progress"]') ||
deepQuery('[class*="uploading"]') ||
deepQuery('[class*="transcoding"]') ||
deepQuery('.weui-desktop-upload__status');
var preview = deepQuery('video') ||
deepQuery('[class*="preview-video"]') ||
deepQuery('[class*="video-thumb"]') ||
deepQuery('[class*="video"][class*="preview"]');
var uploadFailed = deepQuery('[class*="upload-fail"]') || deepQuery('[class*="upload-error"]');
if (uploadFailed || /上传失败|转码失败|处理失败/.test(bodyText)) return { done: false, failed: true };
var hasFileEvidence = fileName && bodyText.indexOf(fileName) >= 0;
var hasSuccessText = /上传成功|转码完成|处理完成/.test(bodyText);
return { done: !uploading && (!!preview || hasFileEvidence || hasSuccessText), failed: false };
})(${JSON.stringify(fileName)})
`);
} catch (err) {
// Bridge may temporarily disconnect when the page re-renders after file is set.
// Wait and retry rather than aborting.
const msg = err instanceof Error ? err.message : String(err);
if (msg.includes('ECONNRESET') || msg.includes('fetch failed')) {
process.stderr.write(` [retry] bridge reconnecting after page re-render (${i + 1}/${maxAttempts})...\n`);
await page.wait({ time: pollMs / 1000 });
continue;
}
throw err;
}
if (done?.failed) {
throw new CommandExecutionError('视频上传失败,请检查文件格式和网络连接');
}
if (done?.done) return;
await page.wait({ time: pollMs / 1000 });
}
throw new CommandExecutionError(`视频上传/转码超时(${Math.ceil(maxMs / 1000)}秒),请检查网络或稍后重试`);
}
// ── Helper: fill text field (with shadow DOM traversal) ─────────────────────
async function fillField(page, selectors, text, fieldName) {
const result = await evalPage(page, `
(function(selectors, text) {
${DEEP_QUERY_FN}
var el = null;
var foundSel = null;
for (var i = 0; i < selectors.length; i++) {
var candidate = deepQuery(selectors[i]);
if (candidate && isVisible(candidate)) {
el = candidate;
foundSel = selectors[i];
break;
}
}
if (!el) return { ok: false };
el.focus();
if (el.isContentEditable) {
// Clear existing content
el.textContent = '';
// Place cursor and insert text
var sel = window.getSelection();
var range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
if (sel) { sel.removeAllRanges(); sel.addRange(range); }
var inserted = document.execCommand('insertText', false, text);
if (!inserted) el.textContent = text;
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' }));
el.dispatchEvent(new Event('change', { bubbles: true }));
} else {
var proto = el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
var nativeSetter = Object.getOwnPropertyDescriptor(proto, 'value') && Object.getOwnPropertyDescriptor(proto, 'value').set;
if (nativeSetter) {
nativeSetter.call(el, text);
} else {
el.value = text;
}
el.dispatchEvent(new InputEvent('input', { bubbles: true, data: text, inputType: 'insertText' }));
el.dispatchEvent(new Event('change', { bubbles: true }));
}
var actual = el.isContentEditable ? (el.innerText || el.textContent || '') : (el.value || '');
el.blur();
return { ok: actual.indexOf(text) >= 0, sel: foundSel, actual: actual };
})(${JSON.stringify(selectors)}, ${JSON.stringify(text)})
`);
if (!result?.ok) {
await page.screenshot({ path: `/tmp/wechat-channels_publish_${fieldName}_debug.png` });
throw new CommandExecutionError(
`找不到 ${fieldName} 输入框,截图已保存到 /tmp/wechat-channels_publish_${fieldName}_debug.png`
);
}
}
// ── Helper: set schedule time ────────────────────────────────────────────────
async function setScheduleTime(page, dt) {
// Parse target date
const targetYear = dt.getFullYear();
const targetMonth = dt.getMonth() + 1;
const targetDay = dt.getDate();
const targetHour = dt.getHours();
const targetMin = dt.getMinutes();
const pad = n => String(n).padStart(2, '0');
// WeChat Channels uses the WeUI desktop date-time picker (class
// `weui-desktop-picker__date-time`). Its real structure (verified against the
// live DOM) is NOT a generic calendar:
// • Day cells are <a href="javascript:;"> inside <td>, NOT the <td> itself.
// The disabled state lives on the <a> (`weui-desktop-picker__disabled`),
// and out-of-month days carry `weui-desktop-picker__faded`.
// • Month nav arrows are `.weui-desktop-btn__icon__left/right`, scoped
// inside the date <dl>. The left arrow is hidden when you can't go back.
// • There is NO <input type="time">. Time is picked by clicking <li> items
// in `ol.weui-desktop-picker__time__hour` and `__minute`.
// • No 确定 button is needed — picking day + hour + minute updates the
// readonly display input live.
// All steps run in ONE async evaluate: separate calls let the session lease
// idle out and reset the tab to about:blank between commands.
const result = await evalPage(page, `
(async function(TY, TM, TD, TH, TMin) {
${DEEP_QUERY_FN}
function sleep(ms) { return new Promise(function(r) { setTimeout(r, ms); }); }
function click(el) {
el.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true, view: window }));
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true, cancelable: true, view: window }));
el.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
}
var pad = function(n) { return String(n).padStart(2, '0'); };
// 1. Select the "定时" radio (exact match — "不定时" also contains "定时").
var labels = deepQueryAll('label');
var radioOk = false;
for (var i = 0; i < labels.length; i++) {
if ((labels[i].innerText || labels[i].textContent || '').trim() === '定时') {
click(labels[i]);
labels[i].click();
radioOk = true;
break;
}
}
if (!radioOk) return { ok: false, reason: 'no-radio' };
await sleep(600);
// 2. Locate the date <dl> and open its panel.
var dateDl = deepQuery('dl.weui-desktop-picker__date');
if (!dateDl) return { ok: false, reason: 'no-date-dl' };
var dateDt = dateDl.querySelector('dt.weui-desktop-picker__dt');
if (!dateDt) return { ok: false, reason: 'no-date-dt' };
click(dateDt);
await sleep(500);
// 3. Navigate months until the panel labels read TY年 TM月.
var reached = false;
for (var nav = 0; nav < 24; nav++) {
var lbls = Array.prototype.map.call(
dateDl.querySelectorAll('.weui-desktop-picker__panel__label'),
function(l) { return (l.innerText || '').trim(); }
);
var ma = lbls.join('').match(/(\\d{4})年\\s*(\\d{1,2})月/);
if (!ma) return { ok: false, reason: 'label-parse', labels: lbls.join('|') };
var cy = parseInt(ma[1], 10), cm = parseInt(ma[2], 10);
if (cy === TY && cm === TM) { reached = true; break; }
var goNext = (cy < TY) || (cy === TY && cm < TM);
var arrow = goNext
? dateDl.querySelector('.weui-desktop-btn__icon__right')
: dateDl.querySelector('.weui-desktop-btn__icon__left');
if (!arrow) return { ok: false, reason: 'no-arrow', cy: cy, cm: cm };
click(arrow);
await sleep(350);
}
if (!reached) return { ok: false, reason: 'month-not-reached' };
// 4. Click the target day — an <a> in the body that is neither faded
// (other month) nor disabled (past).
var bd = dateDl.querySelector('.weui-desktop-picker__panel__bd');
var anchors = bd ? Array.prototype.slice.call(bd.querySelectorAll('a')) : [];
var dayEl = null;
for (var k = 0; k < anchors.length; k++) {
var t = (anchors[k].innerText || anchors[k].textContent || '').trim();
var cls = anchors[k].className || '';
if (t === String(TD) && cls.indexOf('faded') < 0 && cls.indexOf('disabled') < 0) {
dayEl = anchors[k];
break;
}
}
if (!dayEl) return { ok: false, reason: 'day-disabled-or-missing', day: TD };
click(dayEl);
await sleep(500);
// 5. Open the time <dl> and pick hour + minute from the <li> columns.
var timeDl = deepQuery('dl.weui-desktop-picker__time');
if (!timeDl) return { ok: false, reason: 'no-time-dl' };
var timeDt = timeDl.querySelector('dt.weui-desktop-picker__dt');
if (timeDt) click(timeDt);
await sleep(500);
function pickFromColumn(ol, value) {
if (!ol) return false;
var lis = ol.querySelectorAll('li');
for (var i = 0; i < lis.length; i++) {
if ((lis[i].innerText || '').trim() === value &&
(lis[i].className || '').indexOf('disabled') < 0) {
click(lis[i]);
return true;
}
}
return false;
}
var hourOk = pickFromColumn(timeDl.querySelector('ol.weui-desktop-picker__time__hour'), pad(TH));
if (!hourOk) return { ok: false, reason: 'hour-disabled', hour: TH };
await sleep(300);
var minOk = pickFromColumn(timeDl.querySelector('ol.weui-desktop-picker__time__minute'), pad(TMin));
if (!minOk) return { ok: false, reason: 'minute-disabled', minute: TMin };
await sleep(300);
// 6. Read back the display input to confirm the value landed.
var inp = deepQuery('input[placeholder*="发表时间"]');
return { ok: true, value: inp ? inp.value : null };
})(${targetYear}, ${targetMonth}, ${targetDay}, ${targetHour}, ${targetMin})
`);
if (!result?.ok) {
await page.screenshot({ path: '/tmp/wechat-channels_schedule_debug.png' });
const reason = result?.reason ? String(result.reason) : 'empty picker result';
throw new CommandExecutionError(
`定时设置失败 (${reason}),截图: /tmp/wechat-channels_schedule_debug.png`,
);
}
const expected = `${targetYear}-${pad(targetMonth)}-${pad(targetDay)} ${pad(targetHour)}:${pad(targetMin)}`;
if (!String(result.value || '').includes(expected)) {
throw new CommandExecutionError(`定时设置未验证成功: expected=${expected} actual=${result.value || ''}`);
}
process.stderr.write(` 定时设置完成: ${result.value || expected}\n`);
}
// ── Helper: click publish or draft button ────────────────────────────────────
async function clickPublish(page, isDraft) {
const labels = isDraft
? ['存草稿', '保存草稿', '草稿']
: ['发表', '发布'];
const clicked = await evalPage(page, `
(function(labels) {
${DEEP_QUERY_FN}
var btns = deepQueryAll('button');
for (var i = 0; i < btns.length; i++) {
var btn = btns[i];
var text = (btn.innerText || btn.textContent || '').trim();
var isDisabled = btn.disabled || btn.getAttribute('disabled') !== null ||
btn.classList.contains('weui-desktop-btn_disabled');
if (!isDisabled && isVisible(btn)) {
for (var j = 0; j < labels.length; j++) {
if (text === labels[j] || text.includes(labels[j])) {
btn.click();
return { ok: true, text: text };
}
}
}
}
return { ok: false };
})(${JSON.stringify(labels)})
`);
if (!clicked?.ok) {
await page.screenshot({ path: '/tmp/wechat-channels_publish_submit_debug.png' });
throw new CommandExecutionError(
`找不到"${labels[0]}"按钮(按钮可能被禁用或表单未完成),` +
'截图已保存到 /tmp/wechat-channels_publish_submit_debug.png'
);
}
return clicked;
}
// ── Main cli registration ──────────────────────────────────────────────────
cli({
site: 'wechat-channels',
name: 'publish',
access: 'write',
description: '发布视频到视频号',
domain: 'channels.weixin.qq.com',
strategy: Strategy.COOKIE,
browser: true,
navigateBefore: false,
args: [
{ name: 'video', required: true, positional: true, help: '视频文件路径 (.mp4/.mov/.avi/.webm)' },
{ name: 'title', required: false, help: '短标题(建议 6-16 字)' },
{ name: 'caption', required: false, help: '描述内容,支持直接写 #话题(如:日常生活 #搞笑 #生活)' },
{ name: 'schedule', required: false, help: '定时发布时间(ISO8601 或 Unix 秒,如 "2026-05-20 10:00"' },
{ name: 'draft', type: 'bool', default: false, help: '保存为草稿' },
{ name: 'manual', type: 'bool', default: false, help: '填完所有字段后不自动发布,由用户手动点击发表(务必同时传 --site-session persistent,否则表单页约 30 秒后会被重置为空白页)' },
{ name: 'timeout', type: 'int', required: false, default: 600, help: '命令整体超时秒数(含登录等待 + 上传转码,默认 600)' },
],
columns: ['status', 'title', 'detail'],
func: async (page, kwargs) => {
if (!page) throw new CommandExecutionError('需要浏览器页面');
// ── 1. Validate inputs ───────────────────────────────────────────────
const timeoutSeconds = parseTimeoutSeconds(kwargs.timeout);
const deadline = Date.now() + timeoutSeconds * 1000;
const videoPath = requireFilePath(kwargs.video, '视频', VIDEO_EXTENSIONS);
const title = String(kwargs.title ?? '').trim();
const caption = String(kwargs.caption ?? '').trim();
const scheduleTime = parseScheduleDate(kwargs.schedule || null);
const isDraft = parseBooleanFlag(kwargs.draft);
const isManual = parseBooleanFlag(kwargs.manual);
// ── 2. Navigate to creator center ────────────────────────────────────
await page.goto(PUBLISH_URL);
await page.wait({ time: 4 }); // wujie needs extra time to bootstrap
// ── 3. Login check — fallback: navigate to login page and wait ───────
{
const urlAfterNav = await evalPage(page, '() => location.href');
if (urlAfterNav.includes(LOGIN_PATH_FRAGMENT)) {
process.stderr.write(
'\n⚠️ 未登录视频号。已跳转到登录页,请在 Chrome 中扫码登录...\n' +
' 登录完成后将自动继续发布。\n\n'
);
const loginDeadline = Math.min(deadline, Date.now() + 120_000);
let loggedIn = false;
while (Date.now() < loginDeadline) {
await page.wait({ time: 3 });
const url = await evalPage(page, '() => location.href');
if (!url.includes(LOGIN_PATH_FRAGMENT)) {
loggedIn = true;
break;
}
process.stderr.write(' 等待扫码中...\n');
}
if (!loggedIn) {
throw new AuthRequiredError('channels.weixin.qq.com', '登录超时(2分钟),请手动登录后重试');
}
process.stderr.write('✅ 登录成功,继续发布...\n\n');
await page.wait({ time: 3 }); // let bridge stabilize after login redirect
await page.goto(PUBLISH_URL);
await page.wait({ time: 5 });
}
}
// ── 4. Upload video ──────────────────────────────────────────────────
await uploadFile(page, videoPath);
await page.wait({ time: 2 });
// ── 5. Wait for upload + transcode done ──────────────────────────────
await waitForUploadDone(page, path.basename(videoPath), Math.min(180_000, remainingMs(deadline, '视频上传/转码')));
await page.wait({ time: 1 });
// ── 6. Fill title (主要内容) ─────────────────────────────────────────
if (title) {
await fillField(page, TITLE_SELECTORS, title, 'title');
await page.wait({ time: 0.5 });
}
// ── 7. Fill caption (描述 + 话题) ────────────────────────────────────
if (caption) {
await fillField(page, DESC_SELECTORS, caption, 'caption');
await page.wait({ time: 0.5 });
}
// ── 8. Set schedule time (optional) ──────────────────────────────────
if (scheduleTime) {
await setScheduleTime(page, scheduleTime);
await page.wait({ time: 0.5 });
}
// ── 9. Publish or save draft ──────────────────────────────────────────
if (isManual) {
// The owned automation tab is reset to about:blank when its lease is
// released — immediately if --keep-tab is not set, or after the ~30s
// idle timeout otherwise. Only --site-session persistent disables that
// reset entirely (it maps to IDLE_TIMEOUT_NONE in the extension). Manual
// review always exceeds 30s, so warn unless the form will actually
// survive. We can't read the resolved siteSession from here, so the
// guidance is unconditional.
process.stderr.write(
'\n ℹ️ 手动模式:表单已填好,请在浏览器中检查并点击「发表」。\n' +
' 若未加 --site-session persistent,此标签页约 30 秒后会被重置为空白页。\n\n'
);
return [{
status: '⏸️ 已填写完毕,请在浏览器中手动点击发表',
title: title || '',
detail: [
scheduleTime ? `定时: ${scheduleTime}` : null,
].filter(Boolean).join(' · ') || '表单已就绪',
}];
}
await clickPublish(page, isDraft);
// ── 10. Verify result ─────────────────────────────────────────────────
await page.wait({ time: 4 });
const finalUrl = await evalPage(page, '() => location.href');
const successMarkers = isDraft
? ['草稿已保存', '暂存成功', '保存成功']
: ['已发表', '发布成功', '发表成功', '审核中'];
const successMsg = await evalPage(page, `
(function(markers) {
${DEEP_QUERY_FN}
var all = deepQueryAll('*');
for (var i = 0; i < all.length; i++) {
var el = all[i];
var text = (el.innerText || '').trim();
if (el.children.length === 0 && text) {
for (var j = 0; j < markers.length; j++) {
if (text.includes(markers[j])) return text;
}
}
}
return '';
})(${JSON.stringify(successMarkers)})
`);
const isSuccess = submitSucceeded({ isDraft, finalUrl, successMsg });
if (!isSuccess) {
await page.screenshot({ path: '/tmp/wechat-channels_publish_result_debug.png' });
throw new CommandExecutionError(
`未能验证${isDraft ? '草稿保存' : '发布'}成功,截图已保存到 /tmp/wechat-channels_publish_result_debug.png`,
`url=${finalUrl || ''} message=${successMsg || ''}`,
);
}
const verb = isDraft ? '草稿已保存' : '发布成功';
const detailParts = [
scheduleTime ? `定时: ${scheduleTime.toISOString()}` : null,
successMsg || finalUrl,
].filter(Boolean);
const result = [{
status: `✅ ${verb}`,
title: title || '',
detail: detailParts.join(' · ') || finalUrl,
}];
// Leave the tab on the post list rather than the (now-submitted) create
// form. NOTE: under the default ephemeral lifecycle the extension releases
// the owned tab right after this command returns and resets it to
// about:blank, so this navigation is only visible with
// --site-session persistent (or --keep-tab, until the ~30s idle timeout).
// It is harmless either way.
await page.goto('https://channels.weixin.qq.com/platform/post/list', { waitUntil: 'none' }).catch(() => {});
return result;
},
});