feat(publish): add seven publish adapters for new media platforms

Wire sohuhao, wangyihao, juejin, smzdm, weixin-gzh, zol, and dongchedi
into the publish runtime: each ships its own publish protocol and
auth-failure classifier (login/token/challenge codes) plus registry
entries in selectPublishAdapter and the auth adapter map. Smzdm bind
detection now triple-falls-back across page-context fetch, session
fetch, cookie fetch, and a final cookie-derived account so the
nickname-less guest profile still resolves to a stable platform UID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-27 00:57:37 +08:00
parent 34e54b9f78
commit 290da7cd50
14 changed files with 4997 additions and 17 deletions
+266 -9
View File
@@ -205,6 +205,18 @@ type SmzdmCurrentUser = {
nickname?: string;
audit_nickname?: string;
avatar?: string;
data?: {
smzdm_id?: string | number;
nickname?: string;
audit_nickname?: string;
avatar?: string;
};
};
type SmzdmPageState = {
displayName: string | null;
avatarUrl: string | null;
loggedInPage: boolean;
};
type ZolUserInfoResponse = {
@@ -294,6 +306,15 @@ function stringId(value: unknown): string {
return normalizeText(value) ?? "";
}
function decodeCookieText(value: string): string {
const source = value.replace(/\+/g, " ");
try {
return decodeURIComponent(source);
} catch {
return source;
}
}
function hashText(value: string): string {
return createHash("sha1").update(value).digest("hex").slice(0, 20);
}
@@ -2399,25 +2420,261 @@ async function detectJuejin({ session }: DetectContext): Promise<DetectedAccount
};
}
async function detectSmzdm({ session }: DetectContext): Promise<DetectedAccount | null> {
const response = await sessionFetchJson<SmzdmCurrentUser>(
session,
"https://zhiyou.smzdm.com/user/info/jsonp_get_current",
).catch(() => null);
function normalizeSmzdmUid(value: unknown): string {
const uid = stringId(value);
return uid && uid !== "0" ? uid : "";
}
const uid = stringId(response?.smzdm_id);
const nickname = response?.nickname || response?.audit_nickname || "";
if (!uid || !nickname) {
function smzdmAccountFromCurrentUser(
response: SmzdmCurrentUser | null | undefined,
fallback?: {
displayName?: string | null;
avatarUrl?: string | null;
} | null,
): DetectedAccount | null {
const user = response?.data ?? response;
const uid = normalizeSmzdmUid(user?.smzdm_id);
if (!uid) {
return null;
}
const nickname =
normalizeText(user?.nickname)
?? normalizeText(user?.audit_nickname)
?? normalizeText(fallback?.displayName)
?? `值友${uid}`;
return {
platformUid: uid,
displayName: nickname,
avatarUrl: normalizeRemoteUrl(response?.avatar),
avatarUrl: normalizeRemoteUrl(user?.avatar) ?? normalizeRemoteUrl(fallback?.avatarUrl),
};
}
async function readSmzdmPageState(
webContents: WebContents | null | undefined,
): Promise<SmzdmPageState | null> {
if (!webContents || webContents.isDestroyed()) {
return null;
}
const serialized = await webContents.executeJavaScript(`(() => {
try {
const normalize = (value) => {
if (typeof value !== "string") return null;
const trimmed = value.trim().replace(/\\s+/g, " ");
return trimmed || null;
};
const compact = (value) => normalize(value)?.replace(/\\s+/g, "") || null;
const isVisible = (node) => {
if (!(node instanceof Element)) return false;
const style = window.getComputedStyle(node);
if (style.display === "none" || style.visibility === "hidden" || style.opacity === "0") {
return false;
}
const rect = node.getBoundingClientRect();
return rect.width > 0 && rect.height > 0;
};
const ignored = /^(首页|好价|社区|百科|集团官网|商业合作|爆料投稿|个人中心|我的消息|兑换记录|账户设置|关注|粉丝|社区达人榜|查看更多|搜索|文章|笔记|视频|商品|众测|评论|收藏|品牌认领|金币|碎银子|经验|登录|注册|扫码登录|手机号登录)$/;
const scoreName = (text, rect) => {
let score = 0;
if (/^值友[0-9A-Za-z_-]{4,}$/.test(text)) score += 80;
if (rect.top >= 120 && rect.top <= 360 && rect.left >= 160 && rect.left <= 820) score += 40;
if (rect.top >= 40 && rect.top <= 140 && rect.left >= window.innerWidth * 0.72) score += 20;
return score;
};
const pickDisplayName = () => {
const candidates = [];
const selectors = [
"[class*=nickname]",
"[class*=user] [class*=name]",
"[class*=account] [class*=name]",
"[class*=profile] [class*=name]",
".name",
];
for (const selector of selectors) {
for (const node of document.querySelectorAll(selector)) {
if (!isVisible(node)) continue;
const text = compact(node.textContent || "");
if (!text || text.length < 2 || text.length > 40 || ignored.test(text)) continue;
const rect = node.getBoundingClientRect();
candidates.push({ text, score: scoreName(text, rect) + 30 });
}
}
for (const node of document.querySelectorAll("body *")) {
if (!isVisible(node)) continue;
const text = compact(node.textContent || "");
if (!text || text.length < 2 || text.length > 40 || ignored.test(text)) continue;
const rect = node.getBoundingClientRect();
if (rect.top < 0 || rect.top > 420 || rect.left < 80) continue;
const score = scoreName(text, rect);
if (score > 0) {
candidates.push({ text, score });
}
}
candidates.sort((left, right) => right.score - left.score);
if (candidates[0]?.text) {
return candidates[0].text;
}
const bodyText = document.body?.innerText || "";
return compact(bodyText.match(/值友[0-9A-Za-z_-]{4,}/)?.[0] || "");
};
const pickAvatar = () => {
const images = Array.from(document.images)
.map((image) => ({ src: image.currentSrc || image.src || "", rect: image.getBoundingClientRect() }))
.filter(({ src, rect }) =>
src
&& !/(logo|favicon|sprite|blank)/i.test(src)
&& rect.top >= 100
&& rect.top <= 420
&& rect.left >= 80
&& rect.left <= 520
&& rect.width >= 40
&& rect.height >= 40
)
.sort((left, right) => {
const leftArea = left.rect.width * left.rect.height;
const rightArea = right.rect.width * right.rect.height;
return rightArea - leftArea;
});
return images[0]?.src || null;
};
const displayName = pickDisplayName();
const bodyText = document.body?.innerText || "";
const path = window.location.pathname || "";
const loggedInPage =
/(?:^|\\.)smzdm\\.com$/i.test(window.location.hostname)
&& !/\\/user\\/login/i.test(path)
&& Boolean(displayName)
&& (
/个人中心/.test(bodyText)
|| /账户设置/.test(bodyText)
|| /我的消息/.test(bodyText)
|| /^值友[0-9A-Za-z_-]{4,}$/.test(displayName)
);
return JSON.stringify({
displayName,
avatarUrl: pickAvatar(),
loggedInPage,
});
} catch {
return JSON.stringify(null);
}
})()`, true).catch(() => null);
if (typeof serialized !== "string" || !serialized) {
return null;
}
try {
const parsed = JSON.parse(serialized) as Partial<SmzdmPageState> | null;
if (!parsed || typeof parsed !== "object") {
return null;
}
return {
displayName: normalizeText(parsed.displayName),
avatarUrl: normalizeRemoteUrl(parsed.avatarUrl),
loggedInPage: parsed.loggedInPage === true,
};
} catch {
return null;
}
}
async function deriveSmzdmCookieAccount(
session: Session,
pageState?: SmzdmPageState | null,
): Promise<DetectedAccount | null> {
const uid = normalizeSmzdmUid(await sessionCookieValue(session, "smzdm.com", "smzdm_id").catch(() => ""));
const userCookie = await sessionCookieValue(session, "smzdm.com", "user").catch(() => "");
const sessCookie = await sessionCookieValue(session, "smzdm.com", "sess").catch(() => "");
const decodedUser = normalizeText(decodeCookieText(userCookie));
const displayName =
decodedUser
?? normalizeText(pageState?.displayName)
?? (uid ? `值友${uid}` : null);
if (uid && displayName) {
return {
platformUid: uid,
displayName,
avatarUrl: normalizeRemoteUrl(pageState?.avatarUrl),
};
}
const authFingerprint = normalizeText(sessCookie);
if (authFingerprint && displayName) {
return {
platformUid: boundedIdentity("smzdm-sess", authFingerprint),
displayName,
avatarUrl: normalizeRemoteUrl(pageState?.avatarUrl),
};
}
if (pageState?.loggedInPage && pageState.displayName) {
const cookieHeader = await sessionCookieHeader(session, "smzdm.com").catch(() => "");
return {
platformUid: boundedIdentity("smzdm-page", cookieHeader || pageState.displayName),
displayName: pageState.displayName,
avatarUrl: normalizeRemoteUrl(pageState.avatarUrl),
};
}
return null;
}
async function detectSmzdm({ session, webContents }: DetectContext): Promise<DetectedAccount | null> {
const currentUserUrl = "https://zhiyou.smzdm.com/user/info/jsonp_get_current";
const pageState = await readSmzdmPageState(webContents).catch(() => null);
const pageResponse = await pageFetchJson<SmzdmCurrentUser>(
webContents,
currentUserUrl,
{
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
},
},
).catch(() => null);
const fromPage = smzdmAccountFromCurrentUser(pageResponse, pageState);
if (fromPage) {
return fromPage;
}
const sessionResponse = await sessionFetchJson<SmzdmCurrentUser>(
session,
currentUserUrl,
{
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
referer: "https://zhiyou.smzdm.com/user",
origin: "https://zhiyou.smzdm.com",
},
},
).catch(() => null);
const fromSession = smzdmAccountFromCurrentUser(sessionResponse, pageState);
if (fromSession) {
return fromSession;
}
const cookieResponse = await sessionCookieFetchJson<SmzdmCurrentUser>(
session,
currentUserUrl,
{
headers: {
accept: "application/json, text/plain, */*",
},
},
).catch(() => null);
const fromCookieRequest = smzdmAccountFromCurrentUser(cookieResponse, pageState);
if (fromCookieRequest) {
return fromCookieRequest;
}
return await deriveSmzdmCookieAccount(session, pageState);
}
async function detectWeixinGzh({ session }: DetectContext): Promise<DetectedAccount | null> {
const html = await sessionFetchText(session, "https://mp.weixin.qq.com/").catch(() => "");
if (!html) {
@@ -0,0 +1,686 @@
import { createHash, createHmac } from "node:crypto";
import type { JsonValue } from "@geo/shared-types";
import {
extractImageSources,
normalizeArticleHtml,
normalizeRemoteUrl,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
} from "./common";
import {
cropImageAssetBlob,
fetchImageAssetBlob,
type ImageAssetBlob,
} from "./media-image";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type DongchediAccountInfoResponse = {
data?: {
user_id_str?: string;
name?: string;
avatar_url?: string;
};
};
type DongchediUploadAuthResponse = {
data?: {
token?: DongchediImageXToken;
};
};
type DongchediImageXToken = {
AccessKeyId?: string;
SecretAccessKey?: string;
SessionToken?: string;
CurrentTime?: string;
};
type DongchediImageXApplyResponse = {
Result?: {
UploadAddress?: {
StoreInfos?: Array<{
StoreUri?: string;
Auth?: string;
}>;
UploadHosts?: string[];
SessionKey?: string;
};
};
};
type DongchediImageURLResponse = {
data?: {
img_url_map?: Record<string, {
main_url?: string;
}>;
};
};
type DongchediSpiceImageResponse = {
data?: {
image_url?: string;
};
};
type DongchediPublishResponse = {
message?: string;
data?: {
message?: string;
data?: {
pgc_id?: string | number;
};
};
};
type DongchediAccount = {
uid: string;
name: string;
};
type DongchediUploadedImage = {
uri: string;
url: string;
width: number;
height: number;
};
const DONGCHEDI_ORIGIN = "https://mp.dcdapp.com";
const DONGCHEDI_IMAGE_X_ORIGIN = "https://imagex.bytedanceapi.com";
const DONGCHEDI_SERVICE_ID = "f042mdwyw7";
const DONGCHEDI_MANAGE_URL = `${DONGCHEDI_ORIGIN}/profile_v2/content/manage/article`;
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function dongchediResponseMessage(response: DongchediPublishResponse | null | undefined): string {
return response?.data?.message || response?.message || "dongchedi_publish_failed";
}
function isDongchediChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控|访问频繁)/i
.test(message);
}
async function dongchediHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await sessionCookieHeader(context.session, "dongchedi.com").catch(() => "");
return {
accept: "application/json, text/plain, */*",
origin: DONGCHEDI_ORIGIN,
referer: DONGCHEDI_ORIGIN,
...(cookie ? { cookie } : {}),
...extra,
};
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
const text = await response.text();
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`);
}
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
async function fetchAccount(context: PublishAdapterContext): Promise<DongchediAccount | null> {
const url = new URL(`${DONGCHEDI_ORIGIN}/passport/account/info/v2/`);
url.searchParams.set("aid", "2302");
url.searchParams.set("account_sdk_source", "web");
const response = await sessionFetchJson<DongchediAccountInfoResponse>(
context.session,
url.toString(),
{
credentials: "include",
headers: await dongchediHeaders(context),
},
).catch(() => null);
const user = response?.data;
return user?.user_id_str && user.name ? { uid: user.user_id_str, name: user.name } : null;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function hmacSha256(key: Buffer | string, value: string): Buffer {
return createHmac("sha256", key).update(value).digest();
}
function formatAmzDate(date: Date): string {
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
}
function formatDateStamp(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
function signAWS4(input: {
method: string;
url: string;
accessKeyId: string;
secretAccessKey: string;
securityToken?: string;
region?: string;
service?: string;
headers?: Record<string, string>;
body?: string;
date?: Date;
}): Record<string, string> {
const region = input.region ?? "cn-north-1";
const service = input.service ?? "imagex";
const body = input.body ?? "";
const url = new URL(input.url);
const now = input.date ?? new Date();
const amzDate = formatAmzDate(now);
const dateStamp = formatDateStamp(now);
const query = [...url.searchParams.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
const signedHeaders: Record<string, string> = {
"x-amz-date": amzDate,
...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}),
...(input.headers ?? {}),
};
const signedHeaderNames = Object.keys(signedHeaders)
.map((key) => key.toLowerCase())
.sort()
.join(";");
const canonicalHeaders = Object.entries(signedHeaders)
.map(([key, value]) => `${key.toLowerCase()}:${value.trim()}`)
.sort()
.join("\n") + "\n";
const canonicalRequest = [
input.method.toUpperCase(),
url.pathname || "/",
query,
canonicalHeaders,
signedHeaderNames,
sha256Hex(body),
].join("\n");
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
sha256Hex(canonicalRequest),
].join("\n");
const kDate = hmacSha256(`AWS4${input.secretAccessKey}`, dateStamp);
const kRegion = hmacSha256(kDate, region);
const kService = hmacSha256(kRegion, service);
const kSigning = hmacSha256(kService, "aws4_request");
const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
return {
authorization:
`AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames}, Signature=${signature}`,
"x-amz-date": amzDate,
...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}),
};
}
function crc32(data: Uint8Array): string {
let crc = 0xFFFFFFFF;
const table = crc32Table();
for (const byte of data) {
crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xFF];
}
return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
}
let cachedCrc32Table: Uint32Array | null = null;
function crc32Table(): Uint32Array {
if (cachedCrc32Table) {
return cachedCrc32Table;
}
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value;
}
cachedCrc32Table = table;
return table;
}
async function fetchUploadToken(context: PublishAdapterContext): Promise<Required<DongchediImageXToken>> {
const response = await sessionFetchJson<DongchediUploadAuthResponse>(
context.session,
`${DONGCHEDI_ORIGIN}/motor/car_page/v6/img/get_upload_auth`,
{
credentials: "include",
headers: await dongchediHeaders(context),
},
).catch(() => null);
const token = response?.data?.token;
if (!token?.AccessKeyId || !token.SecretAccessKey || !token.SessionToken) {
throw new Error("dongchedi_image_token_failed");
}
return {
AccessKeyId: token.AccessKeyId,
SecretAccessKey: token.SecretAccessKey,
SessionToken: token.SessionToken,
CurrentTime: token.CurrentTime || new Date().toISOString(),
};
}
async function prepareImage(source: ImageAssetBlob, ratio?: number): Promise<ImageAssetBlob | null> {
if (!ratio) {
return source;
}
return await cropImageAssetBlob(source, ratio);
}
async function uploadImage(
context: PublishAdapterContext,
sourceUrl: string,
ratio?: number,
): Promise<DongchediUploadedImage | null> {
const source = await fetchImageAssetBlob(sourceUrl);
if (!source) {
throw new Error(ratio ? "dongchedi_cover_fetch_failed" : "dongchedi_image_fetch_failed");
}
const image = await prepareImage(source, ratio);
if (!image) {
throw new Error(ratio ? "dongchedi_cover_fetch_failed" : "dongchedi_image_fetch_failed");
}
const token = await fetchUploadToken(context);
const nonce = Math.random().toString(36).slice(2);
const applyUrl = new URL(`${DONGCHEDI_IMAGE_X_ORIGIN}/`);
applyUrl.searchParams.set("Action", "ApplyImageUpload");
applyUrl.searchParams.set("Version", "2018-08-01");
applyUrl.searchParams.set("ServiceId", DONGCHEDI_SERVICE_ID);
applyUrl.searchParams.set("s", nonce);
const signedApplyHeaders = signAWS4({
method: "GET",
url: applyUrl.toString(),
accessKeyId: token.AccessKeyId,
secretAccessKey: token.SecretAccessKey,
securityToken: token.SessionToken,
date: new Date(token.CurrentTime),
});
const applyResponse = await mainFetchJson<DongchediImageXApplyResponse>(
applyUrl.toString(),
{
headers: signedApplyHeaders,
},
).catch((error) => {
throw new Error(error instanceof Error ? `dongchedi_image_apply_upload_failed:${error.message}` : "dongchedi_image_apply_upload_failed");
});
const uploadAddress = applyResponse.Result?.UploadAddress;
const storeInfo = uploadAddress?.StoreInfos?.[0];
const uploadHost = uploadAddress?.UploadHosts?.[0];
const storeUri = storeInfo?.StoreUri?.trim() || "";
if (!storeUri || !storeInfo?.Auth || !uploadHost || !uploadAddress?.SessionKey) {
throw new Error("dongchedi_image_apply_upload_failed");
}
const buffer = await image.blob.arrayBuffer();
const putResponse = await fetch(`https://${uploadHost}/${storeUri}`, {
method: "PUT",
headers: {
"content-disposition": 'attachment; filename="undefined"',
"content-type": "application/octet-stream",
"content-crc32": crc32(new Uint8Array(buffer)),
authorization: storeInfo.Auth,
},
body: image.blob,
});
if (!putResponse.ok) {
const text = await putResponse.text().catch(() => "");
throw new Error(text || `dongchedi_image_tos_upload_failed_${putResponse.status}`);
}
const commitUrl = new URL(DONGCHEDI_IMAGE_X_ORIGIN);
commitUrl.searchParams.set("Action", "CommitImageUpload");
commitUrl.searchParams.set("Version", "2018-08-01");
commitUrl.searchParams.set("SessionKey", uploadAddress.SessionKey);
commitUrl.searchParams.set("ServiceId", DONGCHEDI_SERVICE_ID);
const signedCommitHeaders = signAWS4({
method: "POST",
url: commitUrl.toString(),
accessKeyId: token.AccessKeyId,
secretAccessKey: token.SecretAccessKey,
securityToken: token.SessionToken,
});
await mainFetchJson<Record<string, unknown>>(
commitUrl.toString(),
{
method: "POST",
headers: signedCommitHeaders,
},
).catch((error) => {
throw new Error(error instanceof Error ? `dongchedi_image_commit_upload_failed:${error.message}` : "dongchedi_image_commit_upload_failed");
});
const imageUrlResponse = await sessionFetchJson<DongchediImageURLResponse>(
context.session,
`${DONGCHEDI_ORIGIN}/motor/car_page/v6/img/get_url`,
{
method: "POST",
credentials: "include",
headers: await dongchediHeaders(context, {
"content-type": "application/x-www-form-urlencoded;charset=UTF-8",
}),
body: new URLSearchParams({
img_uris: storeUri,
img_url_type: "2",
img_param: "noop",
img_format: "image",
}),
},
).catch(() => null);
const imageUrl = imageUrlResponse?.data?.img_url_map?.[storeUri]?.main_url?.trim() || "";
if (!imageUrl) {
throw new Error("dongchedi_image_url_failed");
}
return {
uri: storeUri,
url: imageUrl,
width: image.width,
height: image.height,
};
}
async function spiceImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
const imageUrl = normalizeRemoteUrl(sourceUrl);
if (!imageUrl) {
return null;
}
const response = await sessionFetchJson<DongchediSpiceImageResponse>(
context.session,
`${DONGCHEDI_ORIGIN}/spice/image?sk=dcd`,
{
method: "POST",
credentials: "include",
headers: await dongchediHeaders(context, {
"content-type": "application/x-www-form-urlencoded;charset=utf-8",
}),
body: new URLSearchParams({
imageUrl,
}),
},
).catch(() => null);
return response?.data?.image_url?.trim() || null;
}
async function uploadContentImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
const spiced = await spiceImage(context, sourceUrl);
if (spiced) {
return spiced;
}
const uploaded = await uploadImage(context, sourceUrl).catch(() => null);
return uploaded?.url ?? null;
}
function htmlTextCount(html: string): number {
return html
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/\s+/g, "")
.length;
}
function buildPublishBody(
title: string,
content: string,
verticalCover: DongchediUploadedImage,
feedCover: DongchediUploadedImage,
): Record<string, unknown> {
return {
extra: {
article_ad_type: 3,
timer_status: 0,
timer_time: "",
vertical_cover_image: JSON.stringify({
uri: verticalCover.uri,
height: verticalCover.height,
width: verticalCover.width,
is_ai_cover: false,
}),
pgc_feed_covers: [
{
url: feedCover.url,
uri: feedCover.uri,
thumb_width: feedCover.width,
thumb_height: feedCover.height,
},
],
content_word_cnt: htmlTextCount(content),
title_id: "",
},
save: 1,
title,
content,
source: 20,
};
}
function buildResultPayload(articleId: string, mediaName: string): Record<string, JsonValue> {
return {
platform: "dongchedi",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: DONGCHEDI_MANAGE_URL,
external_article_url: `https://www.dongchedi.com/article/${encodeURIComponent(articleId)}`,
publish_type: "publish",
};
}
function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "dongchedi_not_logged_in") {
return {
status: "failed",
summary: "懂车帝登录态失效,无法执行发布。",
error: {
code: "dongchedi_not_logged_in",
message,
},
};
}
if (message === "publish_cover_required") {
return {
status: "failed",
summary: "懂车帝发布必须上传封面图。",
error: {
code: "publish_cover_required",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到懂车帝。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message === "dongchedi_cover_fetch_failed" || message.startsWith("dongchedi_cover_fetch_failed:")) {
return {
status: "failed",
summary: "懂车帝封面文件读取失败,请重新上传封面图后重试。",
error: {
code: "dongchedi_cover_fetch_failed",
message,
},
};
}
if (message === "dongchedi_image_fetch_failed" || message.startsWith("dongchedi_image_fetch_failed:")) {
return {
status: "failed",
summary: "懂车帝正文图片读取失败,请检查文章图片后重试。",
error: {
code: "dongchedi_image_fetch_failed",
message,
},
};
}
if (message.startsWith("dongchedi_cover_upload_failed")) {
return {
status: "failed",
summary: "懂车帝封面上传失败,请稍后重试或更换封面图。",
error: {
code: "dongchedi_cover_upload_failed",
message,
},
};
}
if (
message.startsWith("dongchedi_image_token_failed")
|| message.startsWith("dongchedi_image_apply_upload_failed")
|| message.startsWith("dongchedi_image_tos_upload_failed")
|| message.startsWith("dongchedi_image_commit_upload_failed")
|| message.startsWith("dongchedi_image_url_failed")
) {
return {
status: "failed",
summary: "懂车帝图片上传失败,请稍后重试或更换图片。",
error: {
code: "dongchedi_image_upload_failed",
message,
},
};
}
if (message.startsWith("dongchedi_challenge_required") || isDongchediChallengeMessage(message)) {
return {
status: "failed",
summary: "懂车帝触发平台验证,请在懂车帝后台完成验证后重试。",
error: {
code: "dongchedi_challenge_required",
message,
},
};
}
return {
status: "failed",
summary: "懂车帝发布失败。",
error: {
code: "dongchedi_publish_failed",
message,
},
};
}
export const dongchediAdapter: PublishAdapter = {
platform: "dongchedi",
executionMode: "session",
async publish(context) {
try {
context.reportProgress("dongchedi.detect_login");
const account = await fetchAccount(context);
if (!account) {
throw new Error("dongchedi_not_logged_in");
}
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
if (!coverAssetUrl) {
throw new Error("publish_cover_required");
}
let html = normalizeArticleHtml(context.article);
if (!html) {
throw new Error("article_content_empty");
}
if (extractImageSources(html).length === 0) {
html = `<img src="${coverAssetUrl}" />${html}`;
}
context.reportProgress("dongchedi.upload_cover");
const verticalCover = await uploadImage(context, coverAssetUrl, 3 / 4);
const feedCover = await uploadImage(context, coverAssetUrl, 4 / 3);
if (!verticalCover || !feedCover) {
throw new Error("dongchedi_cover_upload_failed");
}
context.reportProgress("dongchedi.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadContentImage(context, sourceUrl));
context.reportProgress("dongchedi.submit");
const title = context.article.title?.trim() || "未命名文章";
const response = await sessionFetchJson<DongchediPublishResponse>(
context.session,
`${DONGCHEDI_ORIGIN}/motor/content_publish/publish_mp_article/v1`,
{
method: "POST",
credentials: "include",
headers: await dongchediHeaders(context, {
"content-type": "application/json",
}),
body: JSON.stringify(buildPublishBody(title, processed.html, verticalCover, feedCover)),
},
).catch((error): DongchediPublishResponse => ({
message: error instanceof Error ? error.message : "dongchedi_publish_failed",
}));
const articleId = stringId(response.data?.data?.pgc_id);
if (!articleId) {
const message = dongchediResponseMessage(response);
if (isDongchediChallengeMessage(message)) {
throw new Error(`dongchedi_challenge_required:${message}`);
}
throw new Error(message || "dongchedi_publish_failed");
}
return {
status: "succeeded",
payload: buildResultPayload(articleId, account.name),
summary: "懂车帝发布成功。",
};
} catch (error) {
return failureResult(error);
}
},
};
@@ -2,15 +2,22 @@ export * from "./base";
export * from "./baijiahao";
export * from "./bilibili";
export * from "./deepseek";
export * from "./dongchedi";
export * from "./doubao";
export * from "./jianshu";
export * from "./juejin";
export * from "./kimi";
export * from "./qiehao";
export * from "./qwen";
export * from "./smzdm";
export * from "./sohuhao";
export * from "./toutiao";
export * from "./wangyihao";
export * from "./wenxin";
export * from "./weixin-gzh";
export * from "./yuanbao";
export * from "./zhihu";
export * from "./zol";
import { deepseekAdapter } from "./deepseek";
import { doubaoAdapter } from "./doubao";
@@ -0,0 +1,984 @@
import { createHash, createHmac, randomUUID } from "node:crypto";
import type { JsonValue } from "@geo/shared-types";
import { resolveDesktopApiURL } from "../transport/api-client";
import {
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
sessionFetchJson,
} from "./common";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type JuejinPublishType = "publish" | "draft";
type JuejinProfileResponse = {
data?: {
bui_user?: {
user_id?: string;
screen_name?: string;
avatar_large?: string;
avatar_url?: string;
};
};
};
type JuejinProfileUser = NonNullable<NonNullable<JuejinProfileResponse["data"]>["bui_user"]>;
type JuejinDraftCreateResponse = {
err_no?: number;
err_msg?: string;
data?: {
id?: string | number;
};
};
type JuejinArticleListResponse = {
err_no?: number;
err_msg?: string;
data?: unknown;
};
type JuejinImageXTokenResponse = {
err_no?: number;
err_msg?: string;
data?: {
token?: {
AccessKeyId?: string;
SecretAccessKey?: string;
SessionToken?: string;
ExpiredTime?: string;
};
};
};
type JuejinImageXToken = {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
};
type JuejinImageXApplyUploadResponse = {
Result?: {
UploadAddress?: {
StoreInfos?: Array<{
StoreUri?: string;
Auth?: string;
}>;
UploadHosts?: string[];
SessionKey?: string;
};
};
};
type JuejinImageXCommitUploadResponse = {
Result?: {
Results?: Array<{
Uri?: string;
UriStatus?: number;
}>;
};
};
type JuejinImageURLResponse = {
err_no?: number;
err_msg?: string;
data?: {
main_url?: string;
backup_url?: string;
};
};
type JuejinEditorResult = {
success: boolean;
message?: string;
url?: string;
};
type JuejinPublishedArticle = {
id: string;
url: string;
};
const JUEJIN_ORIGIN = "https://juejin.cn";
const JUEJIN_API_ORIGIN = "https://api.juejin.cn";
const JUEJIN_IMAGEX_ORIGIN = "https://imagex.bytedanceapi.com";
const JUEJIN_IMAGEX_AID = "2608";
const JUEJIN_IMAGEX_SERVICE_ID = "73owjymdk6";
const JUEJIN_DEFAULT_CATEGORY_ID = "6809637772874219534";
const JUEJIN_DEFAULT_TAG_IDS = ["6809641083107016712", "6809640681271721997"];
const JUEJIN_IMAGE_SKIP_PATTERNS = [
"juejin.cn",
"p1-juejin",
"p3-juejin",
"p6-juejin",
"p9-juejin",
"byteimg.com",
];
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function juejinPostURL(articleId: string): string {
return `${JUEJIN_ORIGIN}/post/${encodeURIComponent(articleId)}`;
}
function articleFromJuejinURL(value?: string | null): JuejinPublishedArticle | null {
const match = /\/post\/(\d+)/.exec(value || "");
if (!match?.[1]) {
return null;
}
return {
id: match[1],
url: juejinPostURL(match[1]),
};
}
function normalizePublishType(payload: Record<string, unknown>): JuejinPublishType {
return payload.publish_type === "draft" || payload.publishType === "draft" ? "draft" : "publish";
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => stringId(item)).filter(Boolean);
}
function juejinHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
return sessionCookieHeader(context.session, "juejin.cn")
.catch(() => "")
.then((cookie) => ({
accept: "application/json, text/plain, */*",
origin: JUEJIN_ORIGIN,
referer: `${JUEJIN_ORIGIN}/`,
...(cookie ? { cookie } : {}),
...extra,
}));
}
async function fetchProfile(context: PublishAdapterContext): Promise<JuejinProfileUser | null> {
const response = await sessionFetchJson<JuejinProfileResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/user_api/v1/user/get_profile`,
{
method: "POST",
credentials: "include",
headers: await juejinHeaders(context, {
"content-type": "application/json",
}),
body: "{}",
},
).catch(() => null);
return response?.data?.bui_user ?? null;
}
async function fetchCsrfToken(context: PublishAdapterContext): Promise<string | null> {
const headers = await juejinHeaders(context, {
"x-secsdk-csrf-request": "1",
"x-secsdk-csrf-version": "1.2.10",
});
const response = await context.session.fetch(`${JUEJIN_API_ORIGIN}/user_api/v1/sys/token`, {
method: "HEAD",
credentials: "include",
headers,
}).catch(() => null);
const token = response?.headers.get("x-ware-csrf-token") ?? "";
const parts = token.split(",");
return parts.length >= 2 && parts[1] ? parts[1] : null;
}
function extractMarkdownImageSources(markdown: string): string[] {
const sources = new Set<string>();
for (const match of markdown.matchAll(/!\[[^\]]*]\((?:<([^>\s]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g)) {
const source = (match[1] || match[2] || "").trim();
if (source) {
sources.add(source);
}
}
for (const match of markdown.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
const source = match[2]?.trim();
if (source) {
sources.add(source);
}
}
return [...sources];
}
function shouldSkipImageSource(source: string): boolean {
return JUEJIN_IMAGE_SKIP_PATTERNS.some((pattern) => source.includes(pattern));
}
async function uploadMarkdownImages(
markdown: string,
uploader: (sourceUrl: string) => Promise<string | null>,
): Promise<string> {
const sources = extractMarkdownImageSources(markdown);
if (!sources.length) {
return markdown;
}
const uploaded = new Map<string, string>();
for (const source of sources) {
if (shouldSkipImageSource(source)) {
continue;
}
const target = await uploader(source);
if (target) {
uploaded.set(source, target);
}
}
let next = markdown;
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to);
}
return next;
}
function stripHtml(value: string): string {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(?:p|div|section|article|h[1-6]|li|tr)>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function htmlTableToMarkdown(tableHtml: string): string {
const rows: string[][] = [];
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells: string[] = [];
for (const cellMatch of (rowMatch[1] ?? "").matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/gi)) {
cells.push(stripHtml(cellMatch[1] ?? "").replace(/\|/g, "\\|"));
}
if (cells.length > 0) {
rows.push(cells);
}
}
if (!rows.length) {
return "";
}
const width = Math.max(...rows.map((row) => row.length));
const normalizeRow = (row: string[]) => {
const cells = Array.from({ length: width }, (_item, index) => row[index] ?? "");
return `| ${cells.join(" | ")} |`;
};
return [
normalizeRow(rows[0] ?? []),
normalizeRow(Array.from({ length: width }, () => "---")),
...rows.slice(1).map(normalizeRow),
].join("\n");
}
function htmlToMarkdown(html: string): string {
let next = html.trim();
next = next.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => `\n\n${htmlTableToMarkdown(tableHtml)}\n\n`);
next = next.replace(/<img\b[^>]*alt=(['"])(.*?)\1[^>]*src=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, alt, _q2, src) =>
`\n![${stripHtml(alt)}](${String(src).trim()})\n`,
);
next = next.replace(/<img\b[^>]*src=(['"])(.*?)\1[^>]*alt=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, src, _q2, alt) =>
`\n![${stripHtml(alt)}](${String(src).trim()})\n`,
);
next = next.replace(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi, (_match, _q, src) => `\n![](${String(src).trim()})\n`);
next = next.replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, (_match, content) => `\n# ${stripHtml(content)}\n`);
next = next.replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, (_match, content) => `\n## ${stripHtml(content)}\n`);
next = next.replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, (_match, content) => `\n### ${stripHtml(content)}\n`);
next = next.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, content) => `\n- ${stripHtml(content)}`);
next = next.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_match, content) => `\n${stripHtml(content)}\n`);
return stripHtml(next);
}
function articleMarkdown(context: PublishAdapterContext): string {
const markdown = context.article.markdown_content?.trim();
if (markdown) {
return markdown;
}
const html = context.article.html_content?.trim();
if (html) {
return htmlToMarkdown(normalizeArticleHtml(context.article));
}
return "";
}
function buildAssetURLCandidates(sourceUrl: string): string[] {
const trimmed = sourceUrl.trim();
if (!trimmed) {
return [];
}
if (/^(data|blob):/i.test(trimmed)) {
return [trimmed];
}
const candidates: string[] = [];
const pushCandidate = (value: string) => {
if (!candidates.includes(value)) {
candidates.push(value);
}
};
try {
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
if (parsed.pathname.startsWith("/api/")) {
pushCandidate(new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)).toString());
}
pushCandidate(parsed.toString());
} catch {
pushCandidate(trimmed);
}
return candidates;
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate);
if (blob) {
return blob;
}
}
return null;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function hmacSha256(key: Buffer | string, value: string): Buffer {
return createHmac("sha256", key).update(value).digest();
}
function formatAmzDate(date: Date): string {
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
}
function formatDateStamp(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
function signAWS4(input: {
method: string;
url: string;
accessKeyId: string;
secretAccessKey: string;
securityToken?: string;
region?: string;
service?: string;
headers?: Record<string, string>;
body?: string;
}): Record<string, string> {
const region = input.region ?? "cn-north-1";
const service = input.service ?? "imagex";
const body = input.body ?? "";
const url = new URL(input.url);
const now = new Date();
const amzDate = formatAmzDate(now);
const dateStamp = formatDateStamp(now);
const query = [...url.searchParams.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
const signedHeaders: Record<string, string> = {
"x-amz-date": amzDate,
...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}),
...(input.headers ?? {}),
};
const signedHeaderNames = Object.keys(signedHeaders)
.map((key) => key.toLowerCase())
.sort()
.join(";");
const canonicalHeaders = Object.entries(signedHeaders)
.map(([key, value]) => `${key.toLowerCase()}:${value.trim()}`)
.sort()
.join("\n") + "\n";
const canonicalRequest = [
input.method.toUpperCase(),
url.pathname || "/",
query,
canonicalHeaders,
signedHeaderNames,
sha256Hex(body),
].join("\n");
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
sha256Hex(canonicalRequest),
].join("\n");
const kDate = hmacSha256(`AWS4${input.secretAccessKey}`, dateStamp);
const kRegion = hmacSha256(kDate, region);
const kService = hmacSha256(kRegion, service);
const kSigning = hmacSha256(kService, "aws4_request");
const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
const authorization =
`AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames}, Signature=${signature}`;
return {
authorization,
"x-amz-date": amzDate,
...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}),
};
}
function crc32(data: Uint8Array): string {
let crc = 0xFFFFFFFF;
const table = crc32Table();
for (const byte of data) {
crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xFF];
}
return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
}
let cachedCrc32Table: Uint32Array | null = null;
function crc32Table(): Uint32Array {
if (cachedCrc32Table) {
return cachedCrc32Table;
}
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value;
}
cachedCrc32Table = table;
return table;
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
const text = await response.text();
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`);
}
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
function randomJuejinUUID(): string {
return randomUUID().replace(/-/g, "").slice(0, 16) + Date.now().toString();
}
async function fetchImageXToken(context: PublishAdapterContext, uuid: string): Promise<JuejinImageXToken> {
const response = await sessionFetchJson<JuejinImageXTokenResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/imagex/v2/gen_token?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&client=web`,
{
method: "GET",
credentials: "include",
headers: await juejinHeaders(context),
},
);
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || "juejin_image_token_failed");
}
const token = response.data?.token;
if (!token?.AccessKeyId || !token.SecretAccessKey) {
throw new Error("juejin_image_token_failed");
}
return {
accessKeyId: token.AccessKeyId,
secretAccessKey: token.SecretAccessKey,
sessionToken: token.SessionToken ?? "",
};
}
async function applyImageUpload(token: JuejinImageXToken) {
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`;
const signedHeaders = signAWS4({
method: "GET",
url,
accessKeyId: token.accessKeyId,
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
});
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(url, {
method: "GET",
headers: signedHeaders,
});
const uploadAddress = response.Result?.UploadAddress;
const storeInfo = uploadAddress?.StoreInfos?.[0];
const uploadHost = uploadAddress?.UploadHosts?.[0];
if (!uploadAddress?.SessionKey || !storeInfo?.StoreUri || !storeInfo.Auth || !uploadHost) {
throw new Error("juejin_image_apply_upload_failed");
}
return {
sessionKey: uploadAddress.SessionKey,
storeUri: storeInfo.StoreUri,
auth: storeInfo.Auth,
uploadHost,
};
}
async function uploadToTOS(upload: Awaited<ReturnType<typeof applyImageUpload>>, blob: Blob): Promise<void> {
const buffer = Buffer.from(await blob.arrayBuffer());
const response = await fetch(`https://${upload.uploadHost}/${upload.storeUri}`, {
method: "PUT",
headers: {
authorization: upload.auth,
"content-type": blob.type || "application/octet-stream",
"content-crc32": crc32(new Uint8Array(buffer)),
},
body: blob,
});
if (!response.ok) {
const text = await response.text().catch(() => "");
throw new Error(text || `juejin_image_tos_upload_failed_${response.status}`);
}
}
async function commitImageUpload(token: JuejinImageXToken, sessionKey: string): Promise<void> {
const url =
`${JUEJIN_IMAGEX_ORIGIN}/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`;
const signedHeaders = signAWS4({
method: "POST",
url,
accessKeyId: token.accessKeyId,
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
});
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(url, {
method: "POST",
headers: {
...signedHeaders,
"content-length": "0",
},
});
if (!response.Result) {
throw new Error("juejin_image_commit_upload_failed");
}
}
async function resolveImageURL(context: PublishAdapterContext, uuid: string, storeUri: string): Promise<string> {
const response = await sessionFetchJson<JuejinImageURLResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/imagex/v2/get_img_url?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&uri=${encodeURIComponent(storeUri)}&img_type=private`,
{
method: "GET",
credentials: "include",
headers: await juejinHeaders(context),
},
);
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || "juejin_image_url_failed");
}
const imageUrl = response.data?.main_url || response.data?.backup_url;
if (!imageUrl) {
throw new Error("juejin_image_url_failed");
}
return imageUrl;
}
async function uploadImage(context: PublishAdapterContext, uuid: string, sourceUrl: string): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl);
if (!blob || !blob.type.startsWith("image/")) {
return null;
}
const token = await fetchImageXToken(context, uuid);
const upload = await applyImageUpload(token);
await uploadToTOS(upload, blob);
await commitImageUpload(token, upload.sessionKey);
return await resolveImageURL(context, uuid, upload.storeUri);
}
async function createDraft(
context: PublishAdapterContext,
payload: Record<string, unknown>,
markdown: string,
uuid: string,
): Promise<string> {
const csrfToken = await fetchCsrfToken(context).catch(() => null);
const categoryId = stringId(payload.category_id ?? payload.categoryId) || JUEJIN_DEFAULT_CATEGORY_ID;
const tagIds = normalizeStringArray(payload.tag_ids ?? payload.tagIds);
const coverUrl = context.article.cover_asset_url?.trim()
? await uploadImage(context, uuid, context.article.cover_asset_url).catch(() => null)
: null;
const response = await sessionFetchJson<JuejinDraftCreateResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/content_api/v1/article_draft/create`,
{
method: "POST",
credentials: "include",
headers: await juejinHeaders(context, {
"content-type": "application/json",
...(csrfToken ? { "x-secsdk-csrf-token": csrfToken } : {}),
}),
body: JSON.stringify({
category_id: categoryId,
tag_ids: tagIds.length ? tagIds : JUEJIN_DEFAULT_TAG_IDS,
link_url: "",
cover_image: coverUrl ?? "",
title: context.article.title?.trim() || "未命名文章",
brief_content: "",
edit_type: 10,
html_content: "deprecated",
mark_content: markdown,
theme_ids: [],
pics: [],
}),
},
);
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || "juejin_create_draft_failed");
}
const draftId = stringId(response.data?.id);
if (!draftId) {
throw new Error(response.err_msg || "juejin_create_draft_failed");
}
return draftId;
}
function normalizeCompactText(value: string | null | undefined): string {
return (value || "").trim().replace(/\s+/g, "");
}
async function clickJuejinButtonByText(
page: NonNullable<PublishAdapterContext["playwright"]>["page"],
selectors: string[],
texts: string[],
timeoutMs: number,
): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
for (const selector of selectors) {
const locator = page.locator(selector);
const count = await locator.count().catch(() => 0);
for (let index = 0; index < count; index += 1) {
const candidate = locator.nth(index);
const text = await candidate.innerText({ timeout: 500 })
.catch(async () => await candidate.textContent({ timeout: 500 }).catch(() => ""));
const compactText = normalizeCompactText(text);
if (!texts.some((target) => compactText.includes(normalizeCompactText(target)))) {
continue;
}
await candidate.click({ timeout: 5_000 });
return true;
}
}
await page.waitForTimeout(150);
}
return false;
}
async function publishDraft(context: PublishAdapterContext, draftId: string): Promise<JuejinEditorResult> {
const page = context.playwright?.page;
if (!page) {
return {
success: false,
message: "juejin_playwright_page_missing",
};
}
const draftURL = `${JUEJIN_ORIGIN}/editor/drafts/${encodeURIComponent(draftId)}`;
await page.goto(draftURL, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
const firstClicked = await clickJuejinButtonByText(
page,
[".publish-popup .xitu-btn", ".publish-popup button", "button", ".xitu-btn", "[role='button']"],
["发布"],
20_000,
);
if (!firstClicked) {
return {
success: false,
message: "juejin_publish_button_missing",
url: page.url(),
};
}
await page.waitForTimeout(2_000);
const previousURL = page.url();
const confirmed = await clickJuejinButtonByText(
page,
[".btn-container button", ".byte-modal button", ".semi-modal button", "button", "[role='button']"],
["确定并发布", "确认发布", "确定"],
12_000,
);
if (!confirmed) {
return {
success: false,
message: "juejin_confirm_button_missing",
url: page.url(),
};
}
await Promise.race([
page.waitForURL((url) => url.toString() !== previousURL, { timeout: 6_000 }).catch(() => undefined),
page.waitForLoadState("domcontentloaded", { timeout: 6_000 }).catch(() => undefined),
]);
await page.waitForTimeout(1_500);
return {
success: true,
url: page.url(),
};
}
function findArticleListItems(data: unknown): Array<Record<string, unknown>> {
if (Array.isArray(data)) {
return data.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
}
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
for (const key of ["list", "article_list", "articles"]) {
const value = record[key];
if (Array.isArray(value)) {
return value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
}
}
}
return [];
}
function extractArticleFromListResponse(response: JuejinArticleListResponse | null, draftId: string): JuejinPublishedArticle | null {
const items = findArticleListItems(response?.data);
for (const item of items) {
const articleInfo = item.article_info && typeof item.article_info === "object"
? item.article_info as Record<string, unknown>
: {};
const matchedDraftId = stringId(articleInfo.draft_id ?? item.draft_id);
if (matchedDraftId !== draftId) {
continue;
}
const articleURL = articleFromJuejinURL(
stringId(item.article_url ?? item.url ?? articleInfo.article_url ?? articleInfo.url),
);
const articleId = stringId(item.article_id) || articleURL?.id || stringId(articleInfo.article_id ?? item.id);
if (!articleId) {
return null;
}
return {
id: articleId,
url: articleURL?.url ?? juejinPostURL(articleId),
};
}
return null;
}
async function findPublishedArticle(
context: PublishAdapterContext,
draftId: string,
uuid: string,
): Promise<JuejinPublishedArticle | null> {
for (let attempt = 0; attempt < 6; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => {
setTimeout(resolve, 1_500);
});
}
const response = await sessionFetchJson<JuejinArticleListResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/content_api/v1/article/list_by_user?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&spider=0`,
{
method: "POST",
credentials: "include",
headers: await juejinHeaders(context, {
"content-type": "application/json",
}),
body: JSON.stringify({
audit_status: null,
keyword: "",
page_size: 10,
page_no: 1,
}),
},
).catch(() => null);
const article = extractArticleFromListResponse(response, draftId);
if (article) {
return article;
}
}
return null;
}
function buildResultPayload(
publishType: JuejinPublishType,
draftId: string,
mediaName: string,
externalManageUrl: string,
externalArticleUrl: string | null,
articleId = draftId,
): Record<string, JsonValue> {
return {
platform: "juejin",
media_name: mediaName,
external_article_id: articleId,
draft_id: draftId,
external_manage_url: externalManageUrl,
external_article_url: externalArticleUrl,
publish_type: publishType,
};
}
function buildFailureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "juejin_not_logged_in") {
return {
status: "failed",
summary: "稀土掘金登录态失效,无法执行发布。",
error: {
code: "juejin_not_logged_in",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到稀土掘金。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message === "juejin_create_draft_failed" || message.includes("创建草稿失败")) {
return {
status: "failed",
summary: "稀土掘金草稿创建失败。",
error: {
code: "juejin_create_draft_failed",
message,
},
};
}
if (message === "juejin_playwright_page_missing" || message.includes("button_missing")) {
return {
status: "failed",
summary: "稀土掘金发布确认按钮未就绪,请打开掘金编辑器确认账号状态后重试。",
error: {
code: "juejin_editor_unavailable",
message,
},
};
}
if (message === "juejin_publish_result_missing") {
return {
status: "failed",
summary: "稀土掘金已执行发布,但未在文章列表中查到新文章,请手动检查掘金创作中心。",
error: {
code: "juejin_publish_result_missing",
message,
},
};
}
return {
status: "failed",
summary: "稀土掘金发布失败。",
error: {
code: "juejin_publish_failed",
message,
},
};
}
export const juejinAdapter: PublishAdapter = {
platform: "juejin",
executionMode: "playwright",
async publish(context, payload) {
try {
context.reportProgress("juejin.detect_login");
const user = await fetchProfile(context);
if (!user?.user_id || !user.screen_name) {
throw new Error("juejin_not_logged_in");
}
const uuid = randomJuejinUUID();
let markdown = articleMarkdown(context);
if (!markdown) {
throw new Error("article_content_empty");
}
context.reportProgress("juejin.upload_content_images");
markdown = await uploadMarkdownImages(markdown, async (sourceUrl) =>
await uploadImage(context, uuid, sourceUrl).catch(() => null),
);
context.reportProgress("juejin.create_draft");
const draftId = await createDraft(context, payload, markdown, uuid);
const draftUrl = `${JUEJIN_ORIGIN}/editor/drafts/${encodeURIComponent(draftId)}`;
const publishType = normalizePublishType(payload);
if (publishType === "draft") {
return {
status: "succeeded",
payload: buildResultPayload("draft", draftId, user.screen_name, draftUrl, draftUrl),
summary: "稀土掘金草稿保存成功。",
};
}
context.reportProgress("juejin.submit_editor");
const editorResult = await publishDraft(context, draftId);
if (!editorResult.success) {
throw new Error(editorResult.message || "juejin_publish_failed");
}
context.reportProgress("juejin.resolve_article_url");
const article = articleFromJuejinURL(editorResult.url) ?? await findPublishedArticle(context, draftId, uuid);
if (!article) {
throw new Error("juejin_publish_result_missing");
}
return {
status: "succeeded",
payload: buildResultPayload("publish", draftId, user.screen_name, draftUrl, article.url, article.id),
summary: "稀土掘金发布成功。",
};
} catch (error) {
return buildFailureResult(error);
}
},
};
@@ -0,0 +1,64 @@
import { Buffer } from "node:buffer";
import { describe, expect, it } from "vitest";
import { vi } from "vitest";
vi.mock("electron", () => ({
nativeImage: {
createFromBuffer: () => ({
isEmpty: () => false,
getSize: () => ({ width: 1, height: 1 }),
toPNG: () => Buffer.from([]),
}),
},
}));
vi.mock("../transport/api-client", () => ({
resolveDesktopApiURL: (path: string) => `http://127.0.0.1:8080${path}`,
}));
import {
buildSmzdmAwne,
encodeSmzdmForm,
extractSmzdmArticleIdFromHref,
getSmzdmCoverCropRect,
smzdmArticleUrl,
} from "./smzdm";
describe("smzdm adapter helpers", () => {
it("extracts article id from SMZDM edit links", () => {
expect(extractSmzdmArticleIdFromHref("https://post.smzdm.com/edit/123456/")).toBe("123456");
expect(extractSmzdmArticleIdFromHref("/edit/abc123")).toBe("abc123");
});
it("builds public SMZDM article links from article ids", () => {
expect(smzdmArticleUrl("ak8p8q8e")).toBe("https://post.smzdm.com/p/ak8p8q8e/");
});
it("encodes nested form fields using SMZDM bracket notation", () => {
expect(encodeSmzdmForm({
article_id: "42",
image_list: [
{
pic_url: "//res.smzdm.com/a.png",
original_drawing: "0",
},
],
})).toBe(
"article_id=42&image_list%5B0%5D%5Bpic_url%5D=%2F%2Fres.smzdm.com%2Fa.png&image_list%5B0%5D%5Boriginal_drawing%5D=0",
);
});
it("matches the reference extension awne calculation", () => {
expect(buildSmzdmAwne("4242025233", 123)).toBe("m2At8ovjSz5kmnLNUFEW9g==");
});
it("centers the cover crop at the required SMZDM ratio", () => {
const crop = getSmzdmCoverCropRect(2000, 1000);
expect(crop.src_x).toBeCloseTo(0);
expect(crop.src_y).toBeCloseTo(76.8194, 3);
expect(crop.src_w).toBeCloseTo(2000);
expect(crop.src_h).toBeCloseTo(846.3612, 3);
});
});
@@ -0,0 +1,895 @@
import { Buffer } from "node:buffer";
import { createCipheriv, createHash } from "node:crypto";
import type { JsonValue } from "@geo/shared-types";
import { nativeImage } from "electron";
import { resolveDesktopApiURL } from "../transport/api-client";
import {
extractImageSources,
normalizeArticleHtml,
sessionCookieHeader,
sessionCookieValue,
sessionFetchJson,
} from "./common";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type SmzdmPublishType = "publish";
type SmzdmCurrentUserResponse = {
smzdm_id?: string | number;
nickname?: string;
audit_nickname?: string;
avatar?: string;
data?: {
smzdm_id?: string | number;
nickname?: string;
audit_nickname?: string;
avatar?: string;
};
};
type SmzdmAccount = {
uid: string;
name: string;
};
type SmzdmImageUploadResponse = {
error_code?: number | string;
error_msg?: string;
msg?: string;
data?: {
id?: string | number;
url?: string;
small_pic?: string;
};
};
type SmzdmOriginalImageResponse = {
error_code?: number | string;
error_msg?: string;
data?: {
original_url?: string;
};
};
type SmzdmCropResponse = {
error_code?: number | string;
error_msg?: string;
data?: Array<{
pic_url?: string;
square_pic_url?: string;
}>;
};
type SmzdmSubmitResponse = {
error_code?: number | string;
error_msg?: string;
data?: {
update_topic?: {
error_code?: string | number;
error_msg?: string;
};
};
};
type SmzdmImageBlob = {
blob: Blob;
width: number;
height: number;
};
type SmzdmUploadedImage = {
id: string;
origin: string;
small: string;
};
type SmzdmCropRect = {
src_x: number;
src_y: number;
src_w: number;
src_h: number;
size_w: number;
size_h: number;
};
const SMZDM_ORIGIN = "https://post.smzdm.com";
const SMZDM_ZHIYOU_ORIGIN = "https://zhiyou.smzdm.com";
const SMZDM_TOU_GAO_URL = `${SMZDM_ORIGIN}/tougao/`;
const SMZDM_MANAGE_URL = `${SMZDM_ORIGIN}/my/publish/`;
const SMZDM_STEP_DELAY_MS = 1200;
const SMZDM_COVER_RATIO = 1484 / 628;
const SMZDM_COVER_SIZE_W = 416;
const SMZDM_COVER_SIZE_H = 178;
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function normalizeSmzdmUid(value: unknown): string {
const uid = stringId(value);
return uid && uid !== "0" ? uid : "";
}
function decodeCookieText(value: string): string {
const source = value.replace(/\+/g, " ");
try {
return decodeURIComponent(source);
} catch {
return source;
}
}
function smzdmResponseMessage(response: {
error_code?: string | number;
error_msg?: string;
msg?: string;
} | null | undefined): string {
return response?.error_msg || response?.msg || `smzdm_error_${response?.error_code ?? "unknown"}`;
}
function isSuccessfulCode(value: unknown): boolean {
return value === 0 || value === "0";
}
function isSmzdmChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风控|风险)/i
.test(message);
}
async function waitForHumanPace(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
throw new Error("adapter_aborted");
}
return new Promise((resolve, reject) => {
const cleanup = () => {
signal?.removeEventListener("abort", onAbort);
};
const timeout = setTimeout(() => {
cleanup();
resolve();
}, SMZDM_STEP_DELAY_MS);
const onAbort = () => {
clearTimeout(timeout);
cleanup();
reject(new Error("adapter_aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function smzdmCookieHeader(context: PublishAdapterContext): Promise<string> {
return await sessionCookieHeader(context.session, "smzdm.com").catch(() => "");
}
async function smzdmHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await smzdmCookieHeader(context);
return {
accept: "application/json, text/plain, */*",
origin: SMZDM_ORIGIN,
referer: SMZDM_TOU_GAO_URL,
...(cookie ? { cookie } : {}),
...extra,
};
}
async function fetchCurrentAccount(context: PublishAdapterContext): Promise<SmzdmAccount | null> {
const response = await sessionFetchJson<SmzdmCurrentUserResponse>(
context.session,
`${SMZDM_ZHIYOU_ORIGIN}/user/info/jsonp_get_current`,
{
credentials: "include",
headers: {
accept: "application/json, text/plain, */*",
origin: SMZDM_ZHIYOU_ORIGIN,
referer: `${SMZDM_ZHIYOU_ORIGIN}/user`,
},
},
).catch(() => null);
const user = response?.data ?? response;
const uid = normalizeSmzdmUid(user?.smzdm_id);
const name = user?.nickname?.trim() || user?.audit_nickname?.trim() || "";
if (uid) {
return {
uid,
name: name || `值友${uid}`,
};
}
const cookieUid = normalizeSmzdmUid(await sessionCookieValue(context.session, "smzdm.com", "smzdm_id").catch(() => ""));
const cookieUser = decodeCookieText(await sessionCookieValue(context.session, "smzdm.com", "user").catch(() => ""));
const cookieName = cookieUser.trim();
if (!cookieUid) {
return null;
}
return {
uid: cookieUid,
name: cookieName || `值友${cookieUid}`,
};
}
export function extractSmzdmArticleIdFromHref(value: string | null | undefined): string {
const href = value?.trim() || "";
if (!href) {
return "";
}
const pathname = (() => {
try {
return new URL(href, SMZDM_ORIGIN).pathname;
} catch {
return href;
}
})();
return pathname.split("/").filter(Boolean).pop() ?? "";
}
async function getArticleId(context: PublishAdapterContext): Promise<string> {
const page = context.playwright?.page;
if (!page) {
throw new Error("smzdm_playwright_page_missing");
}
await page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
const href = await page.evaluate(() => {
const link =
document.querySelector<HTMLAnchorElement>("a.release-new")
?? Array.from(document.querySelectorAll<HTMLAnchorElement>("a[href*='/edit/']"))[0]
?? null;
return link?.getAttribute("href") || link?.href || "";
}).catch(() => "");
const articleId = extractSmzdmArticleIdFromHref(href);
if (!articleId) {
const currentUrl = page.url();
if (/\/user\/login/i.test(currentUrl)) {
throw new Error("smzdm_not_logged_in");
}
throw new Error("smzdm_article_id_missing");
}
return articleId;
}
function buildAssetURLCandidates(sourceUrl: string): string[] {
const trimmed = sourceUrl.trim();
if (!trimmed) {
return [];
}
if (/^(data|blob):/i.test(trimmed)) {
return [trimmed];
}
const candidates: string[] = [];
const pushCandidate = (value: string) => {
if (!candidates.includes(value)) {
candidates.push(value);
}
};
try {
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
if (parsed.pathname.startsWith("/api/")) {
const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`));
if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) {
apiAssetUrl.searchParams.set("format", "png");
}
pushCandidate(apiAssetUrl.toString());
}
pushCandidate(parsed.toString());
} catch {
pushCandidate(trimmed);
}
return candidates;
}
async function fetchSmzdmImageBlob(sourceUrl: string): Promise<SmzdmImageBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null);
if (!response?.ok) {
continue;
}
const sourceBlob = await response.blob().catch(() => null);
if (!sourceBlob || !sourceBlob.type.startsWith("image/")) {
continue;
}
const buffer = Buffer.from(await sourceBlob.arrayBuffer());
const image = nativeImage.createFromBuffer(buffer);
if (image.isEmpty()) {
continue;
}
const size = image.getSize();
const normalizedBlob = sourceBlob.type.toLowerCase() === "image/webp"
? new Blob([new Uint8Array(image.toPNG())], { type: "image/png" })
: sourceBlob;
return {
blob: normalizedBlob,
width: size.width,
height: size.height,
};
}
return null;
}
export function getSmzdmCoverCropRect(width: number, height: number, ratio = SMZDM_COVER_RATIO): SmzdmCropRect {
let srcW = width;
let srcH = srcW / ratio;
if (srcH > height) {
srcH = height;
srcW = srcH * ratio;
}
const srcX = (width - srcW) / 2;
const srcY = (height - srcH) / 2;
const scale = Math.min(SMZDM_COVER_SIZE_W / width, SMZDM_COVER_SIZE_H / height);
return {
src_x: srcX,
src_y: srcY,
src_w: srcW,
src_h: srcH,
size_w: srcW * scale,
size_h: srcH * scale,
};
}
async function uploadImage(
context: PublishAdapterContext,
sourceUrl: string,
articleId: string,
cropCover: boolean,
): Promise<SmzdmUploadedImage | null> {
const image = await fetchSmzdmImageBlob(sourceUrl);
if (!image) {
throw new Error(cropCover ? "smzdm_cover_fetch_failed" : "smzdm_image_fetch_failed");
}
const form = new FormData();
form.append("imgFile", image.blob, "image.png");
form.append("id", "WU_FILE_0");
form.append("type", image.blob.type || "image/png");
form.append("article_id", articleId);
const uploaded = await sessionFetchJson<SmzdmImageUploadResponse>(
context.session,
`${SMZDM_ORIGIN}/api/images/upload/local`,
{
method: "POST",
credentials: "include",
headers: await smzdmHeaders(context),
body: form,
},
).catch((error): SmzdmImageUploadResponse => ({
error_code: -1,
error_msg: error instanceof Error ? error.message : "smzdm_image_upload_failed",
}));
if (!isSuccessfulCode(uploaded.error_code) || !uploaded.data?.url) {
const message = smzdmResponseMessage(uploaded);
if (isSmzdmChallengeMessage(message)) {
throw new Error(`smzdm_challenge_required:${message}`);
}
throw new Error(`${cropCover ? "smzdm_cover_upload_failed" : "smzdm_image_upload_failed"}:${message}`);
}
const uploadedId = stringId(uploaded.data.id);
if (uploadedId) {
void sessionFetchJson(
context.session,
`${SMZDM_ORIGIN}/api/editor/image_add_time/record`,
{
method: "POST",
credentials: "include",
headers: await smzdmHeaders(context, {
"content-type": "application/x-www-form-urlencoded",
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
},
).catch(() => null);
}
if (!cropCover) {
return {
id: uploadedId,
origin: uploaded.data.url,
small: uploaded.data.small_pic || uploaded.data.url,
};
}
const original = await sessionFetchJson<SmzdmOriginalImageResponse>(
context.session,
`${SMZDM_ORIGIN}/api/image/original`,
{
method: "POST",
credentials: "include",
headers: await smzdmHeaders(context, {
"content-type": "application/x-www-form-urlencoded",
}),
body: new URLSearchParams({
article_id: articleId,
pic_url: uploaded.data.url,
}),
},
).catch(() => null);
const crop = getSmzdmCoverCropRect(image.width, image.height);
const cropForm = new FormData();
cropForm.append("cut_pic_list[0][src_x]", String(crop.src_x));
cropForm.append("cut_pic_list[0][src_y]", String(crop.src_y));
cropForm.append("cut_pic_list[0][src_w]", String(crop.src_w));
cropForm.append("cut_pic_list[0][src_h]", String(crop.src_h));
cropForm.append("cut_pic_list[0][article_id]", articleId);
cropForm.append("cut_pic_list[0][size_w]", String(crop.size_w));
cropForm.append("cut_pic_list[0][size_h]", String(crop.size_h));
cropForm.append("cut_pic_list[0][cropperData]", JSON.stringify({
x: crop.src_x,
y: crop.src_y,
width: crop.src_w,
height: crop.src_h,
rotate: 0,
scaleX: 1,
scaleY: 1,
}));
cropForm.append("cut_pic_list[0][original_pic_height]", String(image.height));
cropForm.append("cut_pic_list[0][original_pic_width]", String(image.width));
cropForm.append("cut_pic_list[0][cutUrl]", original?.data?.original_url || "");
cropForm.append("cut_pic_list[0][is_head]", "1");
const cropped = await sessionFetchJson<SmzdmCropResponse>(
context.session,
`${SMZDM_ORIGIN}/api/image/crop`,
{
method: "POST",
credentials: "include",
headers: await smzdmHeaders(context),
body: cropForm,
},
).catch((error): SmzdmCropResponse => ({
error_code: -1,
error_msg: error instanceof Error ? error.message : "smzdm_cover_crop_failed",
}));
const cropResult = cropped.data?.[0];
return {
id: uploadedId,
origin: cropResult?.pic_url || uploaded.data.url,
small: cropResult?.square_pic_url || uploaded.data.small_pic || uploaded.data.url,
};
}
function replaceAllLiteral(source: string, from: string, to: string): string {
return source.split(from).join(to);
}
async function processContentImages(
context: PublishAdapterContext,
html: string,
articleId: string,
): Promise<{ html: string; images: string[] }> {
const sources = extractImageSources(html);
if (!sources.length) {
return { html, images: [] };
}
let next = html;
const uploadedImages: string[] = [];
const uploaded = new Map<string, string>();
for (const source of sources) {
await waitForHumanPace(context.signal);
const image = await uploadImage(context, source, articleId, false);
if (!image?.origin) {
continue;
}
uploaded.set(source, image.origin);
uploadedImages.push(image.origin);
}
for (const [from, to] of uploaded.entries()) {
next = replaceAllLiteral(next, from, to);
}
return {
html: next,
images: uploadedImages,
};
}
export function buildSmzdmAwne(uid: string, textCount: number): string {
const key = createHash("md5")
.update(`${uid}-${textCount}-smzdm.com`.trim())
.digest("hex");
const plaintext = Buffer.from(String(textCount), "utf8").toString("base64");
const cipher = createCipheriv("aes-256-ecb", Buffer.from(key, "utf8"), null);
cipher.setAutoPadding(true);
return Buffer.concat([
cipher.update(Buffer.from(plaintext, "utf8")),
cipher.final(),
]).toString("base64");
}
async function calculateEditorSignature(
context: PublishAdapterContext,
articleId: string,
html: string,
uid: string,
): Promise<{ awne: string; wne: string }> {
const page = context.playwright?.page;
if (!page) {
throw new Error("smzdm_playwright_page_missing");
}
await page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => undefined);
const textCount = await page.evaluate((content) => {
const editorNode = document.querySelector(".ProseMirror[contenteditable]") as (HTMLElement & {
editor?: {
commands?: {
setContent?: (value: string, emitUpdate?: boolean, options?: unknown) => unknown;
getTextCount?: () => unknown;
};
};
}) | null;
const editor = editorNode?.editor;
if (!editor?.commands?.setContent || !editor.commands.getTextCount) {
return null;
}
editor.commands.setContent(content, false, {
parseOptions: {
preserveWhitespace: "full",
},
});
return editor.commands.getTextCount();
}, html).catch(() => null);
const normalizedCount = typeof textCount === "number" && Number.isFinite(textCount)
? Math.round(textCount)
: null;
if (normalizedCount === null) {
throw new Error("smzdm_editor_signature_failed");
}
return {
awne: buildSmzdmAwne(uid, normalizedCount),
wne: String(normalizedCount),
};
}
function withoutProtocol(url: string): string {
return url.replace(/^https?:/i, "");
}
function buildImageList(images: string[]): Array<Record<string, string>> {
return images.map((url) => ({
height: "",
image_tag: "",
original_drawing: "0",
pic_url: url,
picture_id: "",
width: "",
cms_link: "",
other_data: "",
image_product_tag: "",
}));
}
export function encodeSmzdmForm(value: unknown, key = ""): string {
const parts: string[] = [];
const append = (item: unknown, itemKey: string) => {
if (item === null || item === undefined) {
parts.push(`${encodeURIComponent(itemKey)}=`);
return;
}
if (Array.isArray(item)) {
item.forEach((child, index) => append(child, `${itemKey}[${index}]`));
return;
}
if (typeof item === "object") {
for (const [childKey, child] of Object.entries(item as Record<string, unknown>)) {
append(child, itemKey ? `${itemKey}[${childKey}]` : childKey);
}
return;
}
parts.push(`${encodeURIComponent(itemKey)}=${encodeURIComponent(String(item))}`);
};
append(value, key);
return parts.join("&");
}
function buildSubmitForm(input: {
articleId: string;
title: string;
html: string;
awne: string;
wne: string;
cover: SmzdmUploadedImage | null;
images: string[];
}): Record<string, unknown> {
return {
article_id: input.articleId,
submit_type: "submit",
title: input.title,
series_title: "",
focus_image: input.cover ? withoutProtocol(input.cover.origin) : "",
series_order_id: "0",
series_id: "0",
anonymous: "0",
first_publish: "0",
remark: "",
create_state_type: "3",
ai_state_type: "2",
square_pic_url: input.cover ? withoutProtocol(input.cover.small) : "",
cover_image_rectangle: "",
custom_topics: "",
group_id: "",
awne: input.awne,
wne: input.wne,
editorValue: input.html,
...(input.images.length ? { image_list: buildImageList(input.images) } : {}),
};
}
function smzdmManageUrl(articleId: string): string {
return `${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`;
}
export function smzdmArticleUrl(articleId: string): string {
return `${SMZDM_ORIGIN}/p/${encodeURIComponent(articleId)}/`;
}
function buildResultPayload(
publishType: SmzdmPublishType,
articleId: string,
mediaName: string,
externalManageUrl: string,
): Record<string, JsonValue> {
return {
platform: "smzdm",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: externalManageUrl,
external_article_url: smzdmArticleUrl(articleId),
publish_type: publishType,
};
}
function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "smzdm_not_logged_in") {
return {
status: "failed",
summary: "什么值得买登录态失效,无法执行发布。",
error: {
code: "smzdm_not_logged_in",
message,
},
};
}
if (message === "smzdm_playwright_page_missing") {
return {
status: "failed",
summary: "什么值得买发布需要隐藏浏览器页面参与签名计算,请稍后重试。",
error: {
code: "smzdm_playwright_page_missing",
message,
},
};
}
if (message === "smzdm_article_id_missing") {
return {
status: "failed",
summary: "什么值得买投稿入口未返回文章 ID,请打开投稿页确认账号状态后重试。",
error: {
code: "smzdm_article_id_missing",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到什么值得买。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message === "smzdm_editor_signature_failed") {
return {
status: "failed",
summary: "什么值得买编辑器签名计算失败,请打开编辑器确认页面已正常加载后重试。",
error: {
code: "smzdm_editor_signature_failed",
message,
},
};
}
if (message.startsWith("smzdm_cover_fetch_failed")) {
return {
status: "failed",
summary: "什么值得买封面文件读取失败,请重新上传封面图后重试。",
error: {
code: "smzdm_cover_fetch_failed",
message,
},
};
}
if (message.startsWith("smzdm_image_fetch_failed")) {
return {
status: "failed",
summary: "什么值得买正文图片读取失败,请检查文章图片后重试。",
error: {
code: "smzdm_image_fetch_failed",
message,
},
};
}
if (message.startsWith("smzdm_cover_upload_failed")) {
return {
status: "failed",
summary: "什么值得买封面上传失败,请更换封面图后重试。",
error: {
code: "smzdm_cover_upload_failed",
message,
},
};
}
if (message.startsWith("smzdm_image_upload_failed")) {
return {
status: "failed",
summary: "什么值得买正文图片上传失败,请稍后重试或更换图片。",
error: {
code: "smzdm_image_upload_failed",
message,
},
};
}
if (message.startsWith("smzdm_challenge_required") || isSmzdmChallengeMessage(message)) {
return {
status: "failed",
summary: "什么值得买触发平台验证,请在什么值得买后台完成验证后重试。",
error: {
code: "smzdm_challenge_required",
message,
},
};
}
return {
status: "failed",
summary: "什么值得买发布失败。",
error: {
code: "smzdm_publish_failed",
message,
},
};
}
export const smzdmAdapter: PublishAdapter = {
platform: "smzdm",
executionMode: "playwright",
async publish(context) {
try {
context.reportProgress("smzdm.detect_login");
const account = await fetchCurrentAccount(context);
if (!account?.uid) {
throw new Error("smzdm_not_logged_in");
}
context.reportProgress("smzdm.create_article_id");
const articleId = await getArticleId(context);
let html = normalizeArticleHtml(context.article);
if (!html) {
throw new Error("article_content_empty");
}
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
let cover: SmzdmUploadedImage | null = null;
if (coverAssetUrl) {
context.reportProgress("smzdm.upload_cover");
await waitForHumanPace(context.signal);
cover = await uploadImage(context, coverAssetUrl, articleId, true);
}
if (coverAssetUrl && extractImageSources(html).length === 0) {
html = `<img src="${coverAssetUrl}" />${html}`;
}
context.reportProgress("smzdm.upload_content_images");
const processed = await processContentImages(context, html, articleId);
context.reportProgress("smzdm.calculate_signature");
const signature = await calculateEditorSignature(context, articleId, processed.html, account.uid);
context.reportProgress("smzdm.submit");
await waitForHumanPace(context.signal);
const response = await sessionFetchJson<SmzdmSubmitResponse>(
context.session,
`${SMZDM_ORIGIN}/api/editor/article/submit`,
{
method: "POST",
credentials: "include",
headers: await smzdmHeaders(context, {
"content-type": "application/x-www-form-urlencoded",
}),
body: encodeSmzdmForm(buildSubmitForm({
articleId,
title: context.article.title?.trim() || "未命名文章",
html: processed.html,
awne: signature.awne,
wne: signature.wne,
cover,
images: processed.images,
})),
},
).catch((requestError): SmzdmSubmitResponse => ({
error_code: -1,
error_msg: requestError instanceof Error ? requestError.message : "smzdm_publish_failed",
}));
const topicCode = response.data?.update_topic?.error_code;
if (!isSuccessfulCode(response.error_code) || !isSuccessfulCode(topicCode)) {
const message = response.data?.update_topic?.error_msg || smzdmResponseMessage(response);
if (isSmzdmChallengeMessage(message)) {
throw new Error(`smzdm_challenge_required:${message}`);
}
throw new Error(message || "smzdm_publish_failed");
}
return {
status: "succeeded",
payload: buildResultPayload("publish", articleId, account.name, smzdmManageUrl(articleId)),
summary: "什么值得买发布成功。",
};
} catch (error) {
return failureResult(error);
}
},
};
@@ -0,0 +1,362 @@
import { randomBytes } from "node:crypto";
import type { JsonValue } from "@geo/shared-types";
import {
extractImageSources,
normalizeArticleHtml,
sessionCookieValue,
sessionFetchJson,
uploadHtmlImages,
} from "./common";
import { fetchImageAssetBlob } from "./media-image";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type SohuRegisterInfoResponse = {
data?: {
account?: {
id?: string | number;
nickName?: string;
avatar?: string;
};
};
};
type SohuAccountListResponse = {
code?: number;
data?: {
data?: Array<{
accounts?: SohuRawAccount[];
}>;
};
};
type SohuRawAccount = {
id?: string | number;
nickName?: string;
avatar?: string;
};
type SohuSubmitResponse = {
code?: number;
success?: boolean;
data?: string | number;
msg?: string;
message?: string;
};
type SohuUploadResponse = {
url?: string;
msg?: string;
message?: string;
};
type SohuAccount = {
id: string;
name: string;
};
type SohuPublishType = "publish" | "draft";
const SOHU_ORIGIN = "https://mp.sohu.com";
const SOHU_MANAGE_URL = `${SOHU_ORIGIN}/mpfe/v3/main/first/page?newsType=1`;
const SOHU_PUBLISH_URL = `${SOHU_ORIGIN}/mpbp/bp/news/v4/news/publish/v2`;
const SOHU_DRAFT_URL = `${SOHU_ORIGIN}/mpbp/bp/news/v4/news/draft/v2`;
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function resolvePublishType(payload: Record<string, unknown>): SohuPublishType {
return payload.publish_type === "draft" || payload.publishType === "draft" ? "draft" : "publish";
}
function isSohuSuccess(response: SohuSubmitResponse | null | undefined): boolean {
return Boolean(response?.data) && (response?.success === true || response?.code === 2_000_000);
}
function sohuResponseMessage(response: { msg?: string; message?: string; code?: number } | null | undefined): string {
return response?.msg || response?.message || `sohuhao_error_${response?.code ?? "unknown"}`;
}
function isSohuChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i
.test(message);
}
function generateDeviceId(): string {
return randomBytes(16).toString("hex");
}
async function cookieHeaderForUrl(context: PublishAdapterContext, url = SOHU_ORIGIN): Promise<string> {
const cookies = await context.session.cookies.get({ url }).catch(() => []);
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
}
async function sohuSpCm(context: PublishAdapterContext): Promise<string> {
const cookieValue =
(await sessionCookieValue(context.session, "sohu.com", "mp-cv").catch(() => "")) ||
(await sessionCookieValue(context.session, "mp.sohu.com", "mp-cv").catch(() => ""));
return cookieValue || `100-${Date.now()}-${generateDeviceId()}`;
}
async function sohuHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await cookieHeaderForUrl(context);
return {
accept: "application/json, text/plain, */*",
origin: SOHU_ORIGIN,
referer: `${SOHU_ORIGIN}/`,
"x-requested-with": "XMLHttpRequest",
...(cookie ? { cookie } : {}),
...extra,
};
}
function accountFromRaw(account: SohuRawAccount | null | undefined): SohuAccount | null {
const id = stringId(account?.id);
const name = account?.nickName?.trim() || "";
return id && name ? { id, name } : null;
}
async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount | null> {
const registerInfo = await sessionFetchJson<SohuRegisterInfoResponse>(
context.session,
`${SOHU_ORIGIN}/mpbp/bp/account/register-info`,
{
credentials: "include",
headers: await sohuHeaders(context),
},
).catch(() => null);
const account = accountFromRaw(registerInfo?.data?.account);
if (account) {
return account;
}
const accountListURL = new URL(`${SOHU_ORIGIN}/mpbp/bp/account/list`);
accountListURL.searchParams.set("_", String(Date.now()));
const list = await sessionFetchJson<SohuAccountListResponse>(
context.session,
accountListURL.toString(),
{
credentials: "include",
headers: await sohuHeaders(context),
},
).catch(() => null);
for (const group of list?.data?.data ?? []) {
for (const raw of group.accounts ?? []) {
const listed = accountFromRaw(raw);
if (listed) {
return listed;
}
}
}
return null;
}
async function uploadImage(context: PublishAdapterContext, accountId: string, sourceUrl: string): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl);
if (!image) {
return null;
}
const form = new FormData();
form.append("file", image.blob, image.fileName);
form.append("accountId", accountId);
const url = new URL(`${SOHU_ORIGIN}/commons/front/outerUpload/image/file`);
url.searchParams.set("accountId", accountId);
const response = await sessionFetchJson<SohuUploadResponse>(
context.session,
url.toString(),
{
method: "POST",
credentials: "include",
headers: await sohuHeaders(context, {
"sp-cm": await sohuSpCm(context),
}),
body: form,
},
).catch(() => null);
return response?.url?.trim() || null;
}
async function submitArticle(
context: PublishAdapterContext,
account: SohuAccount,
html: string,
publishType: SohuPublishType,
): Promise<string> {
const coverUrl = context.article.cover_asset_url
? await uploadImage(context, account.id, context.article.cover_asset_url).catch(() => null)
: "";
const url = new URL(publishType === "draft" ? SOHU_DRAFT_URL : SOHU_PUBLISH_URL);
url.searchParams.set("accountId", account.id);
const body = {
title: context.article.title?.trim() || "未命名文章",
content: html,
brief: "",
channelId: 30,
categoryId: -1,
cover: coverUrl || "",
accountId: Number(account.id),
infoResource: 2,
};
const response = await sessionFetchJson<SohuSubmitResponse>(
context.session,
url.toString(),
{
method: "POST",
credentials: "include",
headers: await sohuHeaders(context, {
"content-type": "application/json",
"dv-id": generateDeviceId(),
"sp-cm": await sohuSpCm(context),
}),
body: JSON.stringify(body),
},
).catch((error): SohuSubmitResponse => ({
code: -1,
msg: error instanceof Error ? error.message : "sohuhao_submit_failed",
}));
if (!isSohuSuccess(response)) {
const message = sohuResponseMessage(response);
if (isSohuChallengeMessage(message)) {
throw new Error(`sohuhao_challenge_required:${message}`);
}
throw new Error(`sohuhao_submit_failed:${message}`);
}
return stringId(response.data);
}
function buildResultPayload(
articleId: string,
mediaName: string,
publishType: SohuPublishType,
): Record<string, JsonValue> {
const editUrl =
`${SOHU_ORIGIN}/mpfe/v4/contentManagement/news/addarticle?spm=smmp.articlelist.0.0&contentStatus=2&id=${encodeURIComponent(articleId)}`;
return {
platform: "sohuhao",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: editUrl || SOHU_MANAGE_URL,
external_article_url: null,
publish_type: publishType,
};
}
function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "sohuhao_not_logged_in") {
return {
status: "failed",
summary: "搜狐号登录态失效,无法执行发布。",
error: {
code: "sohuhao_not_logged_in",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到搜狐号。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message.startsWith("sohuhao_challenge_required") || isSohuChallengeMessage(message)) {
return {
status: "failed",
summary: "搜狐号触发平台验证,请在搜狐号后台完成验证后重试。",
error: {
code: "sohuhao_challenge_required",
message,
},
};
}
if (message.startsWith("sohuhao_submit_failed")) {
return {
status: "failed",
summary: "搜狐号提交失败,请稍后重试或打开搜狐号后台确认内容状态。",
error: {
code: "sohuhao_submit_failed",
message,
},
};
}
return {
status: "failed",
summary: "搜狐号发布失败。",
error: {
code: "sohuhao_publish_failed",
message,
},
};
}
export const sohuhaoAdapter: PublishAdapter = {
platform: "sohuhao",
executionMode: "session",
async publish(context, payload) {
try {
context.reportProgress("sohuhao.detect_login");
const account = await fetchAccount(context);
if (!account) {
throw new Error("sohuhao_not_logged_in");
}
context.reportProgress("sohuhao.normalize_html");
let html = normalizeArticleHtml(context.article);
if (!html) {
throw new Error("article_content_empty");
}
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
if (coverAssetUrl && extractImageSources(html).length === 0) {
html = `<img src="${coverAssetUrl}" />${html}`;
}
context.reportProgress("sohuhao.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadImage(context, account.id, sourceUrl));
const publishType = resolvePublishType(payload);
context.reportProgress("sohuhao.submit");
const articleId = await submitArticle(context, account, processed.html, publishType);
return {
status: "succeeded",
summary: publishType === "draft" ? "搜狐号草稿保存成功。" : "搜狐号发布成功。",
payload: buildResultPayload(articleId, account.name, publishType),
};
} catch (error) {
return failureResult(error);
}
},
};
@@ -0,0 +1,429 @@
import type { JsonValue } from "@geo/shared-types";
import type { Page } from "playwright-core";
import {
extractImageSources,
normalizeArticleHtml,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
} from "./common";
import { fetchImageAssetBlob } from "./media-image";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type WangyiInfoResponse = {
data?: {
mediaInfo?: {
userId?: string | number;
wemediaId?: string | number;
tname?: string;
icon?: string;
};
};
};
type WangyiUploadResponse = {
code?: number;
message?: string;
data?: {
url?: string;
};
};
type WangyiDraftResponse = {
code?: number;
message?: string;
data?: string;
};
type WangyiAccount = {
realUserId: string;
wemediaId: string;
name: string;
};
const WANGYI_ORIGIN = "https://mp.163.com";
const WANGYI_HOME_URL = `${WANGYI_ORIGIN}/subscribe_v4/index.html`;
const WANGYI_MANAGE_URL = `${WANGYI_ORIGIN}/subscribe_v4/index.html#/article-manage`;
const WANGYI_PUBLISH_DELAY_MS = 2_000;
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function parseFormString(value: string): Record<string, string> {
return Object.fromEntries(new URLSearchParams(value));
}
function wangyiResponseMessage(response: { message?: string; code?: number } | null | undefined): string {
return response?.message || `wangyihao_error_${response?.code ?? "unknown"}`;
}
function isWangyiChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i
.test(message);
}
function sleep(ms: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(new Error("adapter_aborted"));
}
return new Promise((resolve, reject) => {
const cleanup = () => signal?.removeEventListener("abort", onAbort);
const timeout = setTimeout(() => {
cleanup();
resolve();
}, ms);
const onAbort = () => {
clearTimeout(timeout);
cleanup();
reject(new Error("adapter_aborted"));
};
signal?.addEventListener("abort", onAbort, { once: true });
});
}
async function wangyiHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie =
(await sessionCookieHeader(context.session, "mp.163.com").catch(() => "")) ||
(await sessionCookieHeader(context.session, "163.com").catch(() => ""));
return {
accept: "application/json, text/plain, */*",
origin: WANGYI_ORIGIN,
referer: WANGYI_HOME_URL,
...(cookie ? { cookie } : {}),
...extra,
};
}
async function fetchAccount(context: PublishAdapterContext): Promise<WangyiAccount | null> {
const response = await sessionFetchJson<WangyiInfoResponse>(
context.session,
`${WANGYI_ORIGIN}/wemedia/info.do`,
{
credentials: "include",
headers: await wangyiHeaders(context),
},
).catch(() => null);
const media = response?.data?.mediaInfo;
const realUserId = stringId(media?.userId);
const wemediaId = stringId(media?.wemediaId);
const name = media?.tname?.trim() || "";
return realUserId && wemediaId && name ? { realUserId, wemediaId, name } : null;
}
async function getPublishToken(context: PublishAdapterContext): Promise<string> {
const page = context.playwright?.page;
if (!page) {
throw new Error("wangyihao_playwright_missing");
}
await page.goto(WANGYI_HOME_URL, {
waitUntil: "domcontentloaded",
});
await page.waitForFunction(
"() => Boolean(window.neg && typeof window.neg.getToken === 'function')",
undefined,
{ timeout: 12_000 },
).catch(() => null);
const token = await page.evaluate(async () => {
const neg = (window as unknown as { neg?: { getToken?: () => Promise<{ token?: string }> } }).neg;
const result = await neg?.getToken?.().catch(() => null);
return result?.token ?? "";
}).catch(() => "");
return token.trim();
}
async function uploadImage(
context: PublishAdapterContext,
account: WangyiAccount,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl);
if (!image) {
return null;
}
const form = new FormData();
form.append("file", image.blob, image.fileName);
form.append("from", "neteasecode_mp");
form.append("logotext", account.name);
const url = new URL(`${WANGYI_ORIGIN}/api/v3/upload/picupload`);
url.searchParams.set("_", String(Date.now()));
url.searchParams.set("wemediaId", account.wemediaId);
url.searchParams.set("realUserId", account.realUserId);
const response = await sessionFetchJson<WangyiUploadResponse>(
context.session,
url.toString(),
{
method: "POST",
credentials: "include",
headers: await wangyiHeaders(context, {
"x-b3-sampled": "1",
"x-b3-spanid": "0",
}),
body: form,
},
).catch(() => null);
return response?.data?.url?.trim() || null;
}
async function saveDraft(
context: PublishAdapterContext,
account: WangyiAccount,
html: string,
token: string,
): Promise<string> {
const url = new URL(`${WANGYI_ORIGIN}/wemedia/article/status/api/publishV2.do`);
url.searchParams.set("_", String(Date.now()));
url.searchParams.set("wemediaId", account.wemediaId);
url.searchParams.set("realUserId", account.realUserId);
const body = new URLSearchParams({
wemediaId: account.wemediaId,
operation: "saveDraft",
articleId: "-1",
title: context.article.title?.trim() || "未命名文章",
content: html,
essayClassify: "",
essayUrl: "",
essayTitle: "",
essayId: "",
activitySubjectId: "",
subjectId: "",
original: "0",
picUrl: "",
onlineState: "2",
cover: "auto",
scheduled: "0",
creativeStatement: "内容由AI生成",
ursToken: token,
});
const response = await sessionFetchJson<WangyiDraftResponse>(
context.session,
url.toString(),
{
method: "POST",
credentials: "include",
headers: await wangyiHeaders(context, {
"content-type": "application/x-www-form-urlencoded",
"x-b3-sampled": "1",
"x-b3-spanid": "0",
}),
body,
},
).catch((error): WangyiDraftResponse => ({
code: -1,
message: error instanceof Error ? error.message : "wangyihao_draft_save_failed",
}));
if (response.code !== 1 || !response.data) {
const message = wangyiResponseMessage(response);
if (isWangyiChallengeMessage(message)) {
throw new Error(`wangyihao_challenge_required:${message}`);
}
throw new Error(`wangyihao_draft_save_failed:${message}`);
}
const draftId = parseFormString(response.data).docId ?? "";
if (!draftId.trim()) {
throw new Error("wangyihao_draft_id_missing");
}
return draftId.trim();
}
async function clickFirstVisibleButton(page: Page, text: string): Promise<boolean> {
const button = page.locator("button").filter({ hasText: text }).first();
return await button.click({ timeout: 5_000 }).then(
() => true,
() => false,
);
}
async function publishDraft(context: PublishAdapterContext, draftId: string): Promise<boolean> {
const page = context.playwright?.page;
if (!page) {
throw new Error("wangyihao_playwright_missing");
}
const oldUrl = `https://mp.163.com/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`;
await page.goto(oldUrl, {
waitUntil: "domcontentloaded",
});
await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => null);
await clickFirstVisibleButton(page, "发布");
await sleep(WANGYI_PUBLISH_DELAY_MS, context.signal);
const firstNavigated = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 5_000 }).then(
() => true,
() => false,
);
if (firstNavigated) {
return true;
}
await clickFirstVisibleButton(page, "确认发布");
const confirmed = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 8_000 }).then(
() => true,
() => false,
);
return confirmed || page.url() !== oldUrl;
}
function buildResultPayload(articleId: string, mediaName: string): Record<string, JsonValue> {
return {
platform: "wangyihao",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: WANGYI_MANAGE_URL,
external_article_url: null,
publish_type: "publish",
};
}
function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "wangyihao_not_logged_in") {
return {
status: "failed",
summary: "网易号登录态失效,无法执行发布。",
error: {
code: "wangyihao_not_logged_in",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到网易号。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message === "wangyihao_token_missing") {
return {
status: "failed",
summary: "网易号发布 token 获取失败,请重新打开网易号后台确认登录状态。",
error: {
code: "wangyihao_token_missing",
message,
},
};
}
if (message.startsWith("wangyihao_challenge_required") || isWangyiChallengeMessage(message)) {
return {
status: "failed",
summary: "网易号触发平台验证,请在网易号后台完成验证后重试。",
error: {
code: "wangyihao_challenge_required",
message,
},
};
}
if (message.startsWith("wangyihao_draft_save_failed") || message === "wangyihao_draft_id_missing") {
return {
status: "failed",
summary: "网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。",
error: {
code: "wangyihao_draft_save_failed",
message,
},
};
}
if (message === "wangyihao_publish_click_failed") {
return {
status: "failed",
summary: "网易号草稿已保存,但页面自动点击发布失败,请到网易号后台手动发布。",
error: {
code: "wangyihao_publish_click_failed",
message,
},
};
}
return {
status: "failed",
summary: "网易号发布失败。",
error: {
code: "wangyihao_publish_failed",
message,
},
};
}
export const wangyihaoAdapter: PublishAdapter = {
platform: "wangyihao",
executionMode: "playwright",
async publish(context) {
try {
context.reportProgress("wangyihao.detect_login");
const account = await fetchAccount(context);
if (!account) {
throw new Error("wangyihao_not_logged_in");
}
context.reportProgress("wangyihao.normalize_html");
let html = normalizeArticleHtml(context.article);
if (!html) {
throw new Error("article_content_empty");
}
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
if (coverAssetUrl && extractImageSources(html).length === 0) {
html = `<img src="${coverAssetUrl}" />${html}`;
}
context.reportProgress("wangyihao.get_token");
const token = await getPublishToken(context);
if (!token) {
throw new Error("wangyihao_token_missing");
}
context.reportProgress("wangyihao.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadImage(context, account, sourceUrl));
context.reportProgress("wangyihao.save_draft");
const draftId = await saveDraft(context, account, processed.html, token);
context.reportProgress("wangyihao.publish_draft");
const published = await publishDraft(context, draftId);
if (!published) {
throw new Error("wangyihao_publish_click_failed");
}
return {
status: "succeeded",
summary: "网易号发布成功。",
payload: buildResultPayload(draftId, account.name),
};
} catch (error) {
return failureResult(error);
}
},
};
@@ -0,0 +1,579 @@
import type { JsonValue } from "@geo/shared-types";
import {
normalizeArticleHtml,
normalizeRemoteUrl,
sessionFetchJson,
sessionFetchText,
uploadHtmlImages,
} from "./common";
import {
fetchImageAssetBlob,
getCenteredCropRect,
} from "./media-image";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type WeixinMeta = {
token: string;
userName: string;
nickName: string;
ticket: string;
svrTime: number;
};
type WeixinUploadResponse = {
cdn_url?: string;
base_resp?: {
err_msg?: string;
ret?: number;
};
};
type WeixinCropResult = {
cdnurl?: string;
file_id?: string | number;
width?: number;
height?: number;
};
type WeixinCropResponse = {
result?: WeixinCropResult[];
base_resp?: {
err_msg?: string;
ret?: number;
};
};
type WeixinOperateResponse = {
appMsgId?: string;
ret?: number;
base_resp?: {
ret?: number;
err_msg?: string;
};
};
type WeixinCover = {
cdn235: string;
cdn1x1: string;
cdnUrl: string;
cropList: string;
};
const WEIXIN_ORIGIN = "https://mp.weixin.qq.com";
const WEIXIN_HOME_URL = `${WEIXIN_ORIGIN}/`;
function isWeixinChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required)/i
.test(message);
}
async function cookieHeaderForUrl(context: PublishAdapterContext, url = WEIXIN_HOME_URL): Promise<string> {
const cookies = await context.session.cookies.get({ url }).catch(() => []);
return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; ");
}
async function weixinHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await cookieHeaderForUrl(context);
return {
accept: "application/json, text/plain, */*",
origin: WEIXIN_ORIGIN,
referer: WEIXIN_HOME_URL,
...(cookie ? { cookie } : {}),
...extra,
};
}
function parseMeta(html: string): WeixinMeta | null {
const token = html.match(/data:\s*\{[\s\S]*?t:\s*["']([^"']+)["']/)?.[1] ?? "";
const ticket = html.match(/ticket:\s*["']([^"']+)["']/)?.[1] ?? "";
const userName = html.match(/user_name:\s*["']([^"']+)["']/)?.[1] ?? "";
const nickName = html.match(/nick_name:\s*["']([^"']+)["']/)?.[1] ?? "";
const time = html.match(/time:\s*["'](\d+)["']/)?.[1] ?? "";
if (!token || !userName || !nickName) {
return null;
}
return {
token,
ticket,
userName,
nickName,
svrTime: time ? Number(time) : Date.now() / 1000,
};
}
async function fetchMeta(context: PublishAdapterContext): Promise<WeixinMeta | null> {
const html = await sessionFetchText(
context.session,
WEIXIN_HOME_URL,
{
credentials: "include",
headers: await weixinHeaders(context, {
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
}),
},
).catch(() => "");
return html ? parseMeta(html) : null;
}
function sanitizeWeixinHtml(html: string): string {
let next = html;
next = next.replace(/<iframe[\s\S]*?<\/iframe>/gi, "");
next = next.replace(/<(object|embed|svg)[\s\S]*?<\/\1>/gi, "");
next = next.replace(/<img\b[^>]*src=(['"])data:image\/svg\+xml[\s\S]*?>/gi, "");
return next;
}
function isLatexFormula(text: string): boolean {
return /[\\^_{}]/.test(text) || /[α-ωΑ-Ω]/.test(text) || /[∑∏∫∂∇∞≠≤≥±×÷√]/.test(text);
}
function processLatex(html: string): string {
const latexApi = "https://latex.codecogs.com/png.latex";
let next = html.replace(/\$\$([^$]+)\$\$/g, (match, latex: string) => {
if (!isLatexFormula(latex)) {
return match;
}
return `<p style="text-align: center;"><img src="${latexApi}?\\dpi{150}${encodeURIComponent(latex.trim())}" alt="formula" style="vertical-align: middle; max-width: 100%;"></p>`;
});
next = next.replace(/\$([^$]+)\$/g, (match, latex: string) => {
if (!isLatexFormula(latex)) {
return match;
}
return `<img src="${latexApi}?\\dpi{120}${encodeURIComponent(latex.trim())}" alt="formula" style="vertical-align: middle;">`;
});
return next;
}
function stripExternalLinks(html: string): string {
return html.replace(
/<a\s+[^>]*href=["']([^"']*)["'][^>]*>([\s\S]*?)<\/a>/gi,
(match, href: string, text: string) => {
if (
href.includes("mp.weixin.qq.com") ||
href.includes("weixin.qq.com") ||
href.startsWith("#") ||
href.startsWith("javascript:")
) {
return match;
}
return text;
},
);
}
function wrapWeixinContent(html: string): string {
return `<section style="margin-left: 6px; margin-right: 6px; line-height: 1.75em;">${html}</section>`;
}
async function uploadImage(
context: PublishAdapterContext,
meta: WeixinMeta,
sourceUrl: string,
): Promise<string | null> {
const normalized = normalizeRemoteUrl(sourceUrl);
if (normalized && /\/\/mmbiz\.(qpic|qlogo)\.cn\//i.test(normalized)) {
return normalized;
}
const image = await fetchImageAssetBlob(sourceUrl);
if (!image) {
return null;
}
const timestamp = Date.now();
const fileName = `${timestamp}.${image.fileName.split(".").pop() || "jpg"}`;
const form = new FormData();
form.append("type", image.blob.type || "image/jpeg");
form.append("id", String(timestamp));
form.append("name", fileName);
form.append("lastModifiedDate", new Date().toString());
form.append("size", String(image.blob.size));
form.append("file", image.blob, fileName);
const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/filetransfer`);
url.searchParams.set("action", "upload_material");
url.searchParams.set("f", "json");
url.searchParams.set("scene", "8");
url.searchParams.set("writetype", "doublewrite");
url.searchParams.set("groupid", "1");
url.searchParams.set("ticket_id", meta.userName);
url.searchParams.set("ticket", meta.ticket);
url.searchParams.set("svr_time", String(meta.svrTime));
url.searchParams.set("token", meta.token);
url.searchParams.set("lang", "zh_CN");
url.searchParams.set("seq", String(Date.now()));
url.searchParams.set("t", String(Math.random()));
const response = await sessionFetchJson<WeixinUploadResponse>(
context.session,
url.toString(),
{
method: "POST",
credentials: "include",
headers: await weixinHeaders(context),
body: form,
},
).catch(() => null);
if (response?.base_resp?.err_msg && response.base_resp.err_msg !== "ok") {
const message = response.base_resp.err_msg;
if (isWeixinChallengeMessage(message)) {
throw new Error(`weixin_gzh_challenge_required:${message}`);
}
}
return response?.cdn_url?.trim() || null;
}
function cropPercent(width: number, height: number, ratio: number): { x1: number; y1: number; x2: number; y2: number } {
const crop = getCenteredCropRect(width, height, ratio);
const clamp = (value: number) => Math.min(1, Math.max(0, value));
return {
x1: clamp(crop.x / width),
y1: clamp(crop.y / height),
x2: clamp((crop.x + crop.width) / width),
y2: clamp((crop.y + crop.height) / height),
};
}
function buildCropParams(rect235: ReturnType<typeof cropPercent>, rect1x1: ReturnType<typeof cropPercent>): URLSearchParams {
return new URLSearchParams({
size_count: "2",
size0_x1: String(rect235.x1),
size0_y1: String(rect235.y1),
size0_x2: String(rect235.x2),
size0_y2: String(rect235.y2),
format0: "2.35_1",
size1_x1: String(rect1x1.x1),
size1_y1: String(rect1x1.y1),
size1_x2: String(rect1x1.x2),
size1_y2: String(rect1x1.y2),
format1: "1_1",
});
}
function coverFromCropResponse(cdnUrl: string, crop: URLSearchParams, response: WeixinCropResponse): WeixinCover | null {
const results = response.result ?? [];
if (results.length < 2) {
return null;
}
let [wide, square] = results;
if (wide?.width && wide?.height && wide.width === wide.height) {
[wide, square] = [square, wide];
}
if (!wide?.cdnurl || !wide.file_id || !square?.cdnurl || !square.file_id) {
return null;
}
return {
cdn235: wide.cdnurl,
cdn1x1: square.cdnurl,
cdnUrl,
cropList: JSON.stringify({
crop_list: [
{ ratio: "2.35_1", x1: 0, y1: 0, x2: 0, y2: 0, file_id: wide.file_id },
{ ratio: "1_1", x1: 0, y1: 0, x2: 0, y2: 0, file_id: square.file_id },
],
crop_list_percent: [
{
ratio: "2.35_1",
x1: Number(crop.get("size0_x1") ?? 0),
y1: Number(crop.get("size0_y1") ?? 0),
x2: Number(crop.get("size0_x2") ?? 0),
y2: Number(crop.get("size0_y2") ?? 0),
file_id: wide.file_id,
},
{
ratio: "1_1",
x1: Number(crop.get("size1_x1") ?? 0),
y1: Number(crop.get("size1_y1") ?? 0),
x2: Number(crop.get("size1_x2") ?? 0),
y2: Number(crop.get("size1_y2") ?? 0),
file_id: square.file_id,
},
],
}),
};
}
async function uploadCover(
context: PublishAdapterContext,
meta: WeixinMeta,
sourceUrl: string,
): Promise<WeixinCover | null> {
const source = await fetchImageAssetBlob(sourceUrl);
if (!source) {
return null;
}
const cdnUrl = await uploadImage(context, meta, sourceUrl);
if (!cdnUrl) {
return null;
}
const cdnImage = await fetchImageAssetBlob(cdnUrl).catch(() => null);
const width = cdnImage?.width || source.width;
const height = cdnImage?.height || source.height;
const rect235 = cropPercent(width, height, 2.35);
const rect1x1 = cropPercent(width, height, 1);
const body = buildCropParams(rect235, rect1x1);
body.set("imgurl", cdnUrl);
body.set("token", meta.token);
body.set("lang", "zh_CN");
body.set("f", "json");
body.set("ajax", "1");
const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/cropimage`);
url.searchParams.set("action", "crop_multi");
const response = await sessionFetchJson<WeixinCropResponse>(
context.session,
url.toString(),
{
method: "POST",
credentials: "include",
headers: await weixinHeaders(context, {
"content-type": "application/x-www-form-urlencoded",
}),
body,
},
).catch(() => null);
return response ? coverFromCropResponse(cdnUrl, body, response) : null;
}
function formatWeixinError(response: WeixinOperateResponse | null | undefined): string {
const ret = response?.ret ?? response?.base_resp?.ret;
const map: Record<number, string> = {
[-8]: "请输入验证码",
[-6]: "请输入验证码",
[-1]: "系统错误,请注意备份内容后重试",
[-2]: "参数错误,请注意备份内容后重试",
[-5]: "服务错误,请注意备份内容后重试",
[-99]: "内容超出字数,请调整",
[-206]: "服务负荷过大,请稍后重试",
412: "图文中含非法外链",
200003: "登录态超时,请重新登录",
62752: "可能含有具备安全风险的链接,请检查",
64506: "保存失败,链接不合法",
64507: "内容不能包含外部链接",
64562: "请勿插入非微信域名的链接",
64702: "标题超出64字长度限制",
64703: "摘要超出120字长度限制",
64705: "内容超出字数,请调整",
10806: "正文不能有违规内容,请重新编辑",
10807: "内容不能违反公众平台协议",
220001: "素材管理中的存储数量已达上限",
220002: "图片库已达到存储上限",
};
return typeof ret === "number" ? map[ret] || `同步到草稿箱失败 (错误码: ${ret})` : "同步到草稿箱失败";
}
function buildOperateForm(
context: PublishAdapterContext,
meta: WeixinMeta,
content: string,
cover: WeixinCover | null,
): URLSearchParams {
const form = new URLSearchParams({
token: meta.token,
lang: "zh_CN",
f: "json",
ajax: "1",
random: String(Math.random()),
AppMsgId: "",
count: "1",
data_seq: "0",
operate_from: "Chrome",
isnew: "0",
ad_video_transition0: "",
can_reward0: "0",
related_video0: "",
is_video_recommend0: "-1",
title0: context.article.title?.trim() || "未命名文章",
author0: "",
writerid0: "0",
fileid0: "",
digest0: "",
auto_gen_digest0: "1",
content0: content,
sourceurl0: "",
need_open_comment0: "1",
only_fans_can_comment0: "0",
cdn_url0: "",
cdn_235_1_url0: "",
cdn_16_9_url0: "",
cdn_1_1_url0: "",
cdn_url_back0: "",
crop_list0: "",
show_cover_pic0: "0",
copyright_type0: "0",
releasefirst0: "",
reprint_permit_type0: "",
allow_reprint0: "",
allow_reprint_modify0: "",
fee0: "0",
is_share_copyright0: "0",
share_page_type0: "0",
share_imageinfo0: "{\"list\":[]}",
dot0: "{}",
categories_list0: "[]",
claim_source_type0: "1",
is_user_no_claim_source0: "0",
});
if (cover) {
form.set("cdn_url0", cover.cdn235);
form.set("cdn_235_1_url0", cover.cdn235);
form.set("cdn_16_9_url0", cover.cdn235);
form.set("cdn_1_1_url0", cover.cdn1x1);
form.set("cdn_url_back0", cover.cdnUrl);
form.set("crop_list0", cover.cropList);
}
return form;
}
function buildResultPayload(articleId: string, mediaName: string, token: string): Record<string, JsonValue> {
const draftUrl =
`${WEIXIN_ORIGIN}/cgi-bin/appmsg?t=media/appmsg_edit&action=edit&type=77&appmsgid=${encodeURIComponent(articleId)}&token=${encodeURIComponent(token)}&lang=zh_CN`;
return {
platform: "weixin_gzh",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: draftUrl,
external_article_url: null,
publish_type: "draft",
};
}
function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "weixin_gzh_not_logged_in") {
return {
status: "failed",
summary: "微信公众号登录态失效,无法保存草稿。",
error: {
code: "weixin_gzh_not_logged_in",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到微信公众号。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message.startsWith("weixin_gzh_challenge_required") || isWeixinChallengeMessage(message)) {
return {
status: "failed",
summary: "微信公众号触发平台验证,请在公众号后台完成验证后重试。",
error: {
code: "weixin_gzh_challenge_required",
message,
},
};
}
if (message.startsWith("weixin_gzh_save_failed")) {
return {
status: "failed",
summary: "微信公众号草稿保存失败,请打开公众号后台确认内容状态。",
error: {
code: "weixin_gzh_save_failed",
message,
},
};
}
return {
status: "failed",
summary: "微信公众号草稿保存失败。",
error: {
code: "weixin_gzh_publish_failed",
message,
},
};
}
export const weixinGzhAdapter: PublishAdapter = {
platform: "weixin_gzh",
executionMode: "session",
async publish(context) {
try {
context.reportProgress("weixin_gzh.detect_login");
const meta = await fetchMeta(context);
if (!meta) {
throw new Error("weixin_gzh_not_logged_in");
}
context.reportProgress("weixin_gzh.normalize_html");
let html = normalizeArticleHtml(context.article);
html = sanitizeWeixinHtml(html);
html = stripExternalLinks(processLatex(html));
if (!html) {
throw new Error("article_content_empty");
}
context.reportProgress("weixin_gzh.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadImage(context, meta, sourceUrl));
const content = wrapWeixinContent(processed.html);
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
const cover = coverAssetUrl ? await uploadCover(context, meta, coverAssetUrl).catch(() => null) : null;
context.reportProgress("weixin_gzh.save_draft");
const form = buildOperateForm(context, meta, content, cover);
const response = await sessionFetchJson<WeixinOperateResponse>(
context.session,
`${WEIXIN_ORIGIN}/cgi-bin/operate_appmsg?t=ajax-response&sub=create&type=77&token=${encodeURIComponent(meta.token)}&lang=zh_CN`,
{
method: "POST",
credentials: "include",
headers: await weixinHeaders(context, {
"content-type": "application/x-www-form-urlencoded",
}),
body: form,
},
).catch((error): WeixinOperateResponse => ({
ret: -1,
base_resp: {
ret: -1,
err_msg: error instanceof Error ? error.message : "weixin_gzh_save_failed",
},
}));
const articleId = response.appMsgId?.trim() || "";
if (!articleId) {
const message = formatWeixinError(response);
if (isWeixinChallengeMessage(message)) {
throw new Error(`weixin_gzh_challenge_required:${message}`);
}
throw new Error(`weixin_gzh_save_failed:${message}`);
}
return {
status: "succeeded",
summary: "微信公众号草稿保存成功,请在公众号后台确认后发布。",
payload: buildResultPayload(articleId, meta.nickName, meta.token),
};
} catch (error) {
return failureResult(error);
}
},
};
@@ -0,0 +1,454 @@
import type { JsonValue } from "@geo/shared-types";
import {
extractImageSources,
normalizeArticleHtml,
normalizeRemoteUrl,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
} from "./common";
import {
cropImageAssetBlob,
fetchImageAssetBlob,
type ImageAssetBlob,
} from "./media-image";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type ZolUserInfoResponse = {
data?: {
userId?: string | number;
nickName?: string;
photo?: string;
};
};
type ZolDraftResponse = {
errcode?: number;
errmsg?: string;
data?: {
draftId?: string | number;
};
};
type ZolPublishResponse = {
errcode?: number;
errmsg?: string;
data?: {
contentId?: string | number;
};
};
type ZolImageUploadResponse = {
errcode?: number;
errmsg?: string;
data?: {
fileUrl?: string;
pic?: string;
};
};
type ZolSubjectListResponse = {
data?: {
list?: Array<{
subjectId?: string | number;
subjectName?: string;
}>;
};
};
type ZolAccount = {
uid: string;
name: string;
};
type ZolCoverImage = {
url: string;
width: number;
height: number;
};
const ZOL_API_ORIGIN = "https://open-api.zol.com.cn";
const ZOL_REFERER = "https://post.zol.com.cn";
const ZOL_MANAGE_URL = "https://post.zol.com.cn/v2/manage/works/all";
const ZOL_SUBJECT_LIMIT = 5;
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function zolResponseMessage(response: { errmsg?: string; errcode?: number } | null | undefined): string {
return response?.errmsg || `zol_error_${response?.errcode ?? "unknown"}`;
}
function isZolChallengeMessage(message: string): boolean {
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i
.test(message);
}
async function zolHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
const cookie = await sessionCookieHeader(context.session, "zol.com.cn").catch(() => "");
return {
accept: "application/json, text/plain, */*",
origin: ZOL_REFERER,
referer: ZOL_REFERER,
...(cookie ? { cookie } : {}),
...extra,
};
}
async function fetchAccount(context: PublishAdapterContext): Promise<ZolAccount | null> {
const response = await sessionFetchJson<ZolUserInfoResponse>(
context.session,
`${ZOL_API_ORIGIN}/api/v1/creator.user.getinfo`,
{
credentials: "include",
headers: await zolHeaders(context),
},
).catch(() => null);
const user = response?.data;
const uid = stringId(user?.userId);
const name = user?.nickName?.trim() ?? "";
return uid && name ? { uid, name } : null;
}
function appendBaseFormFields(form: FormData, title: string, userId: string): void {
form.append("businessType", "1");
form.append("scontent", "");
form.append("title", title);
form.append("stitle", "");
form.append("userId", userId);
form.append("docType", "");
form.append("isOriginal", "");
form.append("isContribution", "0");
form.append("dutyEditor", "undefined");
form.append("publishDate", "");
form.append("subjectList", "");
form.append("subjectIdStr", "");
form.append("guideImg", "");
form.append("draftId", "");
form.append("contentId", "");
form.append("tryId", "");
form.append("goodsList", "");
form.append("subjectNameStr", "");
form.append("eosXuanti", "0");
form.append("eosDawen", "0");
form.append("eosUser", "0");
form.append("eosTeyue", "0");
form.append("firstEc", "0");
form.append("firstEcForm", "false");
form.append("eosSyXuanti", "0");
form.append("eosGEO", "0");
form.append("eosZiZhu", "0");
form.append("saveType", "2");
}
async function createDraftId(context: PublishAdapterContext, form: FormData): Promise<string> {
const response = await sessionFetchJson<ZolDraftResponse>(
context.session,
`${ZOL_API_ORIGIN}/api/v1/creator.content.draft.save.orther`,
{
method: "POST",
credentials: "include",
headers: await zolHeaders(context),
body: form,
},
).catch((error): ZolDraftResponse => ({
errcode: -1,
errmsg: error instanceof Error ? error.message : "zol_draft_create_failed",
}));
const draftId = stringId(response.data?.draftId);
if (!draftId) {
throw new Error(`zol_draft_create_failed:${zolResponseMessage(response)}`);
}
return draftId;
}
async function uploadCoverVariant(
context: PublishAdapterContext,
source: ImageAssetBlob,
ratio: number,
): Promise<ZolCoverImage | null> {
const image = await cropImageAssetBlob(source, ratio);
if (!image) {
return null;
}
const form = new FormData();
form.append("file", image.blob, image.fileName);
form.append("siteType", "0");
const response = await sessionFetchJson<ZolImageUploadResponse>(
context.session,
`${ZOL_API_ORIGIN}/api/v1/creator.content.image.upload`,
{
method: "POST",
credentials: "include",
headers: await zolHeaders(context),
body: form,
},
).catch((error): ZolImageUploadResponse => ({
errcode: -1,
errmsg: error instanceof Error ? error.message : "zol_cover_upload_failed",
}));
const url = response.data?.fileUrl?.trim() || "";
if (response.errcode !== 0 || !url) {
const message = zolResponseMessage(response);
if (isZolChallengeMessage(message)) {
throw new Error(`zol_challenge_required:${message}`);
}
throw new Error(`zol_cover_upload_failed:${message}`);
}
return {
url,
width: image.width,
height: image.height,
};
}
async function uploadCover(context: PublishAdapterContext, sourceUrl: string): Promise<ZolCoverImage[] | null> {
const source = await fetchImageAssetBlob(sourceUrl);
if (!source) {
throw new Error("zol_cover_fetch_failed");
}
const landscape = await uploadCoverVariant(context, source, 4 / 3);
const portrait = await uploadCoverVariant(context, source, 3 / 4);
return landscape && portrait ? [landscape, portrait] : null;
}
async function uploadContentImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
const networkPic = normalizeRemoteUrl(sourceUrl);
if (!networkPic) {
return null;
}
const form = new FormData();
form.append("networkPic", networkPic);
form.append("siteType", "1");
const response = await sessionFetchJson<ZolImageUploadResponse>(
context.session,
`${ZOL_API_ORIGIN}/api/v1/creator.content.image.upload`,
{
method: "POST",
credentials: "include",
headers: await zolHeaders(context),
body: form,
},
).catch(() => null);
return response?.errcode === 0 ? response.data?.pic?.trim() || null : null;
}
async function applyRecommendSubjects(context: PublishAdapterContext, form: FormData): Promise<void> {
const url = new URL(`${ZOL_API_ORIGIN}/api/v1/creator.comminuty.subjectlist`);
url.searchParams.set("sa", "pc");
url.searchParams.set("keyword", "");
url.searchParams.set("page", "1");
const response = await sessionFetchJson<ZolSubjectListResponse>(
context.session,
url.toString(),
{
credentials: "include",
headers: await zolHeaders(context),
},
).catch(() => null);
const subjects = (response?.data?.list ?? [])
.filter((item) => item.subjectId != null && item.subjectName?.trim())
.slice(0, ZOL_SUBJECT_LIMIT);
if (!subjects.length) {
return;
}
form.set("subjectIdStr", subjects.map((item) => String(item.subjectId)).join(","));
form.set("subjectNameStr", subjects.map((item) => item.subjectName?.trim() ?? "").join(","));
}
function buildResultPayload(articleId: string, mediaName: string): Record<string, JsonValue> {
return {
platform: "zol",
media_name: mediaName,
external_article_id: articleId,
external_manage_url: ZOL_MANAGE_URL,
external_article_url: null,
publish_type: "publish",
};
}
function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "zol_not_logged_in") {
return {
status: "failed",
summary: "中关村在线登录态失效,无法执行发布。",
error: {
code: "zol_not_logged_in",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到中关村在线。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message.startsWith("zol_draft_create_failed")) {
return {
status: "failed",
summary: "中关村在线草稿 ID 创建失败,请重新打开后台确认账号状态。",
error: {
code: "zol_draft_create_failed",
message,
},
};
}
if (message.startsWith("zol_cover_fetch_failed")) {
return {
status: "failed",
summary: "中关村在线封面文件读取失败,请重新上传封面图后重试。",
error: {
code: "zol_cover_fetch_failed",
message,
},
};
}
if (message.startsWith("zol_cover_upload_failed")) {
return {
status: "failed",
summary: "中关村在线封面上传失败,请稍后重试或更换封面图。",
error: {
code: "zol_cover_upload_failed",
message,
},
};
}
if (message.startsWith("zol_challenge_required") || isZolChallengeMessage(message)) {
return {
status: "failed",
summary: "中关村在线触发平台验证,请在中关村在线后台完成验证后重试。",
error: {
code: "zol_challenge_required",
message,
},
};
}
return {
status: "failed",
summary: "中关村在线发布失败。",
error: {
code: "zol_publish_failed",
message,
},
};
}
export const zolAdapter: PublishAdapter = {
platform: "zol",
executionMode: "session",
async publish(context) {
try {
context.reportProgress("zol.detect_login");
const account = await fetchAccount(context);
if (!account) {
throw new Error("zol_not_logged_in");
}
let html = normalizeArticleHtml(context.article);
if (!html) {
throw new Error("article_content_empty");
}
const title = context.article.title?.trim() || "未命名文章";
const form = new FormData();
appendBaseFormFields(form, title, account.uid);
context.reportProgress("zol.create_draft");
const draftId = await createDraftId(context, form);
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
if (coverAssetUrl) {
context.reportProgress("zol.upload_cover");
const cover = await uploadCover(context, coverAssetUrl);
if (cover) {
form.set("guideImg", JSON.stringify(cover));
}
if (extractImageSources(html).length === 0) {
html = `<img src="${coverAssetUrl}" />${html}`;
}
}
context.reportProgress("zol.apply_subjects");
await applyRecommendSubjects(context, form);
context.reportProgress("zol.upload_content_images");
const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadContentImage(context, sourceUrl));
form.set("draftId", draftId);
form.set("saveType", "1");
form.set("scontent", processed.html);
form.append("draftUpdateId", draftId);
form.append("taskType", "1");
form.append("taskIds", "[]");
context.reportProgress("zol.submit");
const response = await sessionFetchJson<ZolPublishResponse>(
context.session,
`${ZOL_API_ORIGIN}/api/v1/creator.content.save.orther`,
{
method: "POST",
credentials: "include",
headers: await zolHeaders(context),
body: form,
},
).catch((error): ZolPublishResponse => ({
errcode: -1,
errmsg: error instanceof Error ? error.message : "zol_publish_failed",
}));
const articleId = stringId(response.data?.contentId);
if (response.errcode !== 0 || !articleId) {
const message = zolResponseMessage(response);
if (isZolChallengeMessage(message)) {
throw new Error(`zol_challenge_required:${message}`);
}
throw new Error(message || "zol_publish_failed");
}
return {
status: "succeeded",
payload: buildResultPayload(articleId, account.name),
summary: "中关村在线发布成功。",
};
} catch (error) {
return failureResult(error);
}
},
};
@@ -45,4 +45,76 @@ describe("platform auth adapters", () => {
}),
).toBe("auth_failure");
});
it("classifies Juejin missing login as auth failure", () => {
const adapter = getPlatformAdapter("juejin");
expect(
adapter.classifyFailure({
error: {
code: "juejin_not_logged_in",
},
}),
).toBe("auth_failure");
});
it("classifies Sohuhao missing login as auth failure", () => {
const adapter = getPlatformAdapter("sohuhao");
expect(
adapter.classifyFailure({
error: {
code: "sohuhao_not_logged_in",
},
}),
).toBe("auth_failure");
});
it("classifies Wangyihao missing token as auth failure", () => {
const adapter = getPlatformAdapter("wangyihao");
expect(
adapter.classifyFailure({
error: {
code: "wangyihao_token_missing",
},
}),
).toBe("auth_failure");
});
it("classifies Weixin GZH verification as challenge", () => {
const adapter = getPlatformAdapter("weixin_gzh");
expect(
adapter.classifyFailure({
error: {
code: "weixin_gzh_challenge_required",
},
}),
).toBe("challenge");
});
it("classifies ZOL missing login as auth failure", () => {
const adapter = getPlatformAdapter("zol");
expect(
adapter.classifyFailure({
error: {
code: "zol_not_logged_in",
},
}),
).toBe("auth_failure");
});
it("classifies Dongchedi platform verification as challenge", () => {
const adapter = getPlatformAdapter("dongchedi");
expect(
adapter.classifyFailure({
error: {
code: "dongchedi_challenge_required",
},
}),
).toBe("challenge");
});
});
@@ -115,6 +115,20 @@ function classifyBaijiahaoFailure(input: PlatformFailureInput): PlatformFailureC
return classifyFailure(input);
}
function classifySohuhaoFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "sohuhao_not_logged_in") {
return "auth_failure";
}
if (code === "sohuhao_challenge_required") {
return "challenge";
}
if (code.startsWith("sohuhao_")) {
return "ok";
}
return classifyFailure(input);
}
function classifyQiehaoFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "qiehao_challenge_required") {
@@ -140,6 +154,73 @@ function classifyBilibiliFailure(input: PlatformFailureInput): PlatformFailureCl
return classifyFailure(input);
}
function classifyJuejinFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "juejin_not_logged_in") {
return "auth_failure";
}
if (code.startsWith("juejin_")) {
return "ok";
}
return classifyFailure(input);
}
function classifyWangyihaoFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "wangyihao_not_logged_in" || code === "wangyihao_token_missing") {
return "auth_failure";
}
if (code === "wangyihao_challenge_required") {
return "challenge";
}
if (code.startsWith("wangyihao_")) {
return "ok";
}
return classifyFailure(input);
}
function classifyWeixinGzhFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "weixin_gzh_not_logged_in") {
return "auth_failure";
}
if (code === "weixin_gzh_challenge_required") {
return "challenge";
}
if (code.startsWith("weixin_gzh_")) {
return "ok";
}
return classifyFailure(input);
}
function classifyZolFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "zol_not_logged_in") {
return "auth_failure";
}
if (code === "zol_challenge_required") {
return "challenge";
}
if (code.startsWith("zol_")) {
return "ok";
}
return classifyFailure(input);
}
function classifyDongchediFailure(input: PlatformFailureInput): PlatformFailureClassification {
const code = typeof input.error?.code === "string" ? input.error.code : "";
if (code === "dongchedi_not_logged_in") {
return "auth_failure";
}
if (code === "dongchedi_challenge_required") {
return "challenge";
}
if (code.startsWith("dongchedi_")) {
return "ok";
}
return classifyFailure(input);
}
const genericAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
@@ -180,6 +261,16 @@ const baijiahaoAdapter: PlatformAdapter = {
classifyFailure: classifyBaijiahaoFailure,
};
const sohuhaoAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
},
async silentRefresh(account, partition) {
return await silentRefreshPublishAccountSession(account, partition);
},
classifyFailure: classifySohuhaoFailure,
};
const qiehaoAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
@@ -200,12 +291,62 @@ const bilibiliAdapter: PlatformAdapter = {
classifyFailure: classifyBilibiliFailure,
};
const wangyihaoAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
},
async silentRefresh(account, partition) {
return await silentRefreshPublishAccountSession(account, partition);
},
classifyFailure: classifyWangyihaoFailure,
};
const juejinAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
},
async silentRefresh(account, partition) {
return await silentRefreshPublishAccountSession(account, partition);
},
classifyFailure: classifyJuejinFailure,
};
const weixinGzhAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
},
async silentRefresh(account, partition) {
return await silentRefreshPublishAccountSession(account, partition);
},
classifyFailure: classifyWeixinGzhFailure,
};
const zolAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
},
async silentRefresh(account, partition) {
return await silentRefreshPublishAccountSession(account, partition);
},
classifyFailure: classifyZolFailure,
};
const dongchediAdapter: PlatformAdapter = {
async probe(account, partition) {
return await probePublishAccountSession(account, partition);
},
async silentRefresh(account, partition) {
return await silentRefreshPublishAccountSession(account, partition);
},
classifyFailure: classifyDongchediFailure,
};
const adapterRegistry = new Map<string, PlatformAdapter>(
[
...aiPlatformCatalog.map((platform) => platform.id),
"toutiaohao",
"baijiahao",
"sohu",
"sohuhao",
"qiehao",
"zhihu",
"wangyihao",
@@ -224,11 +365,23 @@ const adapterRegistry = new Map<string, PlatformAdapter>(
? wenxinAdapter
: platform === "baijiahao"
? baijiahaoAdapter
: platform === "qiehao"
? qiehaoAdapter
: platform === "bilibili"
? bilibiliAdapter
: genericAdapter,
: platform === "sohuhao"
? sohuhaoAdapter
: platform === "qiehao"
? qiehaoAdapter
: platform === "wangyihao"
? wangyihaoAdapter
: platform === "bilibili"
? bilibiliAdapter
: platform === "juejin"
? juejinAdapter
: platform === "weixin_gzh"
? weixinGzhAdapter
: platform === "zol"
? zolAdapter
: platform === "dongchedi"
? dongchediAdapter
: genericAdapter,
]),
);
@@ -19,11 +19,18 @@ import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
import {
baijiahaoAdapter,
bilibiliAdapter,
dongchediAdapter,
getMonitorAdapter,
jianshuAdapter,
juejinAdapter,
qiehaoAdapter,
smzdmAdapter,
sohuhaoAdapter,
toutiaoAdapter,
wangyihaoAdapter,
weixinGzhAdapter,
zhihuAdapter,
zolAdapter,
type AdapterExecutionResult,
type MonitorAdapter,
type PublishAdapter,
@@ -2895,18 +2902,39 @@ function selectPublishAdapter(platform: string): PublishAdapter | null {
if (platform === baijiahaoAdapter.platform) {
return baijiahaoAdapter;
}
if (platform === sohuhaoAdapter.platform) {
return sohuhaoAdapter;
}
if (platform === zhihuAdapter.platform) {
return zhihuAdapter;
}
if (platform === wangyihaoAdapter.platform) {
return wangyihaoAdapter;
}
if (platform === jianshuAdapter.platform) {
return jianshuAdapter;
}
if (platform === juejinAdapter.platform) {
return juejinAdapter;
}
if (platform === qiehaoAdapter.platform) {
return qiehaoAdapter;
}
if (platform === bilibiliAdapter.platform) {
return bilibiliAdapter;
}
if (platform === smzdmAdapter.platform) {
return smzdmAdapter;
}
if (platform === weixinGzhAdapter.platform) {
return weixinGzhAdapter;
}
if (platform === zolAdapter.platform) {
return zolAdapter;
}
if (platform === dongchediAdapter.platform) {
return dongchediAdapter;
}
return null;
}
+12 -2
View File
@@ -23,6 +23,16 @@
头条号:toutiaohao.ts
知乎: zhihu.ts
百家号:baijiao.ts (表格官方渲染成了图片,不可以的,我们需要把表格降级处理)
简书:jianshu.ts (图片未测,文字,表格 ok)
企鹅号:媒体资质未通过,另外要测
哔哩哔哩:平台表格就不支持;降级了
掘金:图 表 ok
什么值得买: 图 表 ok
企鹅号:媒体资质未通过,另外要测
简书:jianshu.ts (图片未测,文字,表格 ok)
网易:媒体资质没下来
搜狐:没注册成功
ZOL
懂车帝
微信公众号