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