896 lines
24 KiB
TypeScript
896 lines
24 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|