mirror of
https://github.com/JimLiu/baoyu-skills.git
synced 2026-07-30 21:59:47 +08:00
feat(baoyu-post-to-wechat): add remote-api publishing via SSH SOCKS5 tunnel
Re-implements PR #156 (remote WeChat API publishing for IP-allowlist constraints) with a fully local code path: spawn a background `ssh -N -D` SOCKS5 dynamic forward, wrap a `SocksProxyAgent`, and thread it through the existing token/upload/draft flow. No Python helper, no SCP, no remote files, no `AppSecret` ever leaving the local process. Reusing `uploadImagesInHtml` (which replaces the matched `<img>` fullTag rather than substring-replacing `src`) naturally avoids the original PR's `html.replace(src, url)` HTML-replacement bug. Changes: - New `wechat-remote-publish.ts`: typed-whitelist SSH args, free-port allocation, SOCKS readiness polling, signal-handler-backed cleanup. - New `wechat-http.ts`: minimal `https.request`-backed HTTP helper so a `SocksProxyAgent` can be passed through (undici `fetch` ignores agent). - New `wechat-image-loader.ts`: extracts `loadUploadAsset` for reuse. - `wechat-api.ts`: thread optional `agent?` through token/upload/draft; add `--remote*` CLI flags; dispatch via `withSshTunnel` when remote mode is selected by flag or `default_publish_method: remote-api`. - `wechat-extend-config.ts`: add typed `remote_publish_*` keys with account-over-global fallback and value validation. - Docs: SKILL.md, references/multi-account.md, references/config/ first-time-setup.md, README.md, README.zh.md. - Tests: 17 new node:test cases covering config parsing, SSH arg whitelisting, free-port allocation, multipart assembly, and HTTP agent threading. Co-authored-by: Dame5211 <1079825614@qq.com>
This commit is contained in:
@@ -2,13 +2,26 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { loadWechatExtendConfig, resolveAccount, loadCredentials } from "./wechat-extend-config.ts";
|
||||
import type http from "node:http";
|
||||
import {
|
||||
loadWechatExtendConfig,
|
||||
resolveAccount,
|
||||
loadCredentials,
|
||||
type ResolvedAccount,
|
||||
type StrictHostKeyChecking,
|
||||
} from "./wechat-extend-config.ts";
|
||||
import {
|
||||
type WechatUploadAsset,
|
||||
prepareWechatBodyImageUpload,
|
||||
needsWechatBodyImageProcessing,
|
||||
detectImageFormatFromBuffer,
|
||||
} from "./wechat-image-processor.ts";
|
||||
import { loadUploadAsset } from "./wechat-image-loader.ts";
|
||||
import { wechatHttp, buildMultipart } from "./wechat-http.ts";
|
||||
import {
|
||||
type RemotePublishConfig,
|
||||
normalizeRemoteConfig,
|
||||
withSshTunnel,
|
||||
} from "./wechat-remote-publish.ts";
|
||||
|
||||
interface AccessTokenResponse {
|
||||
access_token?: string;
|
||||
@@ -62,13 +75,13 @@ const UPLOAD_BODY_IMG_URL = "https://api.weixin.qq.com/cgi-bin/media/uploadimg";
|
||||
const UPLOAD_MATERIAL_URL = "https://api.weixin.qq.com/cgi-bin/material/add_material";
|
||||
const DRAFT_URL = "https://api.weixin.qq.com/cgi-bin/draft/add";
|
||||
|
||||
async function fetchAccessToken(appId: string, appSecret: string): Promise<string> {
|
||||
async function fetchAccessToken(appId: string, appSecret: string, agent?: http.Agent): Promise<string> {
|
||||
const url = `${TOKEN_URL}?grant_type=client_credential&appid=${appId}&secret=${appSecret}`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
const res = await wechatHttp(url, { agent });
|
||||
if (res.status < 200 || res.status >= 300) {
|
||||
throw new Error(`Failed to fetch access token: ${res.status}`);
|
||||
}
|
||||
const data = await res.json() as AccessTokenResponse;
|
||||
const data = await res.json<AccessTokenResponse>();
|
||||
if (data.errcode) {
|
||||
throw new Error(`Access token error ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -83,86 +96,12 @@ function toHttpsUrl(url: string | undefined): string {
|
||||
return url.startsWith("http://") ? url.replace(/^http:\/\//i, "https://") : url;
|
||||
}
|
||||
|
||||
async function loadUploadAsset(
|
||||
imagePath: string,
|
||||
baseDir?: string,
|
||||
): Promise<WechatUploadAsset> {
|
||||
let fileBuffer: Buffer;
|
||||
let filename: string;
|
||||
let contentType: string;
|
||||
let fileSize = 0;
|
||||
let fileExt = "";
|
||||
|
||||
if (imagePath.startsWith("http://") || imagePath.startsWith("https://")) {
|
||||
const response = await fetch(imagePath);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download image: ${imagePath}`);
|
||||
}
|
||||
const buffer = await response.arrayBuffer();
|
||||
if (buffer.byteLength === 0) {
|
||||
throw new Error(`Remote image is empty: ${imagePath}`);
|
||||
}
|
||||
fileBuffer = Buffer.from(buffer);
|
||||
fileSize = buffer.byteLength;
|
||||
const urlPath = imagePath.split("?")[0];
|
||||
filename = path.basename(urlPath) || "image.jpg";
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
contentType = response.headers.get("content-type") || "image/jpeg";
|
||||
} else {
|
||||
const resolvedPath = path.isAbsolute(imagePath)
|
||||
? imagePath
|
||||
: path.resolve(baseDir || process.cwd(), imagePath);
|
||||
|
||||
if (!fs.existsSync(resolvedPath)) {
|
||||
throw new Error(`Image not found: ${resolvedPath}`);
|
||||
}
|
||||
const stats = fs.statSync(resolvedPath);
|
||||
if (stats.size === 0) {
|
||||
throw new Error(`Local image is empty: ${resolvedPath}`);
|
||||
}
|
||||
fileSize = stats.size;
|
||||
fileBuffer = fs.readFileSync(resolvedPath);
|
||||
filename = path.basename(resolvedPath);
|
||||
fileExt = path.extname(filename).toLowerCase();
|
||||
const mimeTypes: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
".webp": "image/webp",
|
||||
".bmp": "image/bmp",
|
||||
".tiff": "image/tiff",
|
||||
".tif": "image/tiff",
|
||||
".svg": "image/svg+xml",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
contentType = mimeTypes[fileExt] || "image/jpeg";
|
||||
}
|
||||
|
||||
// Detect actual format from magic bytes to fix extension/content-type mismatches
|
||||
// (e.g. CDNs serving WebP for URLs with .png extension)
|
||||
const detected = detectImageFormatFromBuffer(fileBuffer);
|
||||
if (detected && detected.contentType !== contentType) {
|
||||
console.error(`[wechat-api] Format mismatch: ${filename} declared as ${contentType}, actual ${detected.contentType}`);
|
||||
contentType = detected.contentType;
|
||||
fileExt = detected.fileExt;
|
||||
filename = `${path.basename(filename, path.extname(filename))}${detected.fileExt}`;
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: fileBuffer,
|
||||
filename,
|
||||
contentType,
|
||||
fileExt,
|
||||
fileSize,
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
imagePath: string,
|
||||
accessToken: string,
|
||||
baseDir?: string,
|
||||
uploadType: "body" | "material" = "body"
|
||||
uploadType: "body" | "material" = "body",
|
||||
agent?: http.Agent,
|
||||
): Promise<UploadResponse> {
|
||||
const asset = await loadUploadAsset(imagePath, baseDir);
|
||||
let uploadAsset = asset;
|
||||
@@ -187,6 +126,7 @@ async function uploadImage(
|
||||
uploadAsset.contentType,
|
||||
accessToken,
|
||||
uploadType,
|
||||
agent,
|
||||
);
|
||||
|
||||
// media/uploadimg 接口只返回 URL,material/add_material 返回 media_id
|
||||
@@ -201,39 +141,28 @@ async function uploadImage(
|
||||
}
|
||||
}
|
||||
|
||||
// 实际的微信上传函数
|
||||
async function uploadToWechat(
|
||||
fileBuffer: Buffer,
|
||||
filename: string,
|
||||
contentType: string,
|
||||
accessToken: string,
|
||||
uploadType: "body" | "material"
|
||||
uploadType: "body" | "material",
|
||||
agent?: http.Agent,
|
||||
): Promise<UploadResponse> {
|
||||
const boundary = `----WebKitFormBoundary${Date.now().toString(16)}`;
|
||||
const header = [
|
||||
`--${boundary}`,
|
||||
`Content-Disposition: form-data; name="media"; filename="${filename}"`,
|
||||
`Content-Type: ${contentType}`,
|
||||
"",
|
||||
"",
|
||||
].join("\r\n");
|
||||
const footer = `\r\n--${boundary}--\r\n`;
|
||||
|
||||
const headerBuffer = Buffer.from(header, "utf-8");
|
||||
const footerBuffer = Buffer.from(footer, "utf-8");
|
||||
const body = Buffer.concat([headerBuffer, fileBuffer, footerBuffer]);
|
||||
const multipart = buildMultipart([
|
||||
{ name: "media", filename, contentType, data: fileBuffer },
|
||||
]);
|
||||
|
||||
const uploadUrl = uploadType === "body" ? UPLOAD_BODY_IMG_URL : UPLOAD_MATERIAL_URL;
|
||||
const url = `${uploadUrl}?type=image&access_token=${accessToken}`;
|
||||
const res = await fetch(url, {
|
||||
const res = await wechatHttp(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
},
|
||||
body,
|
||||
headers: { "Content-Type": multipart.contentType },
|
||||
body: multipart.body,
|
||||
agent,
|
||||
});
|
||||
|
||||
const data = await res.json() as UploadResponse;
|
||||
const data = await res.json<UploadResponse>();
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
throw new Error(`Upload failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -248,6 +177,7 @@ async function uploadImagesInHtml(
|
||||
contentImages: ImageInfo[] = [],
|
||||
articleType: ArticleType = "news",
|
||||
collectNewsCoverFallback: boolean = false,
|
||||
agent?: http.Agent,
|
||||
): Promise<{ html: string; firstCoverMediaId: string; imageMediaIds: string[] }> {
|
||||
const imgRegex = /<img[^>]*\ssrc=["']([^"']+)["'][^>]*>/gi;
|
||||
const matches = [...html.matchAll(imgRegex)];
|
||||
@@ -268,7 +198,7 @@ async function uploadImagesInHtml(
|
||||
if (src.startsWith("https://mmbiz.qpic.cn")) {
|
||||
if (collectNewsCoverFallback && !firstCoverMediaId) {
|
||||
try {
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material");
|
||||
const coverResp = await uploadImage(src, accessToken, baseDir, "material", agent);
|
||||
firstCoverMediaId = coverResp.media_id;
|
||||
} catch (err) {
|
||||
console.error(`[wechat-api] Failed to reuse existing WeChat image as cover: ${src}`, err);
|
||||
@@ -284,8 +214,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
const newTag = fullTag
|
||||
@@ -296,7 +225,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -320,8 +249,7 @@ async function uploadImagesInHtml(
|
||||
try {
|
||||
let resp = uploadedBySource.get(imagePath);
|
||||
if (!resp) {
|
||||
// 正文图片使用 media/uploadimg 接口获取 URL
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body");
|
||||
resp = await uploadImage(imagePath, accessToken, baseDir, "body", agent);
|
||||
uploadedBySource.set(imagePath, resp);
|
||||
}
|
||||
|
||||
@@ -331,7 +259,7 @@ async function uploadImagesInHtml(
|
||||
if (shouldUploadMaterial) {
|
||||
let materialResp = uploadedBySource.get(`${imagePath}:material`);
|
||||
if (!materialResp) {
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material");
|
||||
materialResp = await uploadImage(imagePath, accessToken, baseDir, "material", agent);
|
||||
uploadedBySource.set(`${imagePath}:material`, materialResp);
|
||||
}
|
||||
if (articleType === "newspic" && materialResp.media_id) {
|
||||
@@ -351,7 +279,8 @@ async function uploadImagesInHtml(
|
||||
|
||||
async function publishToDraft(
|
||||
options: ArticleOptions,
|
||||
accessToken: string
|
||||
accessToken: string,
|
||||
agent?: http.Agent,
|
||||
): Promise<PublishResponse> {
|
||||
const url = `${DRAFT_URL}?access_token=${accessToken}`;
|
||||
|
||||
@@ -388,15 +317,14 @@ async function publishToDraft(
|
||||
if (options.digest) article.digest = options.digest;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
const res = await wechatHttp(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ articles: [article] }),
|
||||
agent,
|
||||
});
|
||||
|
||||
const data = await res.json() as PublishResponse;
|
||||
const data = await res.json<PublishResponse>();
|
||||
if (data.errcode && data.errcode !== 0) {
|
||||
throw new Error(`Publish failed ${data.errcode}: ${data.errmsg}`);
|
||||
}
|
||||
@@ -494,6 +422,15 @@ Options:
|
||||
--account <alias> Select account by alias (for multi-account setups)
|
||||
--no-cite Disable bottom citations for ordinary external links in markdown mode
|
||||
--dry-run Parse and render only, don't publish
|
||||
--remote Route WeChat API calls via SSH SOCKS5 tunnel to a whitelisted server
|
||||
--remote-host <h> Remote server host (implies --remote)
|
||||
--remote-user <u> SSH user (default: root, implies --remote)
|
||||
--remote-port <n> SSH port (default: 22, implies --remote)
|
||||
--remote-identity-file <p> SSH private key path (implies --remote)
|
||||
--remote-known-hosts-file <p> SSH known_hosts file path (implies --remote)
|
||||
--remote-strict-host-key-checking <yes|no|accept-new> (implies --remote)
|
||||
--remote-connect-timeout <seconds> SSH ConnectTimeout (implies --remote)
|
||||
--remote-proxy-jump <spec> SSH ProxyJump value (implies --remote)
|
||||
--help Show this help
|
||||
|
||||
Frontmatter Fields (markdown):
|
||||
@@ -539,6 +476,15 @@ interface CliArgs {
|
||||
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 {
|
||||
@@ -553,6 +499,7 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
theme: "default",
|
||||
citeStatus: true,
|
||||
dryRun: false,
|
||||
remote: false,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -582,6 +529,47 @@ function parseArgs(argv: string[]): CliArgs {
|
||||
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("-")) {
|
||||
@@ -607,6 +595,27 @@ function extractHtmlTitle(html: string): string {
|
||||
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<void> {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
@@ -709,8 +718,6 @@ async function main(): Promise<void> {
|
||||
console.error(`[wechat-api] Skipped incomplete credential source: ${skippedSource}`);
|
||||
}
|
||||
console.error(`[wechat-api] Credentials source: ${creds.source}`);
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret);
|
||||
|
||||
const rawCoverPath = args.cover ||
|
||||
frontmatter.coverImage ||
|
||||
@@ -722,62 +729,80 @@ async function main(): Promise<void> {
|
||||
: rawCoverPath;
|
||||
const needNewsCoverFallback = args.articleType === "news" && !coverPath;
|
||||
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
const useRemote = args.remote || resolved.default_publish_method === "remote-api";
|
||||
const method = useRemote ? "remote-api" : "api";
|
||||
|
||||
let thumbMediaId = "";
|
||||
const publishWithAgent = async (agent?: http.Agent): Promise<void> => {
|
||||
console.error("[wechat-api] Fetching access token...");
|
||||
const accessToken = await fetchAccessToken(creds.appId, creds.appSecret, agent);
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
// 封面图片使用 material/add_material 接口
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material");
|
||||
thumbMediaId = coverResp.media_id;
|
||||
console.error(`[wechat-api] Cover uploaded successfully, media_id: ${thumbMediaId}`);
|
||||
} else if (firstCoverMediaId && args.articleType === "news") {
|
||||
// news 类型没有封面时,使用第一张正文图的 media_id 作为封面(兜底逻辑)
|
||||
thumbMediaId = firstCoverMediaId;
|
||||
console.error(`[wechat-api] Using first body image as cover (fallback), media_id: ${thumbMediaId}`);
|
||||
console.error("[wechat-api] Uploading body images...");
|
||||
const { html: processedHtml, firstCoverMediaId, imageMediaIds } = await uploadImagesInHtml(
|
||||
htmlContent,
|
||||
accessToken,
|
||||
baseDir,
|
||||
contentImages,
|
||||
args.articleType,
|
||||
needNewsCoverFallback,
|
||||
agent,
|
||||
);
|
||||
htmlContent = processedHtml;
|
||||
|
||||
let thumbMediaId = "";
|
||||
|
||||
if (coverPath) {
|
||||
console.error(`[wechat-api] Uploading cover: ${coverPath}`);
|
||||
const coverResp = await uploadImage(coverPath, accessToken, baseDir, "material", agent);
|
||||
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,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken, agent);
|
||||
|
||||
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 (agent) => {
|
||||
await publishWithAgent(agent);
|
||||
});
|
||||
} else {
|
||||
await publishWithAgent();
|
||||
}
|
||||
|
||||
if (args.articleType === "news" && !thumbMediaId) {
|
||||
console.error("Error: No cover image. Provide via --cover, frontmatter.coverImage, or include an image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (args.articleType === "newspic" && imageMediaIds.length === 0) {
|
||||
console.error("Error: newspic requires at least one image in content.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.error("[wechat-api] Publishing to draft...");
|
||||
const result = await publishToDraft({
|
||||
title,
|
||||
author: author || undefined,
|
||||
digest: digest || undefined,
|
||||
content: htmlContent,
|
||||
thumbMediaId,
|
||||
articleType: args.articleType,
|
||||
imageMediaIds: args.articleType === "newspic" ? imageMediaIds : undefined,
|
||||
needOpenComment: resolved.need_open_comment,
|
||||
onlyFansCanComment: resolved.only_fans_can_comment,
|
||||
}, accessToken);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
success: true,
|
||||
media_id: result.media_id,
|
||||
title,
|
||||
articleType: args.articleType,
|
||||
}, null, 2));
|
||||
|
||||
console.error(`[wechat-api] Published successfully! media_id: ${result.media_id}`);
|
||||
}
|
||||
|
||||
await main().catch((err) => {
|
||||
|
||||
Reference in New Issue
Block a user