Compare commits

..

10 Commits

Author SHA1 Message Date
Jim Liu 宝玉 8e2967d4a2 chore: release v1.89.0 2026-03-31 18:36:02 -05:00
Jim Liu 宝玉 3a8b0cc158 chore: update bun.lock files 2026-03-31 18:24:26 -05:00
Jim Liu 宝玉 9e3d72cf42 chore(baoyu-url-to-markdown): sync vendor baoyu-fetch with session and lifecycle changes 2026-03-31 18:24:25 -05:00
Jim Liu 宝玉 5eeb1e6d8d refactor(baoyu-danger-x-to-markdown): use graceful Chrome shutdown 2026-03-31 18:24:22 -05:00
Jim Liu 宝玉 0ee6dd4305 refactor(baoyu-danger-gemini-web): use graceful Chrome shutdown 2026-03-31 18:24:22 -05:00
Jim Liu 宝玉 7891f3c3c0 docs(baoyu-post-to-weibo): add post type auto-selection and safer CDP kill instructions 2026-03-31 18:24:17 -05:00
Jim Liu 宝玉 74f4a48ca7 feat(baoyu-post-to-x): add X session persistence and Chrome lock recovery 2026-03-31 18:24:17 -05:00
Jim Liu 宝玉 881c03262e feat(baoyu-article-illustrator): add warm palette variant for vector-illustration style 2026-03-31 18:24:15 -05:00
Jim Liu 宝玉 6afcfa80cc feat(baoyu-fetch): add X session cookie sidecar and graceful Chrome lifecycle
Add cookie sidecar to export/restore X session cookies across runs.
Implement graceful Browser.close via WebSocket before falling back to kill.
Auto-detect and clean stale Chrome profile lock artifacts on launch failure.
Wait for X session readiness (auth_token + ct0) before auto-continuing login flows.
2026-03-31 18:24:10 -05:00
Jim Liu 宝玉 9eb032a22f fix(ci): sync npm lockfile and root node tests 2026-03-27 14:23:59 -05:00
44 changed files with 3423 additions and 150 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
},
"metadata": {
"description": "Skills shared by Baoyu for improving daily work efficiency",
"version": "1.88.0"
"version": "1.89.0"
},
"plugins": [
{
+21
View File
@@ -2,6 +2,27 @@
English | [中文](./CHANGELOG.zh.md)
## 1.89.0 - 2026-03-31
### Features
- `baoyu-fetch`: add X session cookie sidecar to persist login across runs, graceful Chrome shutdown via Browser.close, and stale profile lock auto-recovery
- `baoyu-article-illustrator`: add warm palette variant for vector-illustration style with new `warm-knowledge` preset
- `baoyu-post-to-x`: add X session persistence after login, Chrome lock recovery, and graceful shutdown
### Documentation
- `baoyu-post-to-weibo`: add post type auto-selection rules and safer CDP kill instructions
### Refactor
- `baoyu-danger-gemini-web`: use graceful Chrome shutdown instead of hard kill
- `baoyu-danger-x-to-markdown`: use graceful Chrome shutdown instead of hard kill
### Fixes
- Sync npm lockfile and root node tests
### Maintenance
- `baoyu-url-to-markdown`: sync vendor baoyu-fetch with session and lifecycle changes
- Update bun.lock files
## 1.88.0 - 2026-03-27
### Features
+21
View File
@@ -2,6 +2,27 @@
[English](./CHANGELOG.md) | 中文
## 1.89.0 - 2026-03-31
### 新功能
- `baoyu-fetch`:新增 X 会话 Cookie 旁路文件,跨运行持久化登录状态;通过 Browser.close 优雅关闭 Chrome;自动检测并清理过期的 Chrome 配置锁文件
- `baoyu-article-illustrator`:新增暖色调矢量插画配色方案,含 `warm-knowledge` 预设
- `baoyu-post-to-x`:新增登录后 X 会话持久化、Chrome 锁文件恢复和优雅关闭
### 文档
- `baoyu-post-to-weibo`:新增发帖类型自动选择规则,优化 CDP Chrome 终止指令
### 重构
- `baoyu-danger-gemini-web`:使用优雅 Chrome 关闭替代强制终止
- `baoyu-danger-x-to-markdown`:使用优雅 Chrome 关闭替代强制终止
### 修复
- 同步 npm lockfile 及修复根目录 Node 测试
### 维护
- `baoyu-url-to-markdown`:同步 vendor baoyu-fetch 的会话和生命周期改进
- 更新 bun.lock 文件
## 1.88.0 - 2026-03-27
### 新功能
+1 -1
View File
@@ -1,6 +1,6 @@
# CLAUDE.md
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.88.0**.
Claude Code marketplace plugin providing AI-powered content generation skills. Version: **1.89.0**.
## Architecture
+2101 -9
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -6,8 +6,8 @@
"packages/*"
],
"scripts": {
"test": "node --import tsx --test",
"test:coverage": "node --import tsx --experimental-test-coverage --test"
"test": "node ./scripts/run-node-tests.mjs",
"test:coverage": "node ./scripts/run-node-tests.mjs --experimental-test-coverage"
},
"devDependencies": {
"@mozilla/readability": "^0.6.0",
@@ -2,7 +2,12 @@ import { afterEach, describe, expect, test } from "bun:test";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { ensureChromeProfileDir, resolveChromeProfileDir } from "../browser/profile";
import {
ensureChromeProfileDir,
hasChromeLockArtifacts,
resolveChromeProfileDir,
shouldRetryChromeLaunchRecovery,
} from "../browser/profile";
const originalProfile = process.env.BAOYU_CHROME_PROFILE_DIR;
@@ -47,3 +52,17 @@ describe("ensureChromeProfileDir", () => {
}
});
});
describe("stale lock recovery helpers", () => {
test("detects Chrome singleton lock artifacts", () => {
expect(hasChromeLockArtifacts(["Cookies", "SingletonLock"])).toBe(true);
expect(hasChromeLockArtifacts(["chrome.pid"])).toBe(true);
expect(hasChromeLockArtifacts(["Preferences", "Cookies"])).toBe(false);
});
test("only retries stale-lock recovery when no live owner exists", () => {
expect(shouldRetryChromeLaunchRecovery({ hasLockArtifacts: true, hasLiveOwner: false })).toBe(true);
expect(shouldRetryChromeLaunchRecovery({ hasLockArtifacts: true, hasLiveOwner: true })).toBe(false);
expect(shouldRetryChromeLaunchRecovery({ hasLockArtifacts: false, hasLiveOwner: false })).toBe(false);
});
});
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
import { shouldAutoContinueForceWait } from "../commands/convert";
import { shouldAutoContinueForceWait, shouldKeepBrowserOpenAfterInteraction } from "../commands/convert";
describe("shouldAutoContinueForceWait", () => {
test("continues when a challenge disappears", () => {
@@ -9,28 +9,49 @@ describe("shouldAutoContinueForceWait", () => {
url: "https://example.com/challenge",
hasGate: true,
loginState: "unknown",
sessionReady: true,
},
{
url: "https://example.com/article",
hasGate: false,
loginState: "unknown",
sessionReady: true,
},
),
).toBe(true);
});
test("continues when login state improves from logged out", () => {
test("waits for X session cookies before continuing after login", () => {
expect(
shouldAutoContinueForceWait(
{
url: "https://x.com/i/flow/login",
hasGate: false,
loginState: "logged_out",
sessionReady: false,
},
{
url: "https://x.com/home",
hasGate: false,
loginState: "logged_in",
sessionReady: false,
},
),
).toBe(false);
expect(
shouldAutoContinueForceWait(
{
url: "https://x.com/i/flow/login",
hasGate: false,
loginState: "logged_out",
sessionReady: false,
},
{
url: "https://x.com/home",
hasGate: false,
loginState: "logged_in",
sessionReady: true,
},
),
).toBe(true);
@@ -43,13 +64,42 @@ describe("shouldAutoContinueForceWait", () => {
url: "https://x.com/lennysan/status/2036483059407810640",
hasGate: false,
loginState: "unknown",
sessionReady: false,
},
{
url: "https://x.com/lennysan/status/2036483059407810640",
hasGate: false,
loginState: "unknown",
sessionReady: false,
},
),
).toBe(false);
});
});
describe("shouldKeepBrowserOpenAfterInteraction", () => {
test("keeps launched X login browsers open", () => {
expect(
shouldKeepBrowserOpenAfterInteraction({
launched: true,
interaction: { kind: "login", provider: "x" },
}),
).toBe(true);
});
test("does not keep reused or non-login browsers open", () => {
expect(
shouldKeepBrowserOpenAfterInteraction({
launched: false,
interaction: { kind: "login", provider: "x" },
}),
).toBe(false);
expect(
shouldKeepBrowserOpenAfterInteraction({
launched: true,
interaction: { kind: "cloudflare", provider: "cloudflare" },
}),
).toBe(false);
});
});
@@ -0,0 +1,28 @@
import { describe, expect, test } from "bun:test";
import { buildXSessionCookieMap, hasRequiredXSessionCookies } from "../adapters/x/session";
describe("X session helpers", () => {
test("keeps non-empty X session cookies", () => {
expect(
buildXSessionCookieMap([
{ name: "auth_token", value: "auth" },
{ name: "ct0", value: "csrf" },
{ name: "twid", value: "u=123" },
{ name: "ct0", value: "" },
{ name: "", value: "ignored" },
{ name: "gt", value: undefined },
]),
).toEqual({
auth_token: "auth",
ct0: "csrf",
twid: "u=123",
});
});
test("requires auth_token and ct0 for a ready X session", () => {
expect(hasRequiredXSessionCookies({ auth_token: "auth" })).toBe(false);
expect(hasRequiredXSessionCookies({ ct0: "csrf" })).toBe(false);
expect(hasRequiredXSessionCookies({ auth_token: "auth", ct0: "csrf" })).toBe(true);
});
});
@@ -64,6 +64,8 @@ export interface Adapter {
name: string;
match(input: AdapterInput): boolean;
checkLogin?(context: AdapterContext): Promise<AdapterLoginInfo>;
exportCookies?(context: AdapterContext, profileDir?: string): Promise<boolean>;
restoreCookies?(context: AdapterContext, profileDir?: string): Promise<boolean>;
downloadMedia?(request: MediaDownloadRequest): Promise<MediaDownloadResult>;
process(context: AdapterContext): Promise<AdapterProcessResult>;
}
@@ -1,4 +1,5 @@
import type { Adapter, AdapterLoginInfo } from "../types";
import { exportCookies, restoreCookies, type CookieSidecarConfig } from "../../browser/cookie-sidecar";
import { detectInteractionGate } from "../../browser/interaction-gates";
import type { ExtractedDocument } from "../../extract/document";
import { collectMediaFromDocument } from "../../media/markdown-media";
@@ -10,6 +11,16 @@ import { extractSingleTweetDocumentFromPayload } from "./single";
import { extractThreadDocumentFromPayloads } from "./thread";
import { loadFullXThread } from "./thread-loader";
const cookieConfig: CookieSidecarConfig = {
urls: ["https://x.com/", "https://twitter.com/"],
filename: "x-session-cookies.json",
requiredCookieNames: ["auth_token", "ct0"],
filterCookie: (c) => {
const d = c.domain ?? "";
return d.endsWith("x.com") || d.endsWith("twitter.com");
},
};
function extractDocumentFromPayloads(
payloads: unknown[],
statusId: string,
@@ -49,6 +60,12 @@ export const xAdapter: Adapter = {
async checkLogin(context) {
return detectXLogin(context);
},
async exportCookies(context, profileDir) {
return exportCookies(context.browser.targetSession, cookieConfig, profileDir);
},
async restoreCookies(context, profileDir) {
return restoreCookies(context.browser.targetSession, cookieConfig, profileDir);
},
async process(context) {
const statusId = extractStatusId(context.input.url);
if (!statusId) {
@@ -0,0 +1,47 @@
import type { AdapterContext } from "../types";
const X_SESSION_URLS = ["https://x.com/", "https://twitter.com/"] as const;
const REQUIRED_X_SESSION_COOKIES = ["auth_token", "ct0"] as const;
interface CookieLike {
name?: string;
value?: string | null;
}
interface NetworkGetCookiesResult {
cookies?: CookieLike[];
}
export function buildXSessionCookieMap(cookies: readonly CookieLike[]): Record<string, string> {
const cookieMap: Record<string, string> = {};
for (const cookie of cookies) {
const name = cookie.name?.trim();
const value = cookie.value?.trim();
if (!name || !value) {
continue;
}
cookieMap[name] = value;
}
return cookieMap;
}
export function hasRequiredXSessionCookies(cookieMap: Record<string, string>): boolean {
return REQUIRED_X_SESSION_COOKIES.every((name) => Boolean(cookieMap[name]));
}
export async function readXSessionCookieMap(
context: Pick<AdapterContext, "browser">,
): Promise<Record<string, string>> {
const { cookies } = await context.browser.targetSession.send<NetworkGetCookiesResult>(
"Network.getCookies",
{ urls: [...X_SESSION_URLS] },
);
return buildXSessionCookieMap(cookies ?? []);
}
export async function isXSessionReady(
context: Pick<AdapterContext, "browser">,
): Promise<boolean> {
const cookieMap = await readXSessionCookieMap(context);
return hasRequiredXSessionCookies(cookieMap);
}
@@ -1,6 +1,16 @@
import { launch, type LaunchedChrome } from "chrome-launcher";
import WebSocket from "ws";
import type { Logger } from "../utils/logger";
import { ensureChromeProfileDir, findExistingChromeDebugPort, resolveChromeProfileDir } from "./profile";
import {
cleanChromeLockArtifacts,
ensureChromeProfileDir,
findChromeProcessUsingProfile,
findExistingChromeDebugPort,
hasChromeLockArtifacts,
listChromeProfileEntries,
resolveChromeProfileDir,
shouldRetryChromeLaunchRecovery,
} from "./profile";
interface ChromeVersionResponse {
webSocketDebuggerUrl: string;
@@ -65,6 +75,80 @@ async function tryReuseChrome(profileDir: string, logger?: Logger): Promise<Chro
return null;
}
async function launchFreshChrome(
profileDir: string,
options: Pick<ChromeConnectOptions, "browserPath" | "headless">,
): Promise<ChromeConnection> {
let launchedChrome: LaunchedChrome | null = null;
try {
launchedChrome = await launch({
chromePath: options.browserPath,
userDataDir: profileDir,
chromeFlags: [
"--disable-background-networking",
"--disable-default-apps",
"--disable-popup-blocking",
"--disable-sync",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
...(!options.headless ? ["--no-startup-window"] : []),
...(options.headless ? ["--headless=new"] : []),
],
});
const origin = `http://127.0.0.1:${launchedChrome.port}`;
const version = await fetchJson<ChromeVersionResponse>(`${origin}/json/version`);
const chrome = launchedChrome;
return {
browserWsUrl: version.webSocketDebuggerUrl,
origin,
port: launchedChrome.port,
profileDir,
launched: true,
async close() {
if (!chrome) return;
await gracefulCloseChrome(chrome, origin);
},
};
} catch (error) {
launchedChrome?.kill();
throw error;
}
}
async function gracefulCloseChrome(chrome: LaunchedChrome, origin: string): Promise<void> {
try {
const resp = await fetch(`${origin}/json/version`);
const { webSocketDebuggerUrl } = (await resp.json()) as ChromeVersionResponse;
if (webSocketDebuggerUrl) {
const ws = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(webSocketDebuggerUrl);
socket.once("open", () => resolve(socket));
socket.once("error", reject);
});
const id = 1;
ws.send(JSON.stringify({ id, method: "Browser.close" }));
await new Promise<void>((resolve) => {
const timer = setTimeout(() => { ws.close(); resolve(); }, 5_000);
ws.once("close", () => { clearTimeout(timer); resolve(); });
});
const exited = await new Promise<boolean>((resolve) => {
if (chrome.pid && !isProcessAlive(chrome.pid)) { resolve(true); return; }
const timer = setTimeout(() => resolve(false), 3_000);
chrome.process.once("exit", () => { clearTimeout(timer); resolve(true); });
});
if (exited) return;
}
} catch {}
chrome.kill();
}
function isProcessAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
export async function connectChrome(options: ChromeConnectOptions): Promise<ChromeConnection> {
if (options.cdpUrl) {
if (options.cdpUrl.startsWith("ws://") || options.cdpUrl.startsWith("wss://")) {
@@ -84,34 +168,20 @@ export async function connectChrome(options: ChromeConnectOptions): Promise<Chro
}
options.logger?.warn(`No running Chrome debugger found for profile ${profileDir}. Launching Chrome with that profile.`);
try {
return await launchFreshChrome(profileDir, options);
} catch (error) {
const entries = await listChromeProfileEntries(profileDir);
const shouldRetry = shouldRetryChromeLaunchRecovery({
hasLockArtifacts: hasChromeLockArtifacts(entries),
hasLiveOwner: findChromeProcessUsingProfile(profileDir),
});
if (!shouldRetry) {
throw error;
}
const launchedChrome: LaunchedChrome = await launch({
chromePath: options.browserPath,
userDataDir: profileDir,
chromeFlags: [
"--disable-background-networking",
"--disable-default-apps",
"--disable-popup-blocking",
"--disable-sync",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
...(!options.headless ? ["--no-startup-window"] : []),
...(options.headless ? ["--headless=new"] : []),
],
});
const origin = `http://127.0.0.1:${launchedChrome.port}`;
const version = await fetchJson<ChromeVersionResponse>(`${origin}/json/version`);
return {
browserWsUrl: version.webSocketDebuggerUrl,
origin,
port: launchedChrome.port,
profileDir,
launched: true,
async close() {
launchedChrome.kill();
},
};
options.logger?.warn(`Chrome launch failed with stale profile locks. Cleaning ${profileDir} and retrying once.`);
cleanChromeLockArtifacts(profileDir);
return await launchFreshChrome(profileDir, options);
}
}
@@ -0,0 +1,100 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { resolveChromeProfileDir } from "./profile";
import type { TargetSession } from "./cdp-client";
export interface CdpCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
size: number;
httpOnly: boolean;
secure: boolean;
session: boolean;
sameSite?: string;
priority?: string;
sameParty?: boolean;
sourceScheme?: string;
sourcePort?: number;
partitionKey?: string;
}
interface SidecarData {
savedAt: string;
cookies: CdpCookie[];
}
export interface CookieSidecarConfig {
urls: readonly string[];
filename: string;
requiredCookieNames: readonly string[];
filterCookie?: (cookie: CdpCookie) => boolean;
}
function sidecarPath(filename: string, profileDir?: string): string {
return join(resolveChromeProfileDir(profileDir), filename);
}
function hasRequired(cookies: CdpCookie[], names: readonly string[]): boolean {
return names.every((name) =>
cookies.some((c) => c.name === name && Boolean(c.value)),
);
}
async function getCookies(session: TargetSession, urls: readonly string[]): Promise<CdpCookie[]> {
const { cookies } = await session.send<{ cookies: CdpCookie[] }>(
"Network.getCookies",
{ urls: [...urls] },
);
return cookies ?? [];
}
export async function exportCookies(
session: TargetSession,
config: CookieSidecarConfig,
profileDir?: string,
): Promise<boolean> {
const all = await getCookies(session, config.urls);
const filtered = config.filterCookie ? all.filter(config.filterCookie) : all;
if (!hasRequired(filtered, config.requiredCookieNames)) return false;
const filePath = sidecarPath(config.filename, profileDir);
await mkdir(dirname(filePath), { recursive: true });
const data: SidecarData = { savedAt: new Date().toISOString(), cookies: filtered };
await writeFile(filePath, JSON.stringify(data, null, 2));
return true;
}
export async function restoreCookies(
session: TargetSession,
config: CookieSidecarConfig,
profileDir?: string,
): Promise<boolean> {
const live = await getCookies(session, config.urls);
if (hasRequired(live, config.requiredCookieNames)) return false;
const filePath = sidecarPath(config.filename, profileDir);
const raw = await readFile(filePath, "utf8");
const data = JSON.parse(raw) as SidecarData;
if (!data.cookies?.length) return false;
const now = Date.now() / 1000;
const valid = data.cookies.filter((c) => c.session || !c.expires || c.expires > now);
if (!hasRequired(valid, config.requiredCookieNames)) return false;
await session.send("Network.setCookies", {
cookies: valid.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
httpOnly: c.httpOnly,
secure: c.secure,
sameSite: c.sameSite,
expires: c.expires,
})),
});
return true;
}
@@ -19,6 +19,8 @@ interface ChromeVersionResponse {
webSocketDebuggerUrl?: string;
}
const CHROME_LOCK_FILE_NAMES = ["SingletonLock", "SingletonSocket", "SingletonCookie", "chrome.pid"] as const;
function resolveDataBaseDir(): string {
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support");
@@ -61,6 +63,57 @@ export function ensureChromeProfileDir(profileDir: string): string {
return profileDir;
}
export function hasChromeLockArtifacts(entries: readonly string[]): boolean {
return CHROME_LOCK_FILE_NAMES.some((name) => entries.includes(name));
}
export function shouldRetryChromeLaunchRecovery(options: {
hasLockArtifacts: boolean;
hasLiveOwner: boolean;
}): boolean {
return options.hasLockArtifacts && !options.hasLiveOwner;
}
export function findChromeProcessUsingProfile(profileDir: string): boolean {
if (process.platform === "win32") {
return false;
}
try {
const result = spawnSync("ps", ["aux"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status !== 0 || !result.stdout) {
return false;
}
return result.stdout
.split("\n")
.some((line) => line.includes(`--user-data-dir=${profileDir}`));
} catch {
return false;
}
}
export function cleanChromeLockArtifacts(profileDir: string): void {
for (const name of CHROME_LOCK_FILE_NAMES) {
try {
fs.unlinkSync(path.join(profileDir, name));
} catch {
// Ignore missing files and continue cleaning the remaining artifacts.
}
}
}
export async function listChromeProfileEntries(profileDir: string): Promise<string[]> {
try {
return await fs.promises.readdir(profileDir);
} catch {
return [];
}
}
async function fetchWithTimeout(url: string, timeoutMs = 3_000): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
+50 -7
View File
@@ -7,6 +7,7 @@ import { detectInteractionGate } from "../browser/interaction-gates";
import { NetworkJournal } from "../browser/network-journal";
import { BrowserSession } from "../browser/session";
import { genericAdapter, resolveAdapter } from "../adapters";
import { isXSessionReady } from "../adapters/x/session";
import type { ExtractedDocument } from "../extract/document";
import { renderMarkdown } from "../extract/markdown-renderer";
import { downloadMediaAssets } from "../media/default-downloader";
@@ -55,6 +56,7 @@ interface ForceWaitSnapshot {
url: string;
hasGate: boolean;
loginState: LoginState | "unavailable";
sessionReady: boolean;
}
interface SuccessfulConvertOutput {
@@ -78,6 +80,17 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isForceWaitSessionReady(snapshot: ForceWaitSnapshot): boolean {
return snapshot.sessionReady;
}
export function shouldKeepBrowserOpenAfterInteraction(options: {
launched: boolean;
interaction: Pick<WaitForInteractionRequest, "kind" | "provider">;
}): boolean {
return options.launched && options.interaction.kind === "login" && options.interaction.provider === "x";
}
export function shouldAutoContinueForceWait(
initial: ForceWaitSnapshot,
current: ForceWaitSnapshot,
@@ -86,15 +99,20 @@ export function shouldAutoContinueForceWait(
return true;
}
if (initial.loginState === "logged_out" && current.loginState !== "logged_out") {
if (initial.loginState === "logged_out" && current.loginState !== "logged_out" && isForceWaitSessionReady(current)) {
return true;
}
if (initial.loginState !== "logged_in" && current.loginState === "logged_in") {
if (initial.loginState !== "logged_in" && current.loginState === "logged_in" && isForceWaitSessionReady(current)) {
return true;
}
if (current.url !== initial.url && !current.hasGate && current.loginState !== "logged_out") {
if (
current.url !== initial.url &&
!current.hasGate &&
current.loginState !== "logged_out" &&
isForceWaitSessionReady(current)
) {
return true;
}
@@ -173,6 +191,16 @@ async function closeRuntime(runtime: RuntimeResources | null | undefined): Promi
await runtime.chrome.close().catch(() => {});
}
async function isInteractionSessionReady(
context: AdapterContext,
interaction: WaitForInteractionRequest,
): Promise<boolean> {
if (interaction.provider !== "x") {
return true;
}
return await isXSessionReady(context).catch(() => false);
}
async function reopenInteractiveRuntime(
runtime: RuntimeResources,
options: ConvertCommandOptions,
@@ -203,6 +231,7 @@ async function captureForceWaitSnapshot(
url,
hasGate: Boolean(gate),
loginState: login?.state ?? "unavailable",
sessionReady: adapter.name === "x" ? await isXSessionReady(context).catch(() => false) : true,
};
}
@@ -280,7 +309,7 @@ async function waitForInteraction(
while (Date.now() - startedAt < timeoutMs) {
if (interaction.kind === "login" && adapter.checkLogin) {
lastLogin = await adapter.checkLogin(context);
if (lastLogin.state === "logged_in") {
if (lastLogin.state === "logged_in" && await isInteractionSessionReady(context, interaction)) {
return lastLogin;
}
}
@@ -303,7 +332,7 @@ async function waitForInteraction(
}
lastLogin = await adapter.checkLogin(context);
if (lastLogin.state !== "logged_out") {
if (lastLogin.state !== "logged_out" && await isInteractionSessionReady(context, interaction)) {
return lastLogin;
}
}
@@ -347,10 +376,13 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
const url = normalizeUrl(options.url);
let runtime = await openRuntime(options, options.waitMode !== "none", Boolean(options.debugDir));
const logger = createLogger(Boolean(options.debugDir));
let didLogin = false;
let adapter: Adapter | null = null;
let context: AdapterContext | null = null;
try {
const adapter = resolveAdapter({ url }, options.adapter);
let context: AdapterContext = {
adapter = resolveAdapter({ url }, options.adapter);
context = {
input: { url },
browser: runtime.browser,
network: runtime.network,
@@ -362,6 +394,11 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
downloadMedia: options.downloadMedia,
};
if (adapter.restoreCookies) {
const restored = await adapter.restoreCookies(context, runtime.chrome.profileDir).catch(() => false);
if (restored) logger.info(`Restored ${adapter.name} session cookies from sidecar.`);
}
if (options.waitMode === "force") {
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
await waitForForceResume(adapter, context, options);
@@ -414,6 +451,9 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
};
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
if (result.interaction.kind === "login") {
didLogin = true;
}
await waitForInteraction(adapter, context, result.interaction, options);
result = await adapter.process(context);
@@ -516,6 +556,9 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
printOutput(markdown);
} finally {
if (adapter?.exportCookies && context) {
await adapter.exportCookies(context, runtime.chrome.profileDir).catch(() => {});
}
await closeRuntime(runtime);
}
}
+65
View File
@@ -0,0 +1,65 @@
import { spawn } from "node:child_process";
import { readdir, readFile } from "node:fs/promises";
import path from "node:path";
import process from "node:process";
const ROOT_DIR = process.cwd();
const TEST_FILE_PATTERN = /\.test\.(?:[cm]?[jt]s|tsx)$/;
const SKIP_DIRECTORIES = new Set([".git", "node_modules"]);
const BUN_TEST_IMPORT_PATTERN = /from\s+["']bun:test["']/;
async function collectTestFiles(directory) {
const entries = await readdir(directory, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const entryPath = path.join(directory, entry.name);
if (entry.isDirectory()) {
if (SKIP_DIRECTORIES.has(entry.name)) {
continue;
}
files.push(...(await collectTestFiles(entryPath)));
continue;
}
if (entry.isFile() && TEST_FILE_PATTERN.test(entry.name)) {
files.push(entryPath);
}
}
return files;
}
async function isNodeCompatibleTest(filePath) {
const source = await readFile(filePath, "utf8");
return !BUN_TEST_IMPORT_PATTERN.test(source);
}
const allTestFiles = await collectTestFiles(ROOT_DIR);
const runnableTestFiles = [];
for (const filePath of allTestFiles.sort()) {
if (await isNodeCompatibleTest(filePath)) {
runnableTestFiles.push(filePath);
}
}
if (runnableTestFiles.length === 0) {
console.error("No Node-compatible test files found.");
process.exit(1);
}
const forwardedArgs = process.argv.slice(2);
const nodeArgs = ["--import", "tsx", ...forwardedArgs, "--test", ...runnableTestFiles];
const child = spawn(process.execPath, nodeArgs, { stdio: "inherit" });
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 1);
});
+1 -1
View File
@@ -1,7 +1,7 @@
---
name: baoyu-article-illustrator
description: Analyzes article structure, identifies positions requiring visual aids, generates illustrations with Type × Style two-dimension approach. Use when user asks to "illustrate article", "add images", "generate images for article", or "为文章配图".
version: 1.56.1
version: 1.57.0
metadata:
openclaw:
homepage: https://github.com/JimLiu/baoyu-skills#baoyu-article-illustrator
@@ -135,6 +135,16 @@ COLORS: Cream background (#F5F0E6), Coral Red (#E07A5F), Mint Green (#81B29A), M
ELEMENTS: Geometric simplified icons, no gradients, playful decorative elements (dots, stars)
```
**Infographic + vector-illustration (warm palette)**:
```
Flat vector illustration infographic. Clean black outlines on all elements.
Warm-only color palette, no cool colors.
COLORS: Soft Peach background (#FFECD2), Warm Orange (#ED8936),
Terracotta (#C05621), Golden Yellow (#F6AD55), Deep Brown (#744210)
ELEMENTS: Geometric simplified icons, no gradients, rounded corners,
modular card layout, consistent icon style
```
### Scene
```
@@ -197,6 +207,15 @@ COLORS: Left side Coral (#E07A5F), Right side Mint (#81B29A), cream background
ELEMENTS: Bold icons, black outlines, centered divider line
```
**Comparison + vector-illustration (warm palette)**:
```
Flat vector comparison with split layout. Clear visual separation.
Warm-only color palette, no cool colors.
COLORS: Left side Warm Orange (#ED8936), Right side Terracotta (#C05621),
Soft Peach background (#FFECD2), Deep Brown (#744210) accents
ELEMENTS: Bold icons, black outlines, centered divider line
```
### Framework
```
@@ -220,6 +239,15 @@ COLORS: Cream background (#F5F0E6), nodes in Coral/Mint/Mustard/Blue, black outl
ELEMENTS: Rounded rectangles or circles for nodes, thick connecting lines
```
**Framework + vector-illustration (warm palette)**:
```
Flat vector framework diagram with geometric nodes and bold connectors.
Warm-only color palette, no cool colors.
COLORS: Soft Peach background (#FFECD2), nodes in Warm Orange (#ED8936),
Terracotta (#C05621), Golden Yellow (#F6AD55), black outlines
ELEMENTS: Rounded rectangles or circles for nodes, thick connecting lines
```
### Timeline
```
@@ -21,6 +21,7 @@
| `saas-guide` | `infographic` | `notion` | Product guides, SaaS docs, tool walkthroughs |
| `tutorial` | `flowchart` | `vector-illustration` | Step-by-step tutorials, setup guides |
| `process-flow` | `flowchart` | `notion` | Workflow documentation, onboarding flows |
| `warm-knowledge` | `infographic` | `vector-illustration` (warm palette) | Product showcases, team intros, feature cards, brand content |
### Data & Analysis
@@ -62,7 +63,7 @@ Use this table during Step 3 to recommend presets based on Step 2 content analys
| Opinion / Editorial | `opinion-piece` | `cinematic`, `editorial-poster` |
| Historical / Timeline | `history` | `evolution` |
| Academic / Research | `science-paper` | `tech-explainer`, `data-report` |
| SaaS / Product | `saas-guide` | `knowledge-base`, `process-flow` |
| SaaS / Product | `saas-guide` | `knowledge-base`, `process-flow`, `warm-knowledge` |
## Override Examples
@@ -98,6 +98,12 @@ Full specifications: `references/styles/<style>.md`
- Modern, professional, highly readable
- Perfect for knowledge articles and tutorials
### infographic + vector-illustration (warm palette)
- Same rendering: flat vector, black outlines, geometric simplification
- Warm-only palette: terracotta, gold, brown tones on soft peach
- Modern-retro feel, approachable and brand-friendly
- Best for product showcases, team intros, feature grids, brand content
### flowchart + vector-illustration
- Bold arrows and connectors
- Distinct step containers with icons
@@ -24,6 +24,26 @@ Flat vector illustration with no gradients or 3D effects. Clear, uniform-thickne
| Accent 2 | Rock Blue | #577590 | Cool balance |
| Text | Black | #1A1A1A | Text elements |
## Palette Variants
### Warm Palette
Use with `--preset warm-knowledge` or specify in prompt. For product showcases, team introductions, feature grids, brand content.
| Role | Color | Hex | Usage |
|------|-------|-----|-------|
| Background | Soft Peach | #FFECD2 | Primary background |
| Outlines | Deep Charcoal | #2D2D2D | All element outlines |
| Primary | Warm Orange | #ED8936 | Main accent color |
| Secondary | Terracotta | #C05621 | Warm depth |
| Tertiary | Golden Yellow | #F6AD55 | Highlights, energy |
| Accent | Deep Brown | #744210 | Grounding, anchoring |
| Text | Warm Charcoal | #4A4A4A | Text elements |
Warm-only palette, no cool colors (no green, blue, purple). Modern-retro feel.
Default palette includes Mint Green (#81B29A) and Rock Blue (#577590) for colorful educational content. Warm palette uses exclusively warm tones for brand/product/team content.
## Visual Elements
- All objects have closed black outlines (coloring book style)
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-danger-gemini-web-scripts",
@@ -5,8 +5,8 @@ import {
discoverRunningChromeDebugPort,
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort,
gracefulKillChrome,
getFreePort,
killChrome,
launchChrome as launchChromeBase,
openPageSession,
sleep,
@@ -241,15 +241,11 @@ async function fetch_google_cookies_via_cdp(
try {
await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 });
} catch {}
} else {
try {
await cdp.send('Browser.close', {}, { timeoutMs: 5_000 });
} catch {}
}
cdp.close();
}
if (chrome) killChrome(chrome);
if (chrome) await gracefulKillChrome(chrome, port);
}
}
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-danger-x-to-markdown-scripts",
@@ -2,8 +2,8 @@ import {
CdpConnection,
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort,
gracefulKillChrome,
getFreePort,
killChrome,
launchChrome as launchChromeBase,
openPageSession,
sleep,
@@ -122,15 +122,11 @@ async function fetchXCookiesViaCdp(
try {
await cdp.send("Target.closeTarget", { targetId }, { timeoutMs: 5_000 });
} catch {}
} else {
try {
await cdp.send("Browser.close", {}, { timeoutMs: 5_000 });
} catch {}
}
cdp.close();
}
if (chrome) killChrome(chrome);
if (chrome) await gracefulKillChrome(chrome, port);
}
}
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-markdown-to-html-scripts",
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-post-to-wechat-scripts",
+11 -3
View File
@@ -141,17 +141,25 @@ If the check fails (warnings in output), alert the user with the specific issues
---
## Post Type Selection
Unless the user explicitly specifies the post type:
- **Markdown file** (`.md`) → **Headline Article** (头条文章)
- **Plain text** / text with images → **Regular Post**
## Troubleshooting
### Chrome debug port not ready
If a script fails with `Chrome debug port not ready` or `Unable to connect`, kill existing Chrome CDP instances first, then retry:
If a script fails with `Chrome debug port not ready` or `Unable to connect`, kill only the CDP Chrome instances (those with `--remote-debugging-port` AND the baoyu-skills profile), then retry:
```bash
pkill -f "Chrome.*remote-debugging-port" 2>/dev/null; pkill -f "Chromium.*remote-debugging-port" 2>/dev/null; sleep 2
pkill -f "remote-debugging-port.*baoyu-skills/chrome-profile" 2>/dev/null; sleep 2
```
**Important**: This should be done automatically -- when encountering this error, kill Chrome CDP instances and retry the command without asking the user.
**CRITICAL**: Never kill all Chrome processes (`pkill -f "Google Chrome"`). Only kill Chrome instances launched by CDP with the baoyu-skills profile directory. The user may have regular Chrome windows open.
**Important**: This should be done automatically -- when encountering this error, kill the CDP Chrome instances and retry the command without asking the user.
## Notes
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-post-to-weibo-scripts",
+1
View File
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-post-to-x-scripts",
+32 -11
View File
@@ -7,10 +7,12 @@ import {
copyImageToClipboard,
findExistingChromeDebugPort,
getDefaultProfileDir,
gracefulKillChrome,
launchChrome,
openPageSession,
pasteFromClipboard,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
@@ -44,6 +46,8 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
else console.log(`[x-browser] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
@@ -56,9 +60,11 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableNetwork: true,
});
const { sessionId } = page;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
console.log('[x-browser] Waiting for X editor...');
await sleep(3000);
@@ -69,7 +75,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
@@ -82,6 +88,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
console.log('[x-browser] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
loggedInDuringRun = true;
}
if (text) {
@@ -94,7 +101,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(500);
}
@@ -115,7 +122,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
const imgCountBefore = await cdp.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
// Wait for clipboard to be ready
await sleep(500);
@@ -123,7 +130,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
// Focus the editor
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(200);
// Use paste script (handles platform differences, activates Chrome)
@@ -140,14 +147,14 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}, { sessionId: activeSessionId });
await cdp.send('Input.dispatchKeyEvent', {
type: 'keyUp',
key: 'v',
code: 'KeyV',
modifiers,
windowsVirtualKeyCode: 86,
}, { sessionId });
}, { sessionId: activeSessionId });
}
console.log('[x-browser] Verifying image upload...');
@@ -158,7 +165,7 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
const r = await cdp!.send<{ result: { value: number } }>('Runtime.evaluate', {
expression: `document.querySelectorAll('img[src^="blob:"]').length`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (r.result.value >= expectedImgCount) {
imgUploadOk = true;
break;
@@ -177,18 +184,32 @@ export async function postToX(options: XBrowserOptions): Promise<void> {
console.log('[x-browser] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(2000);
console.log('[x-browser] Post submitted!');
} else {
console.log('[x-browser] Post composed. Please review and click the publish button in the browser.');
}
} finally {
let leaveChromeOpen = !submit;
if (chrome && submit && loggedInDuringRun && cdp && sessionId) {
console.log('[x-browser] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-browser] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
cdp.close();
}
if (chrome) {
chrome.unref();
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}
+33 -13
View File
@@ -5,10 +5,11 @@ import {
CdpConnection,
findExistingChromeDebugPort,
getDefaultProfileDir,
killChrome,
gracefulKillChrome,
launchChrome,
openPageSession,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
@@ -49,7 +50,9 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
else console.log(`[x-quote] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let targetId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
@@ -62,8 +65,10 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
matchTarget: (target) => target.type === 'page' && target.url.includes('x.com'),
enablePage: true,
enableRuntime: true,
enableNetwork: true,
});
const { sessionId } = page;
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
targetId = page.targetId;
console.log('[x-quote] Waiting for tweet to load...');
@@ -76,7 +81,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="retweet"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
@@ -89,13 +94,14 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
console.log('[x-quote] Waiting for login...');
const loggedIn = await waitForRetweetButton();
if (!loggedIn) throw new Error('Timed out waiting for tweet. Please log in first or check the tweet URL.');
loggedInDuringRun = true;
}
// Click the retweet button
console.log('[x-quote] Clicking retweet button...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="retweet"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(1000);
// Wait for and click the "Quote" option in the menu
@@ -106,7 +112,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(200);
}
@@ -121,7 +127,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
// Click the quote option (second menu item)
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="Dropdown"] [role="menuitem"]:nth-child(2)')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(2000);
// Wait for the quote compose dialog
@@ -132,7 +138,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(200);
}
@@ -150,12 +156,12 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
// Use CDP Input.insertText for more reliable text insertion
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetTextarea_0"]')?.focus()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(200);
await cdp.send('Input.insertText', {
text: comment,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(500);
}
@@ -163,7 +169,7 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
console.log('[x-quote] Submitting quote post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(2000);
console.log('[x-quote] Quote post submitted!');
} else {
@@ -172,15 +178,29 @@ export async function quotePost(options: QuoteOptions): Promise<void> {
await sleep(30_000);
}
} finally {
let leaveChromeOpen = false;
if (chrome && loggedInDuringRun && cdp && sessionId) {
console.log('[x-quote] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-quote] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
if (reusing && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
} else if (!reusing) {
try { await cdp.send('Browser.close', {}, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
if (chrome) killChrome(chrome);
if (chrome) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}
@@ -0,0 +1,54 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
buildXSessionCookieMap,
hasChromeLockArtifacts,
hasRequiredXSessionCookies,
shouldRetryChromeLaunch,
} from "./x-utils.ts";
test("hasChromeLockArtifacts detects Chrome singleton artifacts", () => {
assert.equal(hasChromeLockArtifacts(["SingletonSocket"]), true);
assert.equal(hasChromeLockArtifacts(["chrome.pid"]), true);
assert.equal(hasChromeLockArtifacts(["Cookies", "Preferences"]), false);
});
test("shouldRetryChromeLaunch only retries when no live owner exists", () => {
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: false }),
true,
);
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: true, hasLiveOwner: true }),
false,
);
assert.equal(
shouldRetryChromeLaunch({ lockArtifactsPresent: false, hasLiveOwner: false }),
false,
);
});
test("buildXSessionCookieMap keeps only non-empty cookies", () => {
assert.deepEqual(
buildXSessionCookieMap([
{ name: "auth_token", value: "auth" },
{ name: "ct0", value: "csrf" },
{ name: "twid", value: "u=123" },
{ name: "ct0", value: "" },
{ name: "", value: "ignored" },
{ name: "gt", value: undefined },
]),
{
auth_token: "auth",
ct0: "csrf",
twid: "u=123",
},
);
});
test("hasRequiredXSessionCookies requires auth_token and ct0", () => {
assert.equal(hasRequiredXSessionCookies({ auth_token: "auth" }), false);
assert.equal(hasRequiredXSessionCookies({ ct0: "csrf" }), false);
assert.equal(hasRequiredXSessionCookies({ auth_token: "auth", ct0: "csrf" }), true);
});
+142 -8
View File
@@ -1,4 +1,5 @@
import { execSync, spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
@@ -8,6 +9,7 @@ import {
findChromeExecutable as findChromeExecutableBase,
findExistingChromeDebugPort as findExistingChromeDebugPortBase,
getFreePort as getFreePortBase,
gracefulKillChrome,
killChrome,
launchChrome as launchChromeBase,
openPageSession,
@@ -17,9 +19,21 @@ import {
type PlatformCandidates,
} from 'baoyu-chrome-cdp';
export { CdpConnection, killChrome, openPageSession, sleep, waitForChromeDebugPort };
export { CdpConnection, gracefulKillChrome, killChrome, openPageSession, sleep, waitForChromeDebugPort };
export type { PlatformCandidates } from 'baoyu-chrome-cdp';
const X_SESSION_URLS = ['https://x.com/', 'https://twitter.com/'] as const;
const REQUIRED_X_SESSION_COOKIES = ['auth_token', 'ct0'] as const;
interface CookieLike {
name?: string;
value?: string | null;
}
interface NetworkGetCookiesResult {
cookies?: CookieLike[];
}
export const CHROME_CANDIDATES_BASIC: PlatformCandidates = {
darwin: [
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
@@ -94,15 +108,105 @@ export async function findExistingChromeDebugPort(profileDir: string): Promise<n
return await findExistingChromeDebugPortBase({ profileDir });
}
export async function launchChrome(
const CHROME_LOCK_FILES = ['SingletonLock', 'SingletonSocket', 'SingletonCookie', 'chrome.pid'] as const;
export function hasChromeLockArtifacts(entries: readonly string[]): boolean {
return CHROME_LOCK_FILES.some((name) => entries.includes(name));
}
export function shouldRetryChromeLaunch(options: {
lockArtifactsPresent: boolean;
hasLiveOwner: boolean;
}): boolean {
return options.lockArtifactsPresent && !options.hasLiveOwner;
}
export function buildXSessionCookieMap(cookies: readonly CookieLike[]): Record<string, string> {
const cookieMap: Record<string, string> = {};
for (const cookie of cookies) {
const name = cookie.name?.trim();
const value = cookie.value?.trim();
if (!name || !value) {
continue;
}
cookieMap[name] = value;
}
return cookieMap;
}
export function hasRequiredXSessionCookies(cookieMap: Record<string, string>): boolean {
return REQUIRED_X_SESSION_COOKIES.every((name) => Boolean(cookieMap[name]));
}
export async function readXSessionCookieMap(
cdp: CdpConnection,
sessionId?: string,
): Promise<Record<string, string>> {
const { cookies } = await cdp.send<NetworkGetCookiesResult>(
'Network.getCookies',
{ urls: [...X_SESSION_URLS] },
{
sessionId,
timeoutMs: 5_000,
},
);
return buildXSessionCookieMap(cookies ?? []);
}
export async function waitForXSessionPersistence(options: {
cdp: CdpConnection;
sessionId?: string;
timeoutMs?: number;
pollIntervalMs?: number;
}): Promise<boolean> {
const timeoutMs = options.timeoutMs ?? 15_000;
const pollIntervalMs = options.pollIntervalMs ?? 1_000;
const start = Date.now();
while (Date.now() - start < timeoutMs) {
const cookieMap = await readXSessionCookieMap(options.cdp, options.sessionId).catch(() => ({}));
if (hasRequiredXSessionCookies(cookieMap)) {
return true;
}
await sleep(pollIntervalMs);
}
return false;
}
function cleanStaleLockFiles(profileDir: string): void {
for (const name of CHROME_LOCK_FILES) {
try { fs.unlinkSync(path.join(profileDir, name)); } catch {}
}
}
function hasLiveChromeOwner(profileDir: string): boolean {
if (process.platform === 'win32') return false;
try {
const result = spawnSync('ps', ['aux'], {
encoding: 'utf8',
timeout: 5000,
});
if (result.status !== 0 || !result.stdout) return false;
return result.stdout.split('\n').some((line) => line.includes(`--user-data-dir=${profileDir}`));
} catch {
return false;
}
}
async function listProfileDirEntries(profileDir: string): Promise<string[]> {
try {
return await fs.promises.readdir(profileDir);
} catch {
return [];
}
}
async function launchChromeOnce(
url: string,
profileDir: string,
candidates: PlatformCandidates,
chromePathOverride?: string,
chromePath: string,
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
const port = await getFreePort();
const chrome = await launchChromeBase({
chromePath,
@@ -112,7 +216,37 @@ export async function launchChrome(
extraArgs: ['--start-maximized'],
});
return { chrome, port };
try {
await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
return { chrome, port };
} catch (error) {
killChrome(chrome);
throw error;
}
}
export async function launchChrome(
url: string,
profileDir: string,
candidates: PlatformCandidates,
chromePathOverride?: string,
): Promise<{ chrome: Awaited<ReturnType<typeof launchChromeBase>>; port: number }> {
const chromePath = chromePathOverride?.trim() || findChromeExecutable(candidates);
if (!chromePath) throw new Error('Chrome not found. Set X_BROWSER_CHROME_PATH env var.');
try {
return await launchChromeOnce(url, profileDir, chromePath);
} catch (error) {
const entries = await listProfileDirEntries(profileDir);
const shouldRetry = shouldRetryChromeLaunch({
lockArtifactsPresent: hasChromeLockArtifacts(entries),
hasLiveOwner: hasLiveChromeOwner(profileDir),
});
if (!shouldRetry) throw error;
cleanStaleLockFiles(profileDir);
return await launchChromeOnce(url, profileDir, chromePath);
}
}
export function getScriptDir(): string {
+33 -11
View File
@@ -7,10 +7,11 @@ import {
CdpConnection,
findExistingChromeDebugPort,
getDefaultProfileDir,
killChrome,
gracefulKillChrome,
launchChrome,
openPageSession,
sleep,
waitForXSessionPersistence,
waitForChromeDebugPort,
} from './x-utils.js';
@@ -49,7 +50,9 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
else console.log(`[x-video] Launching Chrome (profile: ${profileDir})`);
let cdp: CdpConnection | null = null;
let sessionId: string | null = null;
let targetId: string | null = null;
let loggedInDuringRun = false;
try {
const wsUrl = await waitForChromeDebugPort(port, 30_000, { includeLastError: true });
@@ -63,10 +66,12 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
enablePage: true,
enableRuntime: true,
enableDom: true,
enableNetwork: true,
});
const { sessionId } = page;
const activeSessionId = page.sessionId;
sessionId = activeSessionId;
targetId = page.targetId;
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId });
await cdp.send('Input.setIgnoreInputEvents', { ignore: false }, { sessionId: activeSessionId });
console.log('[x-video] Waiting for X editor...');
await sleep(3000);
@@ -77,7 +82,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
const result = await cdp!.send<{ result: { value: boolean } }>('Runtime.evaluate', {
expression: `!!document.querySelector('[data-testid="tweetTextarea_0"]')`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
if (result.result.value) return true;
await sleep(1000);
}
@@ -90,16 +95,17 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Waiting for login...');
const loggedIn = await waitForEditor();
if (!loggedIn) throw new Error('Timed out waiting for X editor. Please log in first.');
loggedInDuringRun = true;
}
// Upload video FIRST (before typing text to avoid text being cleared)
console.log('[x-video] Uploading video...');
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId });
const { root } = await cdp.send<{ root: { nodeId: number } }>('DOM.getDocument', {}, { sessionId: activeSessionId });
const { nodeId } = await cdp.send<{ nodeId: number }>('DOM.querySelector', {
nodeId: root.nodeId,
selector: 'input[type="file"][accept*="video"], input[data-testid="fileInput"], input[type="file"]',
}, { sessionId });
}, { sessionId: activeSessionId });
if (!nodeId || nodeId === 0) {
throw new Error('Could not find file input for video upload.');
@@ -108,7 +114,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
await cdp.send('DOM.setFileInputFiles', {
nodeId,
files: [absVideoPath],
}, { sessionId });
}, { sessionId: activeSessionId });
console.log('[x-video] Video file set, uploading in background...');
// Wait a moment for upload to start, then type text while video processes
@@ -125,7 +131,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
document.execCommand('insertText', false, ${JSON.stringify(text)});
}
`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(500);
}
@@ -143,7 +149,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
return { hasMedia, buttonEnabled };
})()`,
returnByValue: true,
}, { sessionId });
}, { sessionId: activeSessionId });
const { hasMedia, buttonEnabled } = result.result.value;
if (hasMedia && buttonEnabled) {
@@ -171,7 +177,7 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Submitting post...');
await cdp.send('Runtime.evaluate', {
expression: `document.querySelector('[data-testid="tweetButton"]')?.click()`,
}, { sessionId });
}, { sessionId: activeSessionId });
await sleep(5000);
console.log('[x-video] Post submitted!');
} else {
@@ -179,13 +185,29 @@ export async function postVideoToX(options: XVideoOptions): Promise<void> {
console.log('[x-video] Browser stays open for review.');
}
} finally {
let leaveChromeOpen = !submit;
if (chrome && submit && loggedInDuringRun && cdp && sessionId) {
console.log('[x-video] Waiting for X session cookies to persist...');
const sessionReady = await waitForXSessionPersistence({ cdp, sessionId });
if (!sessionReady) {
console.warn('[x-video] X session cookies not observed yet. Leaving Chrome open so login can finish persisting.');
leaveChromeOpen = true;
}
}
if (cdp) {
if (reusing && submit && targetId) {
try { await cdp.send('Target.closeTarget', { targetId }, { timeoutMs: 5_000 }); } catch {}
}
cdp.close();
}
if (chrome && submit) killChrome(chrome);
if (chrome && submit) {
if (leaveChromeOpen) {
chrome.unref();
} else {
await gracefulKillChrome(chrome, port);
}
}
}
}
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "baoyu-url-to-markdown-scripts",
@@ -64,6 +64,8 @@ export interface Adapter {
name: string;
match(input: AdapterInput): boolean;
checkLogin?(context: AdapterContext): Promise<AdapterLoginInfo>;
exportCookies?(context: AdapterContext, profileDir?: string): Promise<boolean>;
restoreCookies?(context: AdapterContext, profileDir?: string): Promise<boolean>;
downloadMedia?(request: MediaDownloadRequest): Promise<MediaDownloadResult>;
process(context: AdapterContext): Promise<AdapterProcessResult>;
}
@@ -1,4 +1,5 @@
import type { Adapter, AdapterLoginInfo } from "../types";
import { exportCookies, restoreCookies, type CookieSidecarConfig } from "../../browser/cookie-sidecar";
import { detectInteractionGate } from "../../browser/interaction-gates";
import type { ExtractedDocument } from "../../extract/document";
import { collectMediaFromDocument } from "../../media/markdown-media";
@@ -10,6 +11,16 @@ import { extractSingleTweetDocumentFromPayload } from "./single";
import { extractThreadDocumentFromPayloads } from "./thread";
import { loadFullXThread } from "./thread-loader";
const cookieConfig: CookieSidecarConfig = {
urls: ["https://x.com/", "https://twitter.com/"],
filename: "x-session-cookies.json",
requiredCookieNames: ["auth_token", "ct0"],
filterCookie: (c) => {
const d = c.domain ?? "";
return d.endsWith("x.com") || d.endsWith("twitter.com");
},
};
function extractDocumentFromPayloads(
payloads: unknown[],
statusId: string,
@@ -49,6 +60,12 @@ export const xAdapter: Adapter = {
async checkLogin(context) {
return detectXLogin(context);
},
async exportCookies(context, profileDir) {
return exportCookies(context.browser.targetSession, cookieConfig, profileDir);
},
async restoreCookies(context, profileDir) {
return restoreCookies(context.browser.targetSession, cookieConfig, profileDir);
},
async process(context) {
const statusId = extractStatusId(context.input.url);
if (!statusId) {
@@ -0,0 +1,47 @@
import type { AdapterContext } from "../types";
const X_SESSION_URLS = ["https://x.com/", "https://twitter.com/"] as const;
const REQUIRED_X_SESSION_COOKIES = ["auth_token", "ct0"] as const;
interface CookieLike {
name?: string;
value?: string | null;
}
interface NetworkGetCookiesResult {
cookies?: CookieLike[];
}
export function buildXSessionCookieMap(cookies: readonly CookieLike[]): Record<string, string> {
const cookieMap: Record<string, string> = {};
for (const cookie of cookies) {
const name = cookie.name?.trim();
const value = cookie.value?.trim();
if (!name || !value) {
continue;
}
cookieMap[name] = value;
}
return cookieMap;
}
export function hasRequiredXSessionCookies(cookieMap: Record<string, string>): boolean {
return REQUIRED_X_SESSION_COOKIES.every((name) => Boolean(cookieMap[name]));
}
export async function readXSessionCookieMap(
context: Pick<AdapterContext, "browser">,
): Promise<Record<string, string>> {
const { cookies } = await context.browser.targetSession.send<NetworkGetCookiesResult>(
"Network.getCookies",
{ urls: [...X_SESSION_URLS] },
);
return buildXSessionCookieMap(cookies ?? []);
}
export async function isXSessionReady(
context: Pick<AdapterContext, "browser">,
): Promise<boolean> {
const cookieMap = await readXSessionCookieMap(context);
return hasRequiredXSessionCookies(cookieMap);
}
@@ -1,6 +1,16 @@
import { launch, type LaunchedChrome } from "chrome-launcher";
import WebSocket from "ws";
import type { Logger } from "../utils/logger";
import { ensureChromeProfileDir, findExistingChromeDebugPort, resolveChromeProfileDir } from "./profile";
import {
cleanChromeLockArtifacts,
ensureChromeProfileDir,
findChromeProcessUsingProfile,
findExistingChromeDebugPort,
hasChromeLockArtifacts,
listChromeProfileEntries,
resolveChromeProfileDir,
shouldRetryChromeLaunchRecovery,
} from "./profile";
interface ChromeVersionResponse {
webSocketDebuggerUrl: string;
@@ -65,6 +75,80 @@ async function tryReuseChrome(profileDir: string, logger?: Logger): Promise<Chro
return null;
}
async function launchFreshChrome(
profileDir: string,
options: Pick<ChromeConnectOptions, "browserPath" | "headless">,
): Promise<ChromeConnection> {
let launchedChrome: LaunchedChrome | null = null;
try {
launchedChrome = await launch({
chromePath: options.browserPath,
userDataDir: profileDir,
chromeFlags: [
"--disable-background-networking",
"--disable-default-apps",
"--disable-popup-blocking",
"--disable-sync",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
...(!options.headless ? ["--no-startup-window"] : []),
...(options.headless ? ["--headless=new"] : []),
],
});
const origin = `http://127.0.0.1:${launchedChrome.port}`;
const version = await fetchJson<ChromeVersionResponse>(`${origin}/json/version`);
const chrome = launchedChrome;
return {
browserWsUrl: version.webSocketDebuggerUrl,
origin,
port: launchedChrome.port,
profileDir,
launched: true,
async close() {
if (!chrome) return;
await gracefulCloseChrome(chrome, origin);
},
};
} catch (error) {
launchedChrome?.kill();
throw error;
}
}
async function gracefulCloseChrome(chrome: LaunchedChrome, origin: string): Promise<void> {
try {
const resp = await fetch(`${origin}/json/version`);
const { webSocketDebuggerUrl } = (await resp.json()) as ChromeVersionResponse;
if (webSocketDebuggerUrl) {
const ws = await new Promise<WebSocket>((resolve, reject) => {
const socket = new WebSocket(webSocketDebuggerUrl);
socket.once("open", () => resolve(socket));
socket.once("error", reject);
});
const id = 1;
ws.send(JSON.stringify({ id, method: "Browser.close" }));
await new Promise<void>((resolve) => {
const timer = setTimeout(() => { ws.close(); resolve(); }, 5_000);
ws.once("close", () => { clearTimeout(timer); resolve(); });
});
const exited = await new Promise<boolean>((resolve) => {
if (chrome.pid && !isProcessAlive(chrome.pid)) { resolve(true); return; }
const timer = setTimeout(() => resolve(false), 3_000);
chrome.process.once("exit", () => { clearTimeout(timer); resolve(true); });
});
if (exited) return;
}
} catch {}
chrome.kill();
}
function isProcessAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch { return false; }
}
export async function connectChrome(options: ChromeConnectOptions): Promise<ChromeConnection> {
if (options.cdpUrl) {
if (options.cdpUrl.startsWith("ws://") || options.cdpUrl.startsWith("wss://")) {
@@ -84,34 +168,20 @@ export async function connectChrome(options: ChromeConnectOptions): Promise<Chro
}
options.logger?.warn(`No running Chrome debugger found for profile ${profileDir}. Launching Chrome with that profile.`);
try {
return await launchFreshChrome(profileDir, options);
} catch (error) {
const entries = await listChromeProfileEntries(profileDir);
const shouldRetry = shouldRetryChromeLaunchRecovery({
hasLockArtifacts: hasChromeLockArtifacts(entries),
hasLiveOwner: findChromeProcessUsingProfile(profileDir),
});
if (!shouldRetry) {
throw error;
}
const launchedChrome: LaunchedChrome = await launch({
chromePath: options.browserPath,
userDataDir: profileDir,
chromeFlags: [
"--disable-background-networking",
"--disable-default-apps",
"--disable-popup-blocking",
"--disable-sync",
"--no-first-run",
"--no-default-browser-check",
"--remote-allow-origins=*",
...(!options.headless ? ["--no-startup-window"] : []),
...(options.headless ? ["--headless=new"] : []),
],
});
const origin = `http://127.0.0.1:${launchedChrome.port}`;
const version = await fetchJson<ChromeVersionResponse>(`${origin}/json/version`);
return {
browserWsUrl: version.webSocketDebuggerUrl,
origin,
port: launchedChrome.port,
profileDir,
launched: true,
async close() {
launchedChrome.kill();
},
};
options.logger?.warn(`Chrome launch failed with stale profile locks. Cleaning ${profileDir} and retrying once.`);
cleanChromeLockArtifacts(profileDir);
return await launchFreshChrome(profileDir, options);
}
}
@@ -0,0 +1,100 @@
import { readFile, writeFile, mkdir } from "node:fs/promises";
import { dirname, join } from "node:path";
import { resolveChromeProfileDir } from "./profile";
import type { TargetSession } from "./cdp-client";
export interface CdpCookie {
name: string;
value: string;
domain: string;
path: string;
expires: number;
size: number;
httpOnly: boolean;
secure: boolean;
session: boolean;
sameSite?: string;
priority?: string;
sameParty?: boolean;
sourceScheme?: string;
sourcePort?: number;
partitionKey?: string;
}
interface SidecarData {
savedAt: string;
cookies: CdpCookie[];
}
export interface CookieSidecarConfig {
urls: readonly string[];
filename: string;
requiredCookieNames: readonly string[];
filterCookie?: (cookie: CdpCookie) => boolean;
}
function sidecarPath(filename: string, profileDir?: string): string {
return join(resolveChromeProfileDir(profileDir), filename);
}
function hasRequired(cookies: CdpCookie[], names: readonly string[]): boolean {
return names.every((name) =>
cookies.some((c) => c.name === name && Boolean(c.value)),
);
}
async function getCookies(session: TargetSession, urls: readonly string[]): Promise<CdpCookie[]> {
const { cookies } = await session.send<{ cookies: CdpCookie[] }>(
"Network.getCookies",
{ urls: [...urls] },
);
return cookies ?? [];
}
export async function exportCookies(
session: TargetSession,
config: CookieSidecarConfig,
profileDir?: string,
): Promise<boolean> {
const all = await getCookies(session, config.urls);
const filtered = config.filterCookie ? all.filter(config.filterCookie) : all;
if (!hasRequired(filtered, config.requiredCookieNames)) return false;
const filePath = sidecarPath(config.filename, profileDir);
await mkdir(dirname(filePath), { recursive: true });
const data: SidecarData = { savedAt: new Date().toISOString(), cookies: filtered };
await writeFile(filePath, JSON.stringify(data, null, 2));
return true;
}
export async function restoreCookies(
session: TargetSession,
config: CookieSidecarConfig,
profileDir?: string,
): Promise<boolean> {
const live = await getCookies(session, config.urls);
if (hasRequired(live, config.requiredCookieNames)) return false;
const filePath = sidecarPath(config.filename, profileDir);
const raw = await readFile(filePath, "utf8");
const data = JSON.parse(raw) as SidecarData;
if (!data.cookies?.length) return false;
const now = Date.now() / 1000;
const valid = data.cookies.filter((c) => c.session || !c.expires || c.expires > now);
if (!hasRequired(valid, config.requiredCookieNames)) return false;
await session.send("Network.setCookies", {
cookies: valid.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path,
httpOnly: c.httpOnly,
secure: c.secure,
sameSite: c.sameSite,
expires: c.expires,
})),
});
return true;
}
@@ -19,6 +19,8 @@ interface ChromeVersionResponse {
webSocketDebuggerUrl?: string;
}
const CHROME_LOCK_FILE_NAMES = ["SingletonLock", "SingletonSocket", "SingletonCookie", "chrome.pid"] as const;
function resolveDataBaseDir(): string {
if (process.platform === "darwin") {
return path.join(os.homedir(), "Library", "Application Support");
@@ -61,6 +63,57 @@ export function ensureChromeProfileDir(profileDir: string): string {
return profileDir;
}
export function hasChromeLockArtifacts(entries: readonly string[]): boolean {
return CHROME_LOCK_FILE_NAMES.some((name) => entries.includes(name));
}
export function shouldRetryChromeLaunchRecovery(options: {
hasLockArtifacts: boolean;
hasLiveOwner: boolean;
}): boolean {
return options.hasLockArtifacts && !options.hasLiveOwner;
}
export function findChromeProcessUsingProfile(profileDir: string): boolean {
if (process.platform === "win32") {
return false;
}
try {
const result = spawnSync("ps", ["aux"], {
encoding: "utf8",
timeout: 5_000,
});
if (result.status !== 0 || !result.stdout) {
return false;
}
return result.stdout
.split("\n")
.some((line) => line.includes(`--user-data-dir=${profileDir}`));
} catch {
return false;
}
}
export function cleanChromeLockArtifacts(profileDir: string): void {
for (const name of CHROME_LOCK_FILE_NAMES) {
try {
fs.unlinkSync(path.join(profileDir, name));
} catch {
// Ignore missing files and continue cleaning the remaining artifacts.
}
}
}
export async function listChromeProfileEntries(profileDir: string): Promise<string[]> {
try {
return await fs.promises.readdir(profileDir);
} catch {
return [];
}
}
async function fetchWithTimeout(url: string, timeoutMs = 3_000): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -7,6 +7,7 @@ import { detectInteractionGate } from "../browser/interaction-gates";
import { NetworkJournal } from "../browser/network-journal";
import { BrowserSession } from "../browser/session";
import { genericAdapter, resolveAdapter } from "../adapters";
import { isXSessionReady } from "../adapters/x/session";
import type { ExtractedDocument } from "../extract/document";
import { renderMarkdown } from "../extract/markdown-renderer";
import { downloadMediaAssets } from "../media/default-downloader";
@@ -55,6 +56,7 @@ interface ForceWaitSnapshot {
url: string;
hasGate: boolean;
loginState: LoginState | "unavailable";
sessionReady: boolean;
}
interface SuccessfulConvertOutput {
@@ -78,6 +80,17 @@ function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function isForceWaitSessionReady(snapshot: ForceWaitSnapshot): boolean {
return snapshot.sessionReady;
}
export function shouldKeepBrowserOpenAfterInteraction(options: {
launched: boolean;
interaction: Pick<WaitForInteractionRequest, "kind" | "provider">;
}): boolean {
return options.launched && options.interaction.kind === "login" && options.interaction.provider === "x";
}
export function shouldAutoContinueForceWait(
initial: ForceWaitSnapshot,
current: ForceWaitSnapshot,
@@ -86,15 +99,20 @@ export function shouldAutoContinueForceWait(
return true;
}
if (initial.loginState === "logged_out" && current.loginState !== "logged_out") {
if (initial.loginState === "logged_out" && current.loginState !== "logged_out" && isForceWaitSessionReady(current)) {
return true;
}
if (initial.loginState !== "logged_in" && current.loginState === "logged_in") {
if (initial.loginState !== "logged_in" && current.loginState === "logged_in" && isForceWaitSessionReady(current)) {
return true;
}
if (current.url !== initial.url && !current.hasGate && current.loginState !== "logged_out") {
if (
current.url !== initial.url &&
!current.hasGate &&
current.loginState !== "logged_out" &&
isForceWaitSessionReady(current)
) {
return true;
}
@@ -173,6 +191,16 @@ async function closeRuntime(runtime: RuntimeResources | null | undefined): Promi
await runtime.chrome.close().catch(() => {});
}
async function isInteractionSessionReady(
context: AdapterContext,
interaction: WaitForInteractionRequest,
): Promise<boolean> {
if (interaction.provider !== "x") {
return true;
}
return await isXSessionReady(context).catch(() => false);
}
async function reopenInteractiveRuntime(
runtime: RuntimeResources,
options: ConvertCommandOptions,
@@ -203,6 +231,7 @@ async function captureForceWaitSnapshot(
url,
hasGate: Boolean(gate),
loginState: login?.state ?? "unavailable",
sessionReady: adapter.name === "x" ? await isXSessionReady(context).catch(() => false) : true,
};
}
@@ -280,7 +309,7 @@ async function waitForInteraction(
while (Date.now() - startedAt < timeoutMs) {
if (interaction.kind === "login" && adapter.checkLogin) {
lastLogin = await adapter.checkLogin(context);
if (lastLogin.state === "logged_in") {
if (lastLogin.state === "logged_in" && await isInteractionSessionReady(context, interaction)) {
return lastLogin;
}
}
@@ -303,7 +332,7 @@ async function waitForInteraction(
}
lastLogin = await adapter.checkLogin(context);
if (lastLogin.state !== "logged_out") {
if (lastLogin.state !== "logged_out" && await isInteractionSessionReady(context, interaction)) {
return lastLogin;
}
}
@@ -347,10 +376,13 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
const url = normalizeUrl(options.url);
let runtime = await openRuntime(options, options.waitMode !== "none", Boolean(options.debugDir));
const logger = createLogger(Boolean(options.debugDir));
let didLogin = false;
let adapter: Adapter | null = null;
let context: AdapterContext | null = null;
try {
const adapter = resolveAdapter({ url }, options.adapter);
let context: AdapterContext = {
adapter = resolveAdapter({ url }, options.adapter);
context = {
input: { url },
browser: runtime.browser,
network: runtime.network,
@@ -362,6 +394,11 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
downloadMedia: options.downloadMedia,
};
if (adapter.restoreCookies) {
const restored = await adapter.restoreCookies(context, runtime.chrome.profileDir).catch(() => false);
if (restored) logger.info(`Restored ${adapter.name} session cookies from sidecar.`);
}
if (options.waitMode === "force") {
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
await waitForForceResume(adapter, context, options);
@@ -414,6 +451,9 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
};
await context.browser.goto(url.toString(), options.timeoutMs).catch(() => {});
if (result.interaction.kind === "login") {
didLogin = true;
}
await waitForInteraction(adapter, context, result.interaction, options);
result = await adapter.process(context);
@@ -516,6 +556,9 @@ export async function runConvertCommand(options: ConvertCommandOptions): Promise
printOutput(markdown);
} finally {
if (adapter?.exportCookies && context) {
await adapter.exportCookies(context, runtime.chrome.profileDir).catch(() => {});
}
await closeRuntime(runtime);
}
}