import type { JsonValue } from "@geo/shared-types"; import type { Page } from "playwright-core"; import { extractImageSources, normalizeArticleHtml, sessionCookieHeader, sessionFetchJson, uploadHtmlImages, } from "./common"; import { fetchImageAssetBlob } from "./media-image"; import type { PublishAdapter, PublishAdapterContext } from "./base"; type WangyiInfoResponse = { data?: { mediaInfo?: { userId?: string | number; wemediaId?: string | number; tname?: string; icon?: string; }; }; }; type WangyiUploadResponse = { code?: number; message?: string; data?: { url?: string; }; }; type WangyiDraftResponse = { code?: number; message?: string; data?: string; }; type WangyiAccount = { realUserId: string; wemediaId: string; name: string; }; const WANGYI_ORIGIN = "https://mp.163.com"; const WANGYI_HOME_URL = `${WANGYI_ORIGIN}/subscribe_v4/index.html`; const WANGYI_MANAGE_URL = `${WANGYI_ORIGIN}/subscribe_v4/index.html#/article-manage`; const WANGYI_PUBLISH_DELAY_MS = 2_000; function stringId(value: unknown): string { if (typeof value === "number") { return Number.isFinite(value) ? String(value) : ""; } if (typeof value !== "string") { return ""; } return value.trim(); } function parseFormString(value: string): Record { return Object.fromEntries(new URLSearchParams(value)); } function wangyiResponseMessage(response: { message?: string; code?: number } | null | undefined): string { return response?.message || `wangyihao_error_${response?.code ?? "unknown"}`; } function isWangyiChallengeMessage(message: string): boolean { return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i .test(message); } function sleep(ms: number, signal?: AbortSignal): Promise { if (signal?.aborted) { return Promise.reject(new Error("adapter_aborted")); } return new Promise((resolve, reject) => { const cleanup = () => signal?.removeEventListener("abort", onAbort); const timeout = setTimeout(() => { cleanup(); resolve(); }, ms); const onAbort = () => { clearTimeout(timeout); cleanup(); reject(new Error("adapter_aborted")); }; signal?.addEventListener("abort", onAbort, { once: true }); }); } async function wangyiHeaders( context: PublishAdapterContext, extra: Record = {}, ): Promise> { const cookie = (await sessionCookieHeader(context.session, "mp.163.com").catch(() => "")) || (await sessionCookieHeader(context.session, "163.com").catch(() => "")); return { accept: "application/json, text/plain, */*", origin: WANGYI_ORIGIN, referer: WANGYI_HOME_URL, ...(cookie ? { cookie } : {}), ...extra, }; } async function fetchAccount(context: PublishAdapterContext): Promise { const response = await sessionFetchJson( context.session, `${WANGYI_ORIGIN}/wemedia/info.do`, { credentials: "include", headers: await wangyiHeaders(context), }, ).catch(() => null); const media = response?.data?.mediaInfo; const realUserId = stringId(media?.userId); const wemediaId = stringId(media?.wemediaId); const name = media?.tname?.trim() || ""; return realUserId && wemediaId && name ? { realUserId, wemediaId, name } : null; } async function getPublishToken(context: PublishAdapterContext): Promise { const page = context.playwright?.page; if (!page) { throw new Error("wangyihao_playwright_missing"); } await page.goto(WANGYI_HOME_URL, { waitUntil: "domcontentloaded", }); await page.waitForFunction( "() => Boolean(window.neg && typeof window.neg.getToken === 'function')", undefined, { timeout: 12_000 }, ).catch(() => null); const token = await page.evaluate(async () => { const neg = (window as unknown as { neg?: { getToken?: () => Promise<{ token?: string }> } }).neg; const result = await neg?.getToken?.().catch(() => null); return result?.token ?? ""; }).catch(() => ""); return token.trim(); } async function uploadImage( context: PublishAdapterContext, account: WangyiAccount, sourceUrl: string, ): Promise { const image = await fetchImageAssetBlob(sourceUrl); if (!image) { return null; } const form = new FormData(); form.append("file", image.blob, image.fileName); form.append("from", "neteasecode_mp"); form.append("logotext", account.name); const url = new URL(`${WANGYI_ORIGIN}/api/v3/upload/picupload`); url.searchParams.set("_", String(Date.now())); url.searchParams.set("wemediaId", account.wemediaId); url.searchParams.set("realUserId", account.realUserId); const response = await sessionFetchJson( context.session, url.toString(), { method: "POST", credentials: "include", headers: await wangyiHeaders(context, { "x-b3-sampled": "1", "x-b3-spanid": "0", }), body: form, }, ).catch(() => null); return response?.data?.url?.trim() || null; } async function saveDraft( context: PublishAdapterContext, account: WangyiAccount, html: string, token: string, ): Promise { const url = new URL(`${WANGYI_ORIGIN}/wemedia/article/status/api/publishV2.do`); url.searchParams.set("_", String(Date.now())); url.searchParams.set("wemediaId", account.wemediaId); url.searchParams.set("realUserId", account.realUserId); const body = new URLSearchParams({ wemediaId: account.wemediaId, operation: "saveDraft", articleId: "-1", title: context.article.title?.trim() || "未命名文章", content: html, essayClassify: "", essayUrl: "", essayTitle: "", essayId: "", activitySubjectId: "", subjectId: "", original: "0", picUrl: "", onlineState: "2", cover: "auto", scheduled: "0", creativeStatement: "内容由AI生成", ursToken: token, }); const response = await sessionFetchJson( context.session, url.toString(), { method: "POST", credentials: "include", headers: await wangyiHeaders(context, { "content-type": "application/x-www-form-urlencoded", "x-b3-sampled": "1", "x-b3-spanid": "0", }), body, }, ).catch((error): WangyiDraftResponse => ({ code: -1, message: error instanceof Error ? error.message : "wangyihao_draft_save_failed", })); if (response.code !== 1 || !response.data) { const message = wangyiResponseMessage(response); if (isWangyiChallengeMessage(message)) { throw new Error(`wangyihao_challenge_required:${message}`); } throw new Error(`wangyihao_draft_save_failed:${message}`); } const draftId = parseFormString(response.data).docId ?? ""; if (!draftId.trim()) { throw new Error("wangyihao_draft_id_missing"); } return draftId.trim(); } async function clickFirstVisibleButton(page: Page, text: string): Promise { const button = page.locator("button").filter({ hasText: text }).first(); return await button.click({ timeout: 5_000 }).then( () => true, () => false, ); } async function publishDraft(context: PublishAdapterContext, draftId: string): Promise { const page = context.playwright?.page; if (!page) { throw new Error("wangyihao_playwright_missing"); } const oldUrl = `https://mp.163.com/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`; await page.goto(oldUrl, { waitUntil: "domcontentloaded", }); await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => null); await clickFirstVisibleButton(page, "发布"); await sleep(WANGYI_PUBLISH_DELAY_MS, context.signal); const firstNavigated = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 5_000 }).then( () => true, () => false, ); if (firstNavigated) { return true; } await clickFirstVisibleButton(page, "确认发布"); const confirmed = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 8_000 }).then( () => true, () => false, ); return confirmed || page.url() !== oldUrl; } function buildResultPayload(articleId: string, mediaName: string): Record { return { platform: "wangyihao", media_name: mediaName, external_article_id: articleId, external_manage_url: WANGYI_MANAGE_URL, external_article_url: null, publish_type: "publish", }; } function failureResult(error: unknown): ReturnType extends Promise ? T : never { const message = error instanceof Error ? error.message : String(error); if (message === "wangyihao_not_logged_in") { return { status: "failed", summary: "网易号登录态失效,无法执行发布。", error: { code: "wangyihao_not_logged_in", message, }, }; } if (message === "article_content_empty") { return { status: "failed", summary: "文章内容为空,无法推送到网易号。", error: { code: "article_content_empty", message, }, }; } if (message === "wangyihao_token_missing") { return { status: "failed", summary: "网易号发布 token 获取失败,请重新打开网易号后台确认登录状态。", error: { code: "wangyihao_token_missing", message, }, }; } if (message.startsWith("wangyihao_challenge_required") || isWangyiChallengeMessage(message)) { return { status: "failed", summary: "网易号触发平台验证,请在网易号后台完成验证后重试。", error: { code: "wangyihao_challenge_required", message, }, }; } if (message.startsWith("wangyihao_draft_save_failed") || message === "wangyihao_draft_id_missing") { return { status: "failed", summary: "网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。", error: { code: "wangyihao_draft_save_failed", message, }, }; } if (message === "wangyihao_publish_click_failed") { return { status: "failed", summary: "网易号草稿已保存,但页面自动点击发布失败,请到网易号后台手动发布。", error: { code: "wangyihao_publish_click_failed", message, }, }; } return { status: "failed", summary: "网易号发布失败。", error: { code: "wangyihao_publish_failed", message, }, }; } export const wangyihaoAdapter: PublishAdapter = { platform: "wangyihao", executionMode: "playwright", async publish(context) { try { context.reportProgress("wangyihao.detect_login"); const account = await fetchAccount(context); if (!account) { throw new Error("wangyihao_not_logged_in"); } context.reportProgress("wangyihao.normalize_html"); let html = normalizeArticleHtml(context.article); if (!html) { throw new Error("article_content_empty"); } const coverAssetUrl = context.article.cover_asset_url?.trim() || ""; if (coverAssetUrl && extractImageSources(html).length === 0) { html = `${html}`; } context.reportProgress("wangyihao.get_token"); const token = await getPublishToken(context); if (!token) { throw new Error("wangyihao_token_missing"); } context.reportProgress("wangyihao.upload_content_images"); const processed = await uploadHtmlImages(html, async (sourceUrl) => uploadImage(context, account, sourceUrl)); context.reportProgress("wangyihao.save_draft"); const draftId = await saveDraft(context, account, processed.html, token); context.reportProgress("wangyihao.publish_draft"); const published = await publishDraft(context, draftId); if (!published) { throw new Error("wangyihao_publish_click_failed"); } return { status: "succeeded", summary: "网易号发布成功。", payload: buildResultPayload(draftId, account.name), }; } catch (error) { return failureResult(error); } }, };