SSH port (default: 22, implies --remote)
--remote-identity-file SSH private key path (implies --remote)
--remote-known-hosts-file
SSH known_hosts file path (implies --remote)
--remote-strict-host-key-checking (implies --remote)
--remote-connect-timeout SSH ConnectTimeout (implies --remote)
--remote-proxy-jump SSH ProxyJump value (implies --remote)
--help Show this help
Frontmatter Fields (markdown):
title Article title
author Author name
digest/summary Article summary
sourceUrl/contentSourceUrl/content_source_url Original article URL
coverImage/featureImage/cover/image Cover image path
Comments:
Comments are enabled by default, open to all users.
Environment Variables:
WECHAT_APP_ID WeChat App ID
WECHAT_APP_SECRET WeChat App Secret
Config File Locations (in priority order):
1. Environment variables
2. /.baoyu-skills/.env
3. ~/.baoyu-skills/.env
Example:
npx -y bun wechat-api.ts article.md
npx -y bun wechat-api.ts article.md --theme grace --cover cover.png
npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro" --source-url "https://example.com/original"
npx -y bun wechat-api.ts article.html --title "My Article"
npx -y bun wechat-api.ts images/ --type newspic --title "Photo Album"
npx -y bun wechat-api.ts article.md --dry-run
npx -y bun wechat-api.ts article.md --no-cite
`);
process.exit(0);
}
interface CliArgs {
filePath: string;
isHtml: boolean;
articleType: ArticleType;
title?: string;
author?: string;
summary?: string;
sourceUrl?: string;
theme: string;
color?: string;
cover?: string;
account?: string;
citeStatus: boolean;
dryRun: boolean;
remote: boolean;
remoteHost?: string;
remoteUser?: string;
remotePort?: number;
remoteIdentityFile?: string;
remoteKnownHostsFile?: string;
remoteStrictHostKeyChecking?: StrictHostKeyChecking;
remoteConnectTimeout?: number;
remoteProxyJump?: string;
}
function parseArgs(argv: string[]): CliArgs {
if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
printUsage();
}
const args: CliArgs = {
filePath: "",
isHtml: false,
articleType: "news",
theme: "default",
citeStatus: true,
dryRun: false,
remote: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]!;
if (arg === "--type" && argv[i + 1]) {
const t = argv[++i]!.toLowerCase();
if (t === "news" || t === "newspic") {
args.articleType = t;
}
} else if (arg === "--title" && argv[i + 1]) {
args.title = argv[++i];
} else if (arg === "--author" && argv[i + 1]) {
args.author = argv[++i];
} else if (arg === "--summary" && argv[i + 1]) {
args.summary = argv[++i];
} else if (arg === "--source-url" && argv[i + 1]) {
args.sourceUrl = argv[++i];
} else if (arg === "--theme" && argv[i + 1]) {
args.theme = argv[++i]!;
} else if (arg === "--color" && argv[i + 1]) {
args.color = argv[++i];
} else if (arg === "--cover" && argv[i + 1]) {
args.cover = argv[++i];
} else if (arg === "--account" && argv[i + 1]) {
args.account = argv[++i];
} else if (arg === "--cite") {
args.citeStatus = true;
} else if (arg === "--no-cite") {
args.citeStatus = false;
} else if (arg === "--dry-run") {
args.dryRun = true;
} else if (arg === "--remote") {
args.remote = true;
} else if (arg === "--remote-host" && argv[i + 1]) {
args.remoteHost = argv[++i];
args.remote = true;
} else if (arg === "--remote-user" && argv[i + 1]) {
args.remoteUser = argv[++i];
args.remote = true;
} else if (arg === "--remote-port" && argv[i + 1]) {
const n = Number.parseInt(argv[++i]!, 10);
if (!Number.isInteger(n) || n < 1 || n > 65535) {
console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`);
process.exit(1);
}
args.remotePort = n;
args.remote = true;
} else if (arg === "--remote-identity-file" && argv[i + 1]) {
args.remoteIdentityFile = argv[++i];
args.remote = true;
} else if (arg === "--remote-known-hosts-file" && argv[i + 1]) {
args.remoteKnownHostsFile = argv[++i];
args.remote = true;
} else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) {
const v = argv[++i]!.toLowerCase();
if (v !== "yes" && v !== "no" && v !== "accept-new") {
console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`);
process.exit(1);
}
args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking;
args.remote = true;
} else if (arg === "--remote-connect-timeout" && argv[i + 1]) {
const n = Number.parseInt(argv[++i]!, 10);
if (!Number.isInteger(n) || n <= 0) {
console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`);
process.exit(1);
}
args.remoteConnectTimeout = n;
args.remote = true;
} else if (arg === "--remote-proxy-jump" && argv[i + 1]) {
args.remoteProxyJump = argv[++i];
args.remote = true;
} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {
i++;
} else if (!arg.startsWith("-")) {
args.filePath = arg;
}
}
if (!args.filePath) {
console.error("Error: File path required");
process.exit(1);
}
args.isHtml = args.filePath.toLowerCase().endsWith(".html");
return args;
}
function extractHtmlTitle(html: string): string {
const titleMatch = html.match(/([^<]+)<\/title>/i);
if (titleMatch) return titleMatch[1]!;
const h1Match = html.match(/]*>([^<]+)<\/h1>/i);
if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim();
return "";
}
function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig {
const host = args.remoteHost ?? resolved.remote_publish_host;
if (!host) {
throw new Error(
"Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " +
"or an account-level remote_publish_host.",
);
}
return {
host,
user: args.remoteUser ?? resolved.remote_publish_user,
port: args.remotePort ?? resolved.remote_publish_port,
identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file,
knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file,
strictHostKeyChecking:
args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking,
connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout,
proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump,
};
}
async function main(): Promise {
const args = parseArgs(process.argv.slice(2));
const filePath = path.resolve(args.filePath);
if (!fs.existsSync(filePath)) {
console.error(`Error: File not found: ${filePath}`);
process.exit(1);
}
const baseDir = path.dirname(filePath);
let title = args.title || "";
let author = args.author || "";
let digest = args.summary || "";
let sourceUrl = args.sourceUrl || "";
let htmlPath: string;
let htmlContent: string;
let frontmatter: Record = {};
let contentImages: ImageInfo[] = [];
if (args.isHtml) {
htmlPath = filePath;
htmlContent = extractHtmlContent(htmlPath);
const mdPath = filePath.replace(/\.html$/i, ".md");
if (fs.existsSync(mdPath)) {
const mdContent = fs.readFileSync(mdPath, "utf-8");
const parsed = parseFrontmatter(mdContent);
frontmatter = parsed.frontmatter;
if (!title && frontmatter.title) title = frontmatter.title;
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
if (!sourceUrl) sourceUrl = frontmatter.sourceUrl || frontmatter.contentSourceUrl || frontmatter.content_source_url || "";
}
if (!title) {
title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));
}
console.error(`[wechat-api] Using HTML file: ${htmlPath}`);
} else {
const content = fs.readFileSync(filePath, "utf-8");
const parsed = parseFrontmatter(content);
frontmatter = parsed.frontmatter;
const body = parsed.body;
title = title || frontmatter.title || "";
if (!title) {
const h1Match = body.match(/^#\s+(.+)$/m);
if (h1Match) title = h1Match[1]!;
}
if (!author) author = frontmatter.author || "";
if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";
if (!sourceUrl) sourceUrl = frontmatter.sourceUrl || frontmatter.contentSourceUrl || frontmatter.content_source_url || "";
console.error(`[wechat-api] Theme: ${args.theme}${args.color ? `, color: ${args.color}` : ""}, citeStatus: ${args.citeStatus}`);
const rendered = renderMarkdownWithPlaceholders(filePath, args.theme, args.color, args.citeStatus, args.title);
htmlPath = rendered.htmlPath;
contentImages = rendered.contentImages;
if (!title) title = rendered.title;
if (!author) author = rendered.author;
if (!digest) digest = rendered.summary;
console.error(`[wechat-api] HTML generated: ${htmlPath}`);
console.error(`[wechat-api] Placeholder images: ${contentImages.length}`);
htmlContent = extractHtmlContent(htmlPath);
}
if (!title) {
console.error("Error: No title found. Provide via --title, frontmatter, or tag.");
process.exit(1);
}
if (digest && digest.length > 120) {
const truncated = digest.slice(0, 117);
const lastPunct = Math.max(truncated.lastIndexOf("。"), truncated.lastIndexOf(","), truncated.lastIndexOf(";"), truncated.lastIndexOf("、"));
digest = lastPunct > 80 ? truncated.slice(0, lastPunct + 1) : truncated + "...";
console.error(`[wechat-api] Digest truncated to ${digest.length} chars`);
}
console.error(`[wechat-api] Title: ${title}`);
if (author) console.error(`[wechat-api] Author: ${author}`);
if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);
if (sourceUrl) console.error(`[wechat-api] Source URL: ${sourceUrl}`);
console.error(`[wechat-api] Type: ${args.articleType}`);
const extConfig = loadWechatExtendConfig();
const resolved = resolveAccount(extConfig, args.account);
if (resolved.name) console.error(`[wechat-api] Account: ${resolved.name} (${resolved.alias})`);
if (!author && resolved.default_author) author = resolved.default_author;
if (args.dryRun) {
console.log(JSON.stringify({
articleType: args.articleType,
title,
author: author || undefined,
digest: digest || undefined,
sourceUrl: sourceUrl || undefined,
htmlPath,
contentLength: htmlContent.length,
placeholderImageCount: contentImages.length || undefined,
account: resolved.alias || undefined,
}, null, 2));
return;
}
const creds = loadCredentials(resolved);
for (const skippedSource of creds.skippedSources) {
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
}
console.error(`[wechat-api] Credentials source: ${creds.source}`);
const rawCoverPath = args.cover ||
frontmatter.coverImage ||
frontmatter.featureImage ||
frontmatter.cover ||
frontmatter.image;
const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover
? path.resolve(process.cwd(), rawCoverPath)
: rawCoverPath;
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
const method = useRemote ? "remote-api" : "api";
const publishWith = async (client: WechatClient): Promise => {
console.error("[wechat-api] Fetching access token...");
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);
console.error("[wechat-api] Uploading body images...");
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
htmlContent,
accessToken,
baseDir,
contentImages,
args.articleType,
needNewsCoverFallback,
client,
);
htmlContent = processedHtml;
let thumbMediaId = "";
if (coverPath) {
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);
thumbMediaId = coverResp.media_id;
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
} else if (firstCoverMediaId && args.articleType === "news") {
thumbMediaId = firstCoverMediaId;
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
}
if (args.articleType === "news" && !thumbMediaId) {
throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
}
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
throw new Error("newspic requires at least one image in content.");
}
console.error("[wechat-api] Publishing to draft...");
const result = await publishToDraft({
title,
author: author || undefined,
digest: digest || undefined,
content: htmlContent,
thumbMediaId,
articleType: args.articleType,
contentSourceUrl: sourceUrl || undefined,
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
needOpenComment: resolved.need_open_comment,
onlyFansCanComment: resolved.only_fans_can_comment,
}, accessToken, client);
console.log(JSON.stringify({
success: true,
media_id: result.media_id,
title,
articleType: args.articleType,
method,
}, null, 2));
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
};
if (useRemote) {
const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved));
console.error(
`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,
);
await withSshTunnel(remoteConfig, async (client) => {
await publishWith(client);
});
} else {
await publishWith(wechatHttp);
}
}
await main().catch((err) => {
console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);
process.exit(1);
});