430 lines
12 KiB
TypeScript
430 lines
12 KiB
TypeScript
|
|
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);
|
||
|
|
}
|
||
|
|
},
|
||
|
|
};
|