feat(baoyu-post-to-wechat): reuse existing Chrome instead of requiring all windows closed
Previously, the script always launched a new Chrome instance, which failed when Chrome was already running due to profile lock conflicts on macOS. Users had to close all Chrome windows before publishing. This change adds auto-detection of existing Chrome debug ports and reuses an already-logged-in WeChat tab when available, falling back to launching a new instance only when no existing Chrome is found. Changes: - cdp.ts: add tryConnectExisting() and findExistingChromeDebugPort() - wechat-article.ts: try existing Chrome before launching new one, reuse logged-in WeChat tab (identified by token= in URL), add waitForElement() for reliable menu detection, add --cdp-port option for manual override, fix process not exiting after completion Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
5e597f0a8b
commit
6bfafe0ec5
|
|
@ -171,6 +171,37 @@ export interface ChromeSession {
|
|||
targetId: string;
|
||||
}
|
||||
|
||||
export async function tryConnectExisting(port: number): Promise<CdpConnection | null> {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) {
|
||||
const cdp = await CdpConnection.connect(version.webSocketDebuggerUrl, 5_000);
|
||||
return cdp;
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function findExistingChromeDebugPort(): Promise<number | null> {
|
||||
if (process.platform !== 'darwin' && process.platform !== 'linux') return null;
|
||||
try {
|
||||
const { execSync } = await import('node:child_process');
|
||||
const cmd = process.platform === 'darwin'
|
||||
? `lsof -nP -iTCP -sTCP:LISTEN 2>/dev/null | grep -i 'google\\|chrome' | awk '{print $9}' | sed 's/.*://'`
|
||||
: `ss -tlnp 2>/dev/null | grep -i chrome | awk '{print $4}' | sed 's/.*://'`;
|
||||
const output = execSync(cmd, { encoding: 'utf-8', timeout: 5_000 }).trim();
|
||||
if (!output) return null;
|
||||
const ports = output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p) && p > 0);
|
||||
for (const port of ports) {
|
||||
try {
|
||||
const version = await fetchJson<{ webSocketDebuggerUrl?: string }>(`http://127.0.0.1:${port}/json/version`);
|
||||
if (version.webSocketDebuggerUrl) return port;
|
||||
} catch {}
|
||||
}
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function launchChrome(url: string, profileDir?: string): Promise<{ cdp: CdpConnection; chrome: ReturnType<typeof spawn> }> {
|
||||
const chromePath = findChromeExecutable();
|
||||
if (!chromePath) throw new Error('Chrome not found. Set WECHAT_BROWSER_CHROME_PATH env var.');
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import path from 'node:path';
|
|||
import { spawnSync } from 'node:child_process';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { launchChrome, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||
import { launchChrome, tryConnectExisting, findExistingChromeDebugPort, getPageSession, waitForNewTab, clickElement, typeText, evaluate, sleep, type ChromeSession, type CdpConnection } from './cdp.ts';
|
||||
|
||||
const WECHAT_URL = 'https://mp.weixin.qq.com/';
|
||||
|
||||
|
|
@ -25,6 +25,7 @@ interface ArticleOptions {
|
|||
contentImages?: ImageInfo[];
|
||||
submit?: boolean;
|
||||
profileDir?: string;
|
||||
cdpPort?: number;
|
||||
}
|
||||
|
||||
async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promise<boolean> {
|
||||
|
|
@ -37,6 +38,16 @@ async function waitForLogin(session: ChromeSession, timeoutMs = 120_000): Promis
|
|||
return false;
|
||||
}
|
||||
|
||||
async function waitForElement(session: ChromeSession, selector: string, timeoutMs = 10_000): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < timeoutMs) {
|
||||
const found = await evaluate<boolean>(session, `!!document.querySelector('${selector}')`);
|
||||
if (found) return true;
|
||||
await sleep(500);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function clickMenuByText(session: ChromeSession, text: string): Promise<void> {
|
||||
console.log(`[wechat] Clicking "${text}" menu...`);
|
||||
const posResult = await session.cdp.send<{ result: { value: string } }>('Runtime.evaluate', {
|
||||
|
|
@ -234,7 +245,7 @@ async function pressDeleteKey(session: ChromeSession): Promise<void> {
|
|||
}
|
||||
|
||||
export async function postArticle(options: ArticleOptions): Promise<void> {
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir } = options;
|
||||
const { title, content, htmlFile, markdownFile, theme, author, summary, images = [], submit = false, profileDir, cdpPort } = options;
|
||||
let { contentImages = [] } = options;
|
||||
let effectiveTitle = title || '';
|
||||
let effectiveAuthor = author || '';
|
||||
|
|
@ -268,16 +279,67 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||
if (effectiveTitle && effectiveTitle.length > 64) throw new Error(`Title too long: ${effectiveTitle.length} chars (max 64)`);
|
||||
if (!content && !effectiveHtmlFile) throw new Error('Either --content, --html, or --markdown is required');
|
||||
|
||||
const { cdp, chrome } = await launchChrome(WECHAT_URL, profileDir);
|
||||
let cdp: CdpConnection;
|
||||
let chrome: ReturnType<typeof import('node:child_process').spawn> | null = null;
|
||||
|
||||
// Try connecting to existing Chrome: explicit port > auto-detect > launch new
|
||||
const portToTry = cdpPort ?? await findExistingChromeDebugPort();
|
||||
if (portToTry) {
|
||||
const existing = await tryConnectExisting(portToTry);
|
||||
if (existing) {
|
||||
console.log(`[cdp] Connected to existing Chrome on port ${portToTry}`);
|
||||
cdp = existing;
|
||||
} else {
|
||||
console.log(`[cdp] Port ${portToTry} not available, launching new Chrome...`);
|
||||
const launched = await launchChrome(WECHAT_URL, profileDir);
|
||||
cdp = launched.cdp;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
} else {
|
||||
const launched = await launchChrome(WECHAT_URL, profileDir);
|
||||
cdp = launched.cdp;
|
||||
chrome = launched.chrome;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[wechat] Waiting for page load...');
|
||||
await sleep(3000);
|
||||
|
||||
let session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
let session: ChromeSession;
|
||||
if (!chrome) {
|
||||
// Reusing existing Chrome: find an already-logged-in tab (has token in URL)
|
||||
const allTargets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const loggedInTab = allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com') && t.url.includes('token='));
|
||||
const wechatTab = loggedInTab || allTargets.targetInfos.find(t => t.type === 'page' && t.url.includes('mp.weixin.qq.com'));
|
||||
|
||||
if (wechatTab) {
|
||||
console.log(`[wechat] Reusing existing tab: ${wechatTab.url.substring(0, 80)}...`);
|
||||
const { sessionId: reuseSid } = await cdp.send<{ sessionId: string }>('Target.attachToTarget', { targetId: wechatTab.targetId, flatten: true });
|
||||
await cdp.send('Page.enable', {}, { sessionId: reuseSid });
|
||||
await cdp.send('Runtime.enable', {}, { sessionId: reuseSid });
|
||||
await cdp.send('DOM.enable', {}, { sessionId: reuseSid });
|
||||
session = { cdp, sessionId: reuseSid, targetId: wechatTab.targetId };
|
||||
|
||||
// Navigate to home if not already there
|
||||
const currentUrl = await evaluate<string>(session, 'window.location.href');
|
||||
if (!currentUrl.includes('/cgi-bin/home')) {
|
||||
console.log('[wechat] Navigating to home...');
|
||||
await evaluate(session, `window.location.href = '${WECHAT_URL}cgi-bin/home?t=home/index'`);
|
||||
await sleep(5000);
|
||||
}
|
||||
} else {
|
||||
// No WeChat tab found, create one
|
||||
console.log('[wechat] No WeChat tab found, opening...');
|
||||
await cdp.send('Target.createTarget', { url: WECHAT_URL });
|
||||
await sleep(5000);
|
||||
session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
}
|
||||
} else {
|
||||
session = await getPageSession(cdp, 'mp.weixin.qq.com');
|
||||
}
|
||||
|
||||
const url = await evaluate<string>(session, 'window.location.href');
|
||||
if (!url.includes('/cgi-bin/home')) {
|
||||
if (!url.includes('/cgi-bin/')) {
|
||||
console.log('[wechat] Not logged in. Please scan QR code...');
|
||||
const loggedIn = await waitForLogin(session);
|
||||
if (!loggedIn) throw new Error('Login timeout');
|
||||
|
|
@ -285,6 +347,10 @@ export async function postArticle(options: ArticleOptions): Promise<void> {
|
|||
console.log('[wechat] Logged in.');
|
||||
await sleep(2000);
|
||||
|
||||
// Wait for menu to be ready
|
||||
const menuReady = await waitForElement(session, '.new-creation__menu', 20_000);
|
||||
if (!menuReady) throw new Error('Home page menu did not load');
|
||||
|
||||
const targets = await cdp.send<{ targetInfos: Array<{ targetId: string; url: string; type: string }> }>('Target.getTargets');
|
||||
const initialIds = new Set(targets.targetInfos.map(t => t.targetId));
|
||||
|
||||
|
|
@ -413,6 +479,7 @@ Options:
|
|||
--image <path> Content image, can repeat (only with --content)
|
||||
--submit Save as draft
|
||||
--profile <dir> Chrome profile directory
|
||||
--cdp-port <port> Connect to existing Chrome debug port instead of launching new instance
|
||||
|
||||
Examples:
|
||||
npx -y bun wechat-article.ts --markdown article.md
|
||||
|
|
@ -442,6 +509,7 @@ async function main(): Promise<void> {
|
|||
let summary: string | undefined;
|
||||
let submit = false;
|
||||
let profileDir: string | undefined;
|
||||
let cdpPort: number | undefined;
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i]!;
|
||||
|
|
@ -455,15 +523,18 @@ async function main(): Promise<void> {
|
|||
else if (arg === '--image' && args[i + 1]) images.push(args[++i]!);
|
||||
else if (arg === '--submit') submit = true;
|
||||
else if (arg === '--profile' && args[i + 1]) profileDir = args[++i];
|
||||
else if (arg === '--cdp-port' && args[i + 1]) cdpPort = parseInt(args[++i]!, 10);
|
||||
}
|
||||
|
||||
if (!markdownFile && !htmlFile && !title) { console.error('Error: --title is required (or use --markdown/--html)'); process.exit(1); }
|
||||
if (!markdownFile && !htmlFile && !content) { console.error('Error: --content, --html, or --markdown is required'); process.exit(1); }
|
||||
|
||||
await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir });
|
||||
await postArticle({ title: title || '', content, htmlFile, markdownFile, theme, author, summary, images, submit, profileDir, cdpPort });
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
await main().then(() => {
|
||||
process.exit(0);
|
||||
}).catch((err) => {
|
||||
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue