From 290da7cd50e9eca5f85a3d861a08ba76c49c3001 Mon Sep 17 00:00:00 2001 From: liangxu Date: Mon, 27 Apr 2026 00:57:37 +0800 Subject: [PATCH] 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) --- .../desktop-client/src/main/account-binder.ts | 275 ++++- .../src/main/adapters/dongchedi.ts | 686 ++++++++++++ .../desktop-client/src/main/adapters/index.ts | 7 + .../src/main/adapters/juejin.ts | 984 ++++++++++++++++++ .../src/main/adapters/smzdm.test.ts | 64 ++ .../desktop-client/src/main/adapters/smzdm.ts | 895 ++++++++++++++++ .../src/main/adapters/sohuhao.ts | 362 +++++++ .../src/main/adapters/wangyihao.ts | 429 ++++++++ .../src/main/adapters/weixin-gzh.ts | 579 +++++++++++ apps/desktop-client/src/main/adapters/zol.ts | 454 ++++++++ .../src/main/platform-auth-adapters.test.ts | 72 ++ .../src/main/platform-auth-adapters.ts | 165 ++- .../src/main/runtime-controller.ts | 28 + 媒体发布手工自测.md | 14 +- 14 files changed, 4997 insertions(+), 17 deletions(-) create mode 100644 apps/desktop-client/src/main/adapters/dongchedi.ts create mode 100644 apps/desktop-client/src/main/adapters/juejin.ts create mode 100644 apps/desktop-client/src/main/adapters/smzdm.test.ts create mode 100644 apps/desktop-client/src/main/adapters/smzdm.ts create mode 100644 apps/desktop-client/src/main/adapters/sohuhao.ts create mode 100644 apps/desktop-client/src/main/adapters/wangyihao.ts create mode 100644 apps/desktop-client/src/main/adapters/weixin-gzh.ts create mode 100644 apps/desktop-client/src/main/adapters/zol.ts diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index 1fdf021..3d9950f 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -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 { - const response = await sessionFetchJson( - 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 { + 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 | 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 { + 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 { + const currentUserUrl = "https://zhiyou.smzdm.com/user/info/jsonp_get_current"; + const pageState = await readSmzdmPageState(webContents).catch(() => null); + const pageResponse = await pageFetchJson( + 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( + 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( + 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 { const html = await sessionFetchText(session, "https://mp.weixin.qq.com/").catch(() => ""); if (!html) { diff --git a/apps/desktop-client/src/main/adapters/dongchedi.ts b/apps/desktop-client/src/main/adapters/dongchedi.ts new file mode 100644 index 0000000..41d778d --- /dev/null +++ b/apps/desktop-client/src/main/adapters/dongchedi.ts @@ -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; + }; +}; + +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 = {}, +): Promise> { + 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(input: string, init?: RequestInit): Promise { + 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 { + 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( + 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; + body?: string; + date?: Date; +}): Record { + 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 = { + "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> { + const response = await sessionFetchJson( + 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 { + if (!ratio) { + return source; + } + return await cropImageAssetBlob(source, ratio); +} + +async function uploadImage( + context: PublishAdapterContext, + sourceUrl: string, + ratio?: number, +): Promise { + 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( + 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>( + 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( + 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 { + const imageUrl = normalizeRemoteUrl(sourceUrl); + if (!imageUrl) { + return null; + } + + const response = await sessionFetchJson( + 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 { + 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(//gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/g, "'") + .replace(/\s+/g, "") + .length; +} + +function buildPublishBody( + title: string, + content: string, + verticalCover: DongchediUploadedImage, + feedCover: DongchediUploadedImage, +): Record { + 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 { + 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 extends Promise ? 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 = `${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( + 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index 87e0729..28b9ba5 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -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"; diff --git a/apps/desktop-client/src/main/adapters/juejin.ts b/apps/desktop-client/src/main/adapters/juejin.ts new file mode 100644 index 0000000..147801e --- /dev/null +++ b/apps/desktop-client/src/main/adapters/juejin.ts @@ -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["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): 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 = {}, +): Promise> { + 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 { + const response = await sessionFetchJson( + 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 { + 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(); + + 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(/]*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, +): Promise { + const sources = extractMarkdownImageSources(markdown); + if (!sources.length) { + return markdown; + } + + const uploaded = new Map(); + 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(//gi, "\n") + .replace(/<\/(?:p|div|section|article|h[1-6]|li|tr)>/gi, "\n") + .replace(/<[^>]+>/g, "") + .replace(/ /g, " ") + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/"/g, "\"") + .replace(/'/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(/]*>([\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(/]*>[\s\S]*?<\/table>/gi, (tableHtml) => `\n\n${htmlTableToMarkdown(tableHtml)}\n\n`); + next = next.replace(/]*alt=(['"])(.*?)\1[^>]*src=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, alt, _q2, src) => + `\n![${stripHtml(alt)}](${String(src).trim()})\n`, + ); + next = next.replace(/]*src=(['"])(.*?)\1[^>]*alt=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, src, _q2, alt) => + `\n![${stripHtml(alt)}](${String(src).trim()})\n`, + ); + next = next.replace(/]*src=(['"])(.*?)\1[^>]*>/gi, (_match, _q, src) => `\n![](${String(src).trim()})\n`); + next = next.replace(/]*>([\s\S]*?)<\/h1>/gi, (_match, content) => `\n# ${stripHtml(content)}\n`); + next = next.replace(/]*>([\s\S]*?)<\/h2>/gi, (_match, content) => `\n## ${stripHtml(content)}\n`); + next = next.replace(/]*>([\s\S]*?)<\/h3>/gi, (_match, content) => `\n### ${stripHtml(content)}\n`); + next = next.replace(/]*>([\s\S]*?)<\/li>/gi, (_match, content) => `\n- ${stripHtml(content)}`); + next = next.replace(/]*>([\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 { + 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; + body?: string; +}): Record { + 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 = { + "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(input: string, init?: RequestInit): Promise { + 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 { + const response = await sessionFetchJson( + 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(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>, blob: Blob): Promise { + 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 { + 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(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 { + const response = await sessionFetchJson( + 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 { + 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, + markdown: string, + uuid: string, +): Promise { + 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( + 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["page"], + selectors: string[], + texts: string[], + timeoutMs: number, +): Promise { + 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 { + 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> { + if (Array.isArray(data)) { + return data.filter((item): item is Record => Boolean(item) && typeof item === "object"); + } + if (data && typeof data === "object") { + const record = data as Record; + for (const key of ["list", "article_list", "articles"]) { + const value = record[key]; + if (Array.isArray(value)) { + return value.filter((item): item is Record => 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 + : {}; + 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 { + for (let attempt = 0; attempt < 6; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => { + setTimeout(resolve, 1_500); + }); + } + + const response = await sessionFetchJson( + 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 { + 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 extends Promise ? 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/smzdm.test.ts b/apps/desktop-client/src/main/adapters/smzdm.test.ts new file mode 100644 index 0000000..988a9a8 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/smzdm.test.ts @@ -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); + }); +}); diff --git a/apps/desktop-client/src/main/adapters/smzdm.ts b/apps/desktop-client/src/main/adapters/smzdm.ts new file mode 100644 index 0000000..02dde46 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/smzdm.ts @@ -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 { + 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 { + return await sessionCookieHeader(context.session, "smzdm.com").catch(() => ""); +} + +async function smzdmHeaders( + context: PublishAdapterContext, + extra: Record = {}, +): Promise> { + 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 { + const response = await sessionFetchJson( + 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 { + 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("a.release-new") + ?? Array.from(document.querySelectorAll("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 { + 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 { + 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( + 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( + 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( + 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(); + 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> { + 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)) { + 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 { + 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 { + 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 extends Promise ? 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 = `${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( + 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/sohuhao.ts b/apps/desktop-client/src/main/adapters/sohuhao.ts new file mode 100644 index 0000000..96b2ffe --- /dev/null +++ b/apps/desktop-client/src/main/adapters/sohuhao.ts @@ -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): 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 { + const cookies = await context.session.cookies.get({ url }).catch(() => []); + return cookies.map((cookie) => `${cookie.name}=${cookie.value}`).join("; "); +} + +async function sohuSpCm(context: PublishAdapterContext): Promise { + 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 = {}, +): Promise> { + 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 { + const registerInfo = await sessionFetchJson( + 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( + 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 { + 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( + 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 { + 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( + 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 { + 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 extends Promise ? 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 = `${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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/wangyihao.ts b/apps/desktop-client/src/main/adapters/wangyihao.ts new file mode 100644 index 0000000..2cf3fb0 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/wangyihao.ts @@ -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 { + 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 { + 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 = {}, +): Promise> { + 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 { + const response = await sessionFetchJson( + 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 { + 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 { + 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( + 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 { + 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( + 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 { + 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 { + 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 { + 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 extends Promise ? 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 = `${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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/weixin-gzh.ts b/apps/desktop-client/src/main/adapters/weixin-gzh.ts new file mode 100644 index 0000000..5da9996 --- /dev/null +++ b/apps/desktop-client/src/main/adapters/weixin-gzh.ts @@ -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 { + 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 = {}, +): Promise> { + 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 { + 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(//gi, ""); + next = next.replace(/<(object|embed|svg)[\s\S]*?<\/\1>/gi, ""); + next = next.replace(/]*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 `

formula

`; + }); + next = next.replace(/\$([^$]+)\$/g, (match, latex: string) => { + if (!isLatexFormula(latex)) { + return match; + } + return `formula`; + }); + return next; +} + +function stripExternalLinks(html: string): string { + return html.replace( + /]*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 `
${html}
`; +} + +async function uploadImage( + context: PublishAdapterContext, + meta: WeixinMeta, + sourceUrl: string, +): Promise { + 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( + 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, rect1x1: ReturnType): 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 { + 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( + 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 = { + [-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 { + 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 extends Promise ? 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( + 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/zol.ts b/apps/desktop-client/src/main/adapters/zol.ts new file mode 100644 index 0000000..66efe2e --- /dev/null +++ b/apps/desktop-client/src/main/adapters/zol.ts @@ -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 = {}, +): Promise> { + 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 { + const response = await sessionFetchJson( + 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 { + const response = await sessionFetchJson( + 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 { + 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( + 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 { + 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 { + const networkPic = normalizeRemoteUrl(sourceUrl); + if (!networkPic) { + return null; + } + + const form = new FormData(); + form.append("networkPic", networkPic); + form.append("siteType", "1"); + + const response = await sessionFetchJson( + 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 { + 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( + 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 { + 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 extends Promise ? 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 = `${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( + 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/platform-auth-adapters.test.ts b/apps/desktop-client/src/main/platform-auth-adapters.test.ts index fc93820..f92135b 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.test.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.test.ts @@ -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"); + }); }); diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index 118160f..893d24d 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -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( [ ...aiPlatformCatalog.map((platform) => platform.id), "toutiaohao", "baijiahao", - "sohu", + "sohuhao", "qiehao", "zhihu", "wangyihao", @@ -224,11 +365,23 @@ const adapterRegistry = new Map( ? 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, ]), ); diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index a0e3835..714e208 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -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; } diff --git a/媒体发布手工自测.md b/媒体发布手工自测.md index 2d4f8a3..6702804 100644 --- a/媒体发布手工自测.md +++ b/媒体发布手工自测.md @@ -23,6 +23,16 @@ 头条号:toutiaohao.ts 知乎: zhihu.ts 百家号:baijiao.ts (表格官方渲染成了图片,不可以的,我们需要把表格降级处理) -简书:jianshu.ts (图片未测,文字,表格 ok) -企鹅号:媒体资质未通过,另外要测 哔哩哔哩:平台表格就不支持;降级了 +掘金:图 表 ok +什么值得买: 图 表 ok + + +企鹅号:媒体资质未通过,另外要测 +简书:jianshu.ts (图片未测,文字,表格 ok) + +网易:媒体资质没下来 +搜狐:没注册成功 +ZOL +懂车帝 +微信公众号 \ No newline at end of file