feat(publish): add seven publish adapters for new media platforms
Wire sohuhao, wangyihao, juejin, smzdm, weixin-gzh, zol, and dongchedi into the publish runtime: each ships its own publish protocol and auth-failure classifier (login/token/challenge codes) plus registry entries in selectPublishAdapter and the auth adapter map. Smzdm bind detection now triple-falls-back across page-context fetch, session fetch, cookie fetch, and a final cookie-derived account so the nickname-less guest profile still resolves to a stable platform UID. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user