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
@@ -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);
}
},
};