Loading source
Pulling the file list, source metadata, and syntax-aware rendering for this listing.
Source from repo
Post articles and image-text content to WeChat Official Account via API or Chrome CDP, with markdown-to-WeChat HTML conversion.
Files
Skill
Size
Entrypoint
Format
Open file
Syntax-highlighted preview of this file as included in the skill package.
scripts/wechat-api.ts
1import fs from "node:fs";2import path from "node:path";3import { spawnSync } from "node:child_process";4import { fileURLToPath } from "node:url";5import {6loadWechatExtendConfig,7resolveAccount,8loadCredentials,9type ResolvedAccount,10type StrictHostKeyChecking,11} from "./wechat-extend-config.ts";12import {13type WechatUploadAsset,14prepareWechatBodyImageUpload,15needsWechatBodyImageProcessing,16} from "./wechat-image-processor.ts";17import { loadUploadAsset } from "./wechat-image-loader.ts";18import { wechatHttp, buildMultipart, type WechatClient } from "./wechat-http.ts";19import {20type RemotePublishConfig,21normalizeRemoteConfig,22withSshTunnel,23} from "./wechat-remote-publish.ts";2425interface AccessTokenResponse {26access_token?: string;27errcode?: number;28errmsg?: string;29}3031interface UploadResponse {32media_id: string;33url: string;34errcode?: number;35errmsg?: string;36}3738interface PublishResponse {39media_id?: string;40errcode?: number;41errmsg?: string;42}4344interface ImageInfo {45placeholder: string;46localPath: string;47originalPath: string;48}4950interface MarkdownRenderResult {51title: string;52author: string;53summary: string;54htmlPath: string;55contentImages: ImageInfo[];56}5758type ArticleType = "news" | "newspic";5960interface ArticleOptions {61title: string;62author?: string;63digest?: string;64content: string;65thumbMediaId: string;66articleType: ArticleType;67contentSourceUrl?: string;68imageMediaIds?: string[];69needOpenComment?: number;70onlyFansCanComment?: number;71}7273const TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token";74const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";75const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";76const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";7778async function fetchAccessToken(79appId: string,80appSecret: string,81client: WechatClient = wechatHttp,82): Promise<string> {83const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;84const res = await client(url);85if (res.status < 200 || res.status >= 300) {86throw new Error(`Failed to fetch access token: ${res.status}`);87}88const data = await res.json<AccessTokenResponse>();89if (data.errcode) {90throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);91}92if (!data.access_token) {93throw new Error("No access_token in response");94}95return data.access_token;96}9798function toHttpsUrl(url: string | undefined): string {99if (!url) return "";100return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;101}102103function htmlToPlainText(html: string): string {104if (!html) return "";105106let text = html;107108// 1. 将 <br>, <br/>, <br /> 替换为换行符109text = text.replace(/<br\s*\/?>/gi, "\n");110111// 2. 将 </p>, </div>, </h1>, </h2>, </h3>, </h4>, </h5>, </h6>, </li> 替换为换行符112text = text.replace(/<\/(?:p|div|h[1-6]|li|tr|td|th)>/gi, "\n");113114// 3. 去掉所有剩余的 HTML 标签115text = text.replace(/<[^>]+>/g, "");116117// 4. 解码 HTML 实体118const entityMap: Record<string, string> = {119" ": " ",120"<": "<",121">": ">",122"&": "&",123""": '"',124"'": "'",125"'": "'",126"—": "—",127"–": "–",128"…": "…",129"“": "“",130"”": "”",131"‘": "‘",132"’": "’",133};134text = text.replace(/&(?:[a-zA-Z]+|#x[0-9a-fA-F]+|#\d+);/g, (entity) => {135if (entityMap[entity]) return entityMap[entity];136const hexMatch = entity.match(/^&#x([0-9a-fA-F]+);$/);137if (hexMatch) {138return String.fromCodePoint(Number.parseInt(hexMatch[1]!, 16));139}140const numMatch = entity.match(/^&#(\d+);$/);141if (numMatch) {142return String.fromCodePoint(Number.parseInt(numMatch[1]!, 10));143}144return entity;145});146147// 5. 合并多个连续空白字符(空格、制表符、换行)为一个空格148text = text.replace(/[ \t]+/g, " ");149150// 6. 合并多个连续换行为一个换行151text = text.replace(/\n{3,}/g, "\n\n");152153// 7. 去掉行首行尾空白154text = text.split("\n").map(line => line.trim()).join("\n");155156// 8. 最终 trim157return text.trim();158}159160async function uploadImage(161imagePath: string,162accessToken: string,163baseDir?: string,164uploadType: "body" | "material" = "body",165client: WechatClient = wechatHttp,166): Promise<UploadResponse> {167const asset = await loadUploadAsset(imagePath, baseDir);168let uploadAsset = asset;169170if (uploadType === "body" && needsWechatBodyImageProcessing(asset)) {171const prepared = await prepareWechatBodyImageUpload(asset);172uploadAsset = {173...asset,174buffer: prepared.buffer,175filename: prepared.filename,176contentType: prepared.contentType,177fileExt: path.extname(prepared.filename).toLowerCase(),178fileSize: prepared.buffer.length,179};180const note = prepared.processingNotes.join(", ");181console.error(`[wechat-api] Processed ${asset.filename} for body upload: ${note}`);182}183184const result = await uploadToWechat(185uploadAsset.buffer,186uploadAsset.filename,187uploadAsset.contentType,188accessToken,189uploadType,190client,191);192193// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id194if (uploadType === "body") {195return {196url: toHttpsUrl(result.url),197media_id: "",198} as UploadResponse;199} else {200result.url = toHttpsUrl(result.url);201return result;202}203}204205async function uploadToWechat(206fileBuffer: Buffer,207filename: string,208contentType: string,209accessToken: string,210uploadType: "body" | "material",211client: WechatClient = wechatHttp,212): Promise<UploadResponse> {213const multipart = buildMultipart([214{ name: "media", filename, contentType, data: fileBuffer },215]);216217const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;218const url = `${uploadUrl}?type=image&access_token=${accessToken}`;219const res = await client(url, {220method: "POST",221headers: { "Content-Type": multipart.contentType },222body: multipart.body,223});224225const data = await res.json<UploadResponse>();226if (data.errcode && data.errcode !== 0) {227throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);228}229230return data;231}232233async function uploadImagesInHtml(234html: string,235accessToken: string,236baseDir: string,237contentImages: ImageInfo[] = [],238articleType: ArticleType = "news",239collectNewsCoverFallback: boolean = false,240client: WechatClient = wechatHttp,241): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {242const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;243const matches = [...html.matchAll(imgRegex)];244245if (matches.length === 0 && contentImages.length === 0) {246return { html, firstCoverMediaId: "", imageMediaIds: [] };247}248249let firstCoverMediaId = "";250let updatedHtml = html;251const imageMediaIds: string[] = [];252const uploadedBySource = new Map<string, UploadResponse>();253254for (const match of matches) {255const [fullTag, src] = match;256if (!src) continue;257258if (src.startsWith("https://mmbiz.qpic.cn")) {259if (collectNewsCoverFallback && !firstCoverMediaId) {260try {261const coverResp = await uploadImage(src, accessToken, baseDir, "material", client);262firstCoverMediaId = coverResp.media_id;263} catch (err) {264console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);265}266}267continue;268}269270const localPathMatch = fullTag.match(/data-local-path=["']([^"']+)["']/);271const imagePath = localPathMatch ? localPathMatch[1]! : src;272273console.error(`[wechat-api] Uploading body image: ${imagePath}`);274try {275let resp = uploadedBySource.get(imagePath);276if (!resp) {277resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);278uploadedBySource.set(imagePath, resp);279}280const newTag = fullTag281.replace(/\ssrc=["'][^"']+["']/, ` src="${resp.url}"`)282.replace(/\sdata-local-path=["'][^"']+["']/, "");283updatedHtml = updatedHtml.replace(fullTag, newTag);284const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);285if (shouldUploadMaterial) {286let materialResp = uploadedBySource.get(`${imagePath}:material`);287if (!materialResp) {288materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);289uploadedBySource.set(`${imagePath}:material`, materialResp);290}291if (articleType === "newspic" && materialResp.media_id) {292imageMediaIds.push(materialResp.media_id);293}294if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {295firstCoverMediaId = materialResp.media_id;296}297}298} catch (err) {299console.error(`[wechat-api] Failed to upload ${imagePath}:`, err);300}301}302303for (const image of contentImages) {304if (!updatedHtml.includes(image.placeholder)) continue;305306const imagePath = image.localPath || image.originalPath;307console.error(`[wechat-api] Uploading body image: ${imagePath}`);308309try {310let resp = uploadedBySource.get(imagePath);311if (!resp) {312resp = await uploadImage(imagePath, accessToken, baseDir, "body", client);313uploadedBySource.set(imagePath, resp);314}315316const replacementTag = `<img src="${resp.url}" style="display: block; width: 100%; margin: 1.5em auto;">`;317updatedHtml = replaceAllPlaceholders(updatedHtml, image.placeholder, replacementTag);318const shouldUploadMaterial = articleType === "newspic" || (collectNewsCoverFallback && !firstCoverMediaId);319if (shouldUploadMaterial) {320let materialResp = uploadedBySource.get(`${imagePath}:material`);321if (!materialResp) {322materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", client);323uploadedBySource.set(`${imagePath}:material`, materialResp);324}325if (articleType === "newspic" && materialResp.media_id) {326imageMediaIds.push(materialResp.media_id);327}328if (collectNewsCoverFallback && !firstCoverMediaId && materialResp.media_id) {329firstCoverMediaId = materialResp.media_id;330}331}332} catch (err) {333console.error(`[wechat-api] Failed to upload placeholder ${image.placeholder}:`, err);334}335}336337return { html: updatedHtml, firstCoverMediaId, imageMediaIds };338}339340async function publishToDraft(341options: ArticleOptions,342accessToken: string,343client: WechatClient = wechatHttp,344): Promise<PublishResponse> {345const url = `${DRAFT_URL}?access_token=${accessToken}`;346347let article: Record<string, unknown>;348349const noc = options.needOpenComment ?? 1;350const ofcc = options.onlyFansCanComment ?? 0;351352if (options.articleType === "newspic") {353if (!options.imageMediaIds || options.imageMediaIds.length === 0) {354throw new Error("newspic requires at least one image");355}356// newspic 的 content 应该是纯文本,需要:357// 1. 去掉 HTML 标签358// 2. 解码 HTML 实体( 、< 等)359// 3. 合并多余空白字符360const plainContent = htmlToPlainText(options.content);361article = {362article_type: "newspic",363title: options.title,364content: plainContent,365need_open_comment: noc,366only_fans_can_comment: ofcc,367image_info: {368image_list: options.imageMediaIds.map(id => ({ image_media_id: id })),369},370};371if (options.author) article.author = options.author;372} else {373article = {374article_type: "news",375title: options.title,376content: options.content,377thumb_media_id: options.thumbMediaId,378need_open_comment: noc,379only_fans_can_comment: ofcc,380};381if (options.author) article.author = options.author;382if (options.digest) article.digest = options.digest;383if (options.contentSourceUrl) article.content_source_url = options.contentSourceUrl;384}385386const res = await client(url, {387method: "POST",388headers: { "Content-Type": "application/json" },389body: JSON.stringify({ articles: [article] }),390});391392const data = await res.json<PublishResponse>();393if (data.errcode && data.errcode !== 0) {394throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);395}396397return data;398}399400function parseFrontmatter(content: string): { frontmatter: Record<string, string>; body: string } {401const match = content.match(/^\s*---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);402if (!match) return { frontmatter: {}, body: content };403404const frontmatter: Record<string, string> = {};405const lines = match[1]!.split("\n");406for (const line of lines) {407const colonIdx = line.indexOf(":");408if (colonIdx > 0) {409const key = line.slice(0, colonIdx).trim();410let value = line.slice(colonIdx + 1).trim();411if ((value.startsWith('"') && value.endsWith('"')) ||412(value.startsWith("'") && value.endsWith("'"))) {413value = value.slice(1, -1);414}415frontmatter[key] = value;416}417}418419return { frontmatter, body: match[2]! };420}421422function renderMarkdownWithPlaceholders(423markdownPath: string,424theme: string = "default",425color?: string,426citeStatus: boolean = true,427title?: string,428): MarkdownRenderResult {429const __filename = fileURLToPath(import.meta.url);430const __dirname = path.dirname(__filename);431const mdToWechatScript = path.join(__dirname, "md-to-wechat.ts");432const baseDir = path.dirname(markdownPath);433434const args = ["-y", "bun", mdToWechatScript, markdownPath];435if (title) args.push("--title", title);436if (theme) args.push("--theme", theme);437if (color) args.push("--color", color);438if (!citeStatus) args.push("--no-cite");439440console.error(`[wechat-api] Rendering markdown with placeholders via md-to-wechat: ${theme}${color ? `, color: ${color}` : ""}, citeStatus: ${citeStatus}`);441const result = spawnSync("npx", args, {442stdio: ["inherit", "pipe", "pipe"],443cwd: baseDir,444});445446if (result.status !== 0) {447const stderr = result.stderr?.toString() || "";448throw new Error(`Markdown placeholder render failed: ${stderr}`);449}450451const stdout = result.stdout?.toString() || "";452return JSON.parse(stdout) as MarkdownRenderResult;453}454455function replaceAllPlaceholders(html: string, placeholder: string, replacement: string): string {456const escapedPlaceholder = placeholder.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");457return html.replace(new RegExp(escapedPlaceholder + "(?!\\d)", "g"), replacement);458}459460function extractHtmlContent(htmlPath: string): string {461const html = fs.readFileSync(htmlPath, "utf-8");462const match = html.match(/<div id="output">([\s\S]*?)<\/div>\s*<\/body>/);463if (match) {464return match[1]!.trim();465}466const bodyMatch = html.match(/<body[^>]*>([\s\S]*?)<\/body>/i);467return bodyMatch ? bodyMatch[1]!.trim() : html;468}469470function printUsage(): never {471console.log(`Publish article to WeChat Official Account draft using API472473Usage:474npx -y bun wechat-api.ts <file> [options]475476Arguments:477file Markdown (.md) or HTML (.html) file478479Options:480--type <type> Article type: news (文章, default) or newspic (图文)481--title <title> Override title482--author <name> Author name (max 16 chars)483--summary <text> Article summary/digest (max 128 chars)484--source-url <url> Original article URL ("阅读原文" link, max 1KB)485--theme <name> Theme name for markdown (default, grace, simple, modern). Default: default486--color <name|hex> Primary color (blue, green, vermilion, etc. or hex)487--cover <path> Cover image path (local or URL)488--account <alias> Select account by alias (for multi-account setups)489--no-cite Disable bottom citations for ordinary external links in markdown mode490--dry-run Parse and render only, don't publish491--remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server492--remote-host <h> Remote server host (implies --remote)493--remote-user <u> SSH user (default: root, implies --remote)494--remote-port <n> SSH port (default: 22, implies --remote)495--remote-identity-file <p> SSH private key path (implies --remote)496--remote-known-hosts-file <p> SSH known_hosts file path (implies --remote)497--remote-strict-host-key-checking <yes|no|accept-new> (implies --remote)498--remote-connect-timeout <seconds> SSH ConnectTimeout (implies --remote)499--remote-proxy-jump <spec> SSH ProxyJump value (implies --remote)500--help Show this help501502Frontmatter Fields (markdown):503title Article title504author Author name505digest/summary Article summary506sourceUrl/contentSourceUrl/content_source_url Original article URL507coverImage/featureImage/cover/image Cover image path508509Comments:510Comments are enabled by default, open to all users.511512Environment Variables:513WECHAT_APP_ID WeChat App ID514WECHAT_APP_SECRET WeChat App Secret515516Config File Locations (in priority order):5171. Environment variables5182. <cwd>/.baoyu-skills/.env5193. ~/.baoyu-skills/.env520521Example:522npx -y bun wechat-api.ts article.md523npx -y bun wechat-api.ts article.md --theme grace --cover cover.png524npx -y bun wechat-api.ts article.md --author "Author Name" --summary "Brief intro" --source-url "https://example.com/original"525npx -y bun wechat-api.ts article.html --title "My Article"526npx -y bun wechat-api.ts images/ --type newspic --title "Photo Album"527npx -y bun wechat-api.ts article.md --dry-run528npx -y bun wechat-api.ts article.md --no-cite529`);530process.exit(0);531}532533interface CliArgs {534filePath: string;535isHtml: boolean;536articleType: ArticleType;537title?: string;538author?: string;539summary?: string;540sourceUrl?: string;541theme: string;542color?: string;543cover?: string;544account?: string;545citeStatus: boolean;546dryRun: boolean;547remote: boolean;548remoteHost?: string;549remoteUser?: string;550remotePort?: number;551remoteIdentityFile?: string;552remoteKnownHostsFile?: string;553remoteStrictHostKeyChecking?: StrictHostKeyChecking;554remoteConnectTimeout?: number;555remoteProxyJump?: string;556}557558function parseArgs(argv: string[]): CliArgs {559if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {560printUsage();561}562563const args: CliArgs = {564filePath: "",565isHtml: false,566articleType: "news",567theme: "default",568citeStatus: true,569dryRun: false,570remote: false,571};572573for (let i = 0; i < argv.length; i++) {574const arg = argv[i]!;575if (arg === "--type" && argv[i + 1]) {576const t = argv[++i]!.toLowerCase();577if (t === "news" || t === "newspic") {578args.articleType = t;579}580} else if (arg === "--title" && argv[i + 1]) {581args.title = argv[++i];582} else if (arg === "--author" && argv[i + 1]) {583args.author = argv[++i];584} else if (arg === "--summary" && argv[i + 1]) {585args.summary = argv[++i];586} else if (arg === "--source-url" && argv[i + 1]) {587args.sourceUrl = argv[++i];588} else if (arg === "--theme" && argv[i + 1]) {589args.theme = argv[++i]!;590} else if (arg === "--color" && argv[i + 1]) {591args.color = argv[++i];592} else if (arg === "--cover" && argv[i + 1]) {593args.cover = argv[++i];594} else if (arg === "--account" && argv[i + 1]) {595args.account = argv[++i];596} else if (arg === "--cite") {597args.citeStatus = true;598} else if (arg === "--no-cite") {599args.citeStatus = false;600} else if (arg === "--dry-run") {601args.dryRun = true;602} else if (arg === "--remote") {603args.remote = true;604} else if (arg === "--remote-host" && argv[i + 1]) {605args.remoteHost = argv[++i];606args.remote = true;607} else if (arg === "--remote-user" && argv[i + 1]) {608args.remoteUser = argv[++i];609args.remote = true;610} else if (arg === "--remote-port" && argv[i + 1]) {611const n = Number.parseInt(argv[++i]!, 10);612if (!Number.isInteger(n) || n < 1 || n > 65535) {613console.error(`Error: --remote-port must be 1-65535, got ${argv[i]}`);614process.exit(1);615}616args.remotePort = n;617args.remote = true;618} else if (arg === "--remote-identity-file" && argv[i + 1]) {619args.remoteIdentityFile = argv[++i];620args.remote = true;621} else if (arg === "--remote-known-hosts-file" && argv[i + 1]) {622args.remoteKnownHostsFile = argv[++i];623args.remote = true;624} else if (arg === "--remote-strict-host-key-checking" && argv[i + 1]) {625const v = argv[++i]!.toLowerCase();626if (v !== "yes" && v !== "no" && v !== "accept-new") {627console.error(`Error: --remote-strict-host-key-checking must be yes|no|accept-new, got ${argv[i]}`);628process.exit(1);629}630args.remoteStrictHostKeyChecking = v as StrictHostKeyChecking;631args.remote = true;632} else if (arg === "--remote-connect-timeout" && argv[i + 1]) {633const n = Number.parseInt(argv[++i]!, 10);634if (!Number.isInteger(n) || n <= 0) {635console.error(`Error: --remote-connect-timeout must be a positive integer, got ${argv[i]}`);636process.exit(1);637}638args.remoteConnectTimeout = n;639args.remote = true;640} else if (arg === "--remote-proxy-jump" && argv[i + 1]) {641args.remoteProxyJump = argv[++i];642args.remote = true;643} else if (arg.startsWith("--") && argv[i + 1] && !argv[i + 1]!.startsWith("-")) {644i++;645} else if (!arg.startsWith("-")) {646args.filePath = arg;647}648}649650if (!args.filePath) {651console.error("Error: File path required");652process.exit(1);653}654655args.isHtml = args.filePath.toLowerCase().endsWith(".html");656657return args;658}659660function extractHtmlTitle(html: string): string {661const titleMatch = html.match(/<title>([^<]+)<\/title>/i);662if (titleMatch) return titleMatch[1]!;663const h1Match = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);664if (h1Match) return h1Match[1]!.replace(/<[^>]+>/g, "").trim();665return "";666}667668function buildRemoteConfig(args: CliArgs, resolved: ResolvedAccount): RemotePublishConfig {669const host = args.remoteHost ?? resolved.remote_publish_host;670if (!host) {671throw new Error(672"Remote publishing requires a host. Set --remote-host, EXTEND.md remote_publish_host, " +673"or an account-level remote_publish_host.",674);675}676return {677host,678user: args.remoteUser ?? resolved.remote_publish_user,679port: args.remotePort ?? resolved.remote_publish_port,680identityFile: args.remoteIdentityFile ?? resolved.remote_publish_identity_file,681knownHostsFile: args.remoteKnownHostsFile ?? resolved.remote_publish_known_hosts_file,682strictHostKeyChecking:683args.remoteStrictHostKeyChecking ?? resolved.remote_publish_strict_host_key_checking,684connectTimeout: args.remoteConnectTimeout ?? resolved.remote_publish_connect_timeout,685proxyJump: args.remoteProxyJump ?? resolved.remote_publish_proxy_jump,686};687}688689async function main(): Promise<void> {690const args = parseArgs(process.argv.slice(2));691692const filePath = path.resolve(args.filePath);693if (!fs.existsSync(filePath)) {694console.error(`Error: File not found: ${filePath}`);695process.exit(1);696}697698const baseDir = path.dirname(filePath);699let title = args.title || "";700let author = args.author || "";701let digest = args.summary || "";702let sourceUrl = args.sourceUrl || "";703let htmlPath: string;704let htmlContent: string;705let frontmatter: Record<string, string> = {};706let contentImages: ImageInfo[] = [];707708if (args.isHtml) {709htmlPath = filePath;710htmlContent = extractHtmlContent(htmlPath);711const mdPath = filePath.replace(/\.html$/i, ".md");712if (fs.existsSync(mdPath)) {713const mdContent = fs.readFileSync(mdPath, "utf-8");714const parsed = parseFrontmatter(mdContent);715frontmatter = parsed.frontmatter;716if (!title && frontmatter.title) title = frontmatter.title;717if (!author) author = frontmatter.author || "";718if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";719if (!sourceUrl) sourceUrl = frontmatter.sourceUrl || frontmatter.contentSourceUrl || frontmatter.content_source_url || "";720}721if (!title) {722title = extractHtmlTitle(fs.readFileSync(htmlPath, "utf-8"));723}724console.error(`[wechat-api] Using HTML file: ${htmlPath}`);725} else {726const content = fs.readFileSync(filePath, "utf-8");727const parsed = parseFrontmatter(content);728frontmatter = parsed.frontmatter;729const body = parsed.body;730731title = title || frontmatter.title || "";732if (!title) {733const h1Match = body.match(/^#\s+(.+)$/m);734if (h1Match) title = h1Match[1]!;735}736if (!author) author = frontmatter.author || "";737if (!digest) digest = frontmatter.digest || frontmatter.summary || frontmatter.description || "";738if (!sourceUrl) sourceUrl = frontmatter.sourceUrl || frontmatter.contentSourceUrl || frontmatter.content_source_url || "";739740console.error(`[wechat-api] Theme: ${args.theme}${args.color ? `, color: ${args.color}` : ""}, citeStatus: ${args.citeStatus}`);741const rendered = renderMarkdownWithPlaceholders(filePath, args.theme, args.color, args.citeStatus, args.title);742htmlPath = rendered.htmlPath;743contentImages = rendered.contentImages;744if (!title) title = rendered.title;745if (!author) author = rendered.author;746if (!digest) digest = rendered.summary;747console.error(`[wechat-api] HTML generated: ${htmlPath}`);748console.error(`[wechat-api] Placeholder images: ${contentImages.length}`);749htmlContent = extractHtmlContent(htmlPath);750}751752if (!title) {753console.error("Error: No title found. Provide via --title, frontmatter, or <title> tag.");754process.exit(1);755}756757if (digest && digest.length > 120) {758const truncated = digest.slice(0, 117);759const lastPunct = Math.max(truncated.lastIndexOf("。"), truncated.lastIndexOf(","), truncated.lastIndexOf(";"), truncated.lastIndexOf("、"));760digest = lastPunct > 80 ? truncated.slice(0, lastPunct + 1) : truncated + "...";761console.error(`[wechat-api] Digest truncated to ${digest.length} chars`);762}763764console.error(`[wechat-api] Title: ${title}`);765if (author) console.error(`[wechat-api] Author: ${author}`);766if (digest) console.error(`[wechat-api] Digest: ${digest.slice(0, 50)}...`);767if (sourceUrl) console.error(`[wechat-api] Source URL: ${sourceUrl}`);768console.error(`[wechat-api] Type: ${args.articleType}`);769770const extConfig = loadWechatExtendConfig();771const resolved = resolveAccount(extConfig, args.account);772if (resolved.name) console.error(`[wechat-api] Account: ${resolved.name} (${resolved.alias})`);773774if (!author && resolved.default_author) author = resolved.default_author;775776if (args.dryRun) {777console.log(JSON.stringify({778articleType: args.articleType,779title,780author: author || undefined,781digest: digest || undefined,782sourceUrl: sourceUrl || undefined,783htmlPath,784contentLength: htmlContent.length,785placeholderImageCount: contentImages.length || undefined,786account: resolved.alias || undefined,787}, null, 2));788return;789}790791const creds = loadCredentials(resolved);792for (const skippedSource of creds.skippedSources) {793console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);794}795console.error(`[wechat-api] Credentials source: ${creds.source}`);796797const rawCoverPath = args.cover ||798frontmatter.coverImage ||799frontmatter.featureImage ||800frontmatter.cover ||801frontmatter.image;802const coverPath = rawCoverPath && !path.isAbsolute(rawCoverPath) && args.cover803? path.resolve(process.cwd(), rawCoverPath)804: rawCoverPath;805const needNewsCoverFallback = args.articleType === "news" && !coverPath;806807const useRemote = args.remote || resolved.default_publish_method === "remote-api";808const method = useRemote ? "remote-api" : "api";809810const publishWith = async (client: WechatClient): Promise<void> => {811console.error("[wechat-api] Fetching access token...");812const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, client);813814console.error("[wechat-api] Uploading body images...");815const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(816htmlContent,817accessToken,818baseDir,819contentImages,820args.articleType,821needNewsCoverFallback,822client,823);824htmlContent = processedHtml;825826let thumbMediaId = "";827828if (coverPath) {829console.error(`[wechat-api] Uploading cover: ${coverPath}`);830const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", client);831thumbMediaId = coverResp.media_id;832console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);833} else if (firstCoverMediaId && args.articleType === "news") {834thumbMediaId = firstCoverMediaId;835console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);836}837838if (args.articleType === "news" && !thumbMediaId) {839throw new Error("No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");840}841842if (args.articleType === "newspic" && imageMediaIds.length === 0) {843throw new Error("newspic requires at least one image in content.");844}845846console.error("[wechat-api] Publishing to draft...");847const result = await publishToDraft({848title,849author: author || undefined,850digest: digest || undefined,851content: htmlContent,852thumbMediaId,853articleType: args.articleType,854contentSourceUrl: sourceUrl || undefined,855imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,856needOpenComment: resolved.need_open_comment,857onlyFansCanComment: resolved.only_fans_can_comment,858}, accessToken, client);859860console.log(JSON.stringify({861success: true,862media_id: result.media_id,863title,864articleType: args.articleType,865method,866}, null, 2));867868console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);869};870871if (useRemote) {872const remoteConfig = normalizeRemoteConfig(buildRemoteConfig(args, resolved));873console.error(874`[wechat-api] Remote publishing via ${remoteConfig.user}@${remoteConfig.host}:${remoteConfig.port}`,875);876await withSshTunnel(remoteConfig, async (client) => {877await publishWith(client);878});879} else {880await publishWith(wechatHttp);881}882}883884await main().catch((err) => {885console.error(`Error: ${err instanceof Error ? err.message : String(err)}`);886process.exit(1);887});888