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:
@@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user