Files
geo/apps/desktop-client/src/main/adapters/wangyihao.ts
T
root 162abdc97c
Backend CI / Backend (push) Has been cancelled
Frontend CI / Frontend (push) Failing after 1m39s
chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports)
- Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier
- Add root scripts: format, format:check, lint, lint:fix
- Reformat 257 files across admin-web, ops-web, desktop-client, packages
- Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters
- Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex
- Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:39:09 +08:00

1023 lines
30 KiB
TypeScript

import type { JsonValue } from '@geo/shared-types'
import type { Page } from 'playwright-core'
import {
WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE,
WANGYIHAO_PUBLISH_CLICK_MESSAGE,
WANGYIHAO_TOKEN_MISSING_MESSAGE,
} from '../../shared/publisher-errors'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
extractImageSources,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
} from './common'
import { fetchImageAssetBlob } from './media-image'
import { prepareWangyihaoArticleHtml } from './wangyihao-content'
import { wangyihaoPublicArticleUrl } from './wangyihao-links'
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 | string
message?: string
msg?: string
errMsg?: string
errorMsg?: string
data?: string
}
type WangyiPublishDetailResponse = {
code?: number | string
data?: {
wemediaId?: string | number
onlineState?: string | number
hasRecommRight?: boolean | number
allowedRepost?: boolean | number
newProminentFlag?: string | number
}
}
type WangyiAccount = {
realUserId: string
wemediaId: string
name: string
}
type WangyiPublishDetail = {
wemediaId: string
onlineState: string
hasRecommRight: boolean
allowedRepost: boolean
newProminentFlag: string
}
const WANGYI_API_ORIGIN = 'https://mp.163.com'
const WANGYI_CONSOLE_ORIGIN = 'http://mp.163.com'
const WANGYI_API_REFERER = `${WANGYI_API_ORIGIN}/subscribe_v4/index.html`
const WANGYI_HOME_URL = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html`
const WANGYI_MANAGE_URL = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html#/article-manage`
const WANGYI_CREATIVE_STATEMENT = '个人原创,仅供参考'
const WANGYI_PUBLISH_DELAY_MS = 2_000
const WANGYI_TOKEN_WAIT_MS = 12_000
const WANGYI_GUARDIAN_APP_ID = 'YD00250021379271'
const WANGYI_GUARDIAN_SCRIPT_URL =
'https://static.ws.126.net/163/mp/main/static/YiDunProtector-Web-2.0.7.js'
const WANGYI_TOKEN_ATTR = 'data-geo-wangyihao-token'
const WANGYI_TOKEN_STATUS_ATTR = 'data-geo-wangyihao-token-status'
const WANGYI_TOKEN_ERROR_ATTR = 'data-geo-wangyihao-token-error'
const WANGYI_RESPONSE_CODE_MESSAGES: Record<string, string> = {
'100001': '参数为空',
'100002': '参数错误',
'100003': '数据不存在',
'100004': '参数超出范围',
'100005': '数据已存在',
'100006': '请选择上传文件',
}
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
msg?: string
errMsg?: string
errorMsg?: string
code?: number | string
data?: unknown
}
| null
| undefined,
): string {
return (
response?.message ||
response?.msg ||
response?.errMsg ||
response?.errorMsg ||
(typeof response?.data === 'string' && !response.data.includes('=') ? response.data : '') ||
WANGYI_RESPONSE_CODE_MESSAGES[String(response?.code ?? '')] ||
`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_API_ORIGIN,
referer: WANGYI_API_REFERER,
...(cookie ? { cookie } : {}),
...extra,
}
}
async function fetchAccount(context: PublishAdapterContext): Promise<WangyiAccount | null> {
const response = await sessionFetchJson<WangyiInfoResponse>(
context.session,
`${WANGYI_API_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 fetchPublishDetail(
context: PublishAdapterContext,
account: WangyiAccount,
): Promise<WangyiPublishDetail> {
const fallback: WangyiPublishDetail = {
wemediaId: account.wemediaId,
onlineState: '',
hasRecommRight: false,
allowedRepost: false,
newProminentFlag: '',
}
const url = new URL(`${WANGYI_API_ORIGIN}/wemedia/article/postpage.do`)
url.searchParams.set('_', String(Date.now()))
url.searchParams.set('wemediaId', account.wemediaId)
url.searchParams.set('mediaId', account.wemediaId)
const response = await sessionFetchJson<WangyiPublishDetailResponse>(
context.session,
url.toString(),
{
credentials: 'include',
headers: await wangyiHeaders(context),
},
).catch(() => null)
if (String(response?.code) !== '1' || !response?.data) {
return fallback
}
return {
wemediaId: stringId(response.data.wemediaId) || account.wemediaId,
onlineState: stringId(response.data.onlineState),
hasRecommRight: response.data.hasRecommRight === true || response.data.hasRecommRight === 1,
allowedRepost: response.data.allowedRepost === true || response.data.allowedRepost === 1,
newProminentFlag: stringId(response.data.newProminentFlag),
}
}
async function getPublishToken(context: PublishAdapterContext): Promise<string> {
const page = context.playwright?.page
if (!page) {
throw new Error('wangyihao_playwright_missing')
}
for (let attempt = 0; attempt < 2; attempt += 1) {
await page
.goto(WANGYI_HOME_URL, {
waitUntil: attempt === 0 ? 'domcontentloaded' : 'load',
timeout: 20_000,
})
.catch(() => null)
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => null)
if (attempt > 0) {
await sleep(800, context.signal)
}
// 网易号的 neg 有时是顶层绑定,不一定挂在 window 上;这里保持和旧版插件一致的读取方式。
const token = (await readPublishTokenFromPage(page)).trim()
if (token) {
return token
}
}
return ''
}
async function readPublishTokenFromPage(page: Page): Promise<string> {
await page
.evaluate(
(attrs) => {
document.documentElement.removeAttribute(attrs.token)
document.documentElement.removeAttribute(attrs.status)
document.documentElement.removeAttribute(attrs.error)
},
{
token: WANGYI_TOKEN_ATTR,
status: WANGYI_TOKEN_STATUS_ATTR,
error: WANGYI_TOKEN_ERROR_ATTR,
},
)
.catch(() => null)
await page
.addScriptTag({
content: `
(() => {
const tokenAttr = ${JSON.stringify(WANGYI_TOKEN_ATTR)};
const statusAttr = ${JSON.stringify(WANGYI_TOKEN_STATUS_ATTR)};
const errorAttr = ${JSON.stringify(WANGYI_TOKEN_ERROR_ATTR)};
const guardianScriptUrl = ${JSON.stringify(WANGYI_GUARDIAN_SCRIPT_URL)};
const guardianAppId = ${JSON.stringify(WANGYI_GUARDIAN_APP_ID)};
const timeoutMs = ${WANGYI_TOKEN_WAIT_MS};
const retryMs = 250;
const scriptLoadTimeoutMs = 6_000;
const root = document.documentElement;
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const setStatus = (status, error = "") => {
root.setAttribute(statusAttr, status);
if (error) {
root.setAttribute(errorAttr, String(error).slice(0, 240));
}
};
const readTopLevelNeg = () => {
try {
return typeof neg !== "undefined" ? neg : null;
} catch (_error) {
return null;
}
};
const readGuardianFactory = () => {
try {
return typeof createNEGuardian === "function" ? createNEGuardian : null;
} catch (_error) {
return null;
}
};
const readGuardian = () => {
const topLevelNeg = readTopLevelNeg();
if (topLevelNeg && typeof topLevelNeg.getToken === "function") {
return topLevelNeg;
}
if (window.neg && typeof window.neg.getToken === "function") {
return window.neg;
}
if (window.__geoWangyihaoNeg && typeof window.__geoWangyihaoNeg.getToken === "function") {
return window.__geoWangyihaoNeg;
}
const createGuardian = readGuardianFactory();
if (createGuardian) {
window.__geoWangyihaoNeg = createGuardian({ appId: guardianAppId, timeout: 6_000 });
return window.__geoWangyihaoNeg;
}
return null;
};
const loadGuardianScript = () => new Promise((resolve) => {
if (readGuardianFactory()) {
resolve(true);
return;
}
const script = document.createElement("script");
let done = false;
const finish = (loaded) => {
if (done) {
return;
}
done = true;
resolve(loaded);
};
script.src = guardianScriptUrl;
script.async = false;
script.onload = () => finish(true);
script.onerror = () => finish(false);
setTimeout(() => finish(false), scriptLoadTimeoutMs);
document.head.appendChild(script);
});
const requestToken = async (client) => {
const result = await Promise.resolve(client.getToken());
return typeof result?.token === "string" ? result.token.trim() : "";
};
window.__geoWangyihaoTokenPromise = (async () => {
const deadline = Date.now() + timeoutMs;
setStatus("waiting");
if (document.readyState === "loading") {
await new Promise((resolve) => {
document.addEventListener("DOMContentLoaded", resolve, { once: true });
});
}
if (!readGuardian()) {
setStatus("loading_guardian");
await loadGuardianScript();
}
while (Date.now() <= deadline) {
const client = readGuardian();
if (!client) {
setStatus("missing_client");
await sleep(retryMs);
continue;
}
try {
setStatus("requesting");
const token = await requestToken(client);
if (token) {
root.setAttribute(tokenAttr, token);
setStatus("ready");
return token;
}
setStatus("empty_token");
} catch (error) {
setStatus("retrying", error && error.message ? error.message : error);
}
await sleep(retryMs);
}
setStatus("missing");
return "";
})().catch((error) => {
setStatus("failed", error && error.message ? error.message : error);
return "";
});
})();
`,
})
.catch(() => null)
await page
.waitForFunction(
({ token, status }) => {
const root = document.documentElement
const statusValue = root.getAttribute(status)
return (
Boolean(root.getAttribute(token)) || statusValue === 'missing' || statusValue === 'failed'
)
},
{
token: WANGYI_TOKEN_ATTR,
status: WANGYI_TOKEN_STATUS_ATTR,
},
{ timeout: WANGYI_TOKEN_WAIT_MS + 2_000 },
)
.catch(() => null)
const token = await page
.evaluate((attr) => document.documentElement.getAttribute(attr) ?? '', WANGYI_TOKEN_ATTR)
.catch(() => '')
await page
.evaluate(
(attrs) => {
document.documentElement.removeAttribute(attrs.token)
document.documentElement.removeAttribute(attrs.status)
document.documentElement.removeAttribute(attrs.error)
},
{
token: WANGYI_TOKEN_ATTR,
status: WANGYI_TOKEN_STATUS_ATTR,
error: WANGYI_TOKEN_ERROR_ATTR,
},
)
.catch(() => null)
return token
}
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_API_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,
detail: WangyiPublishDetail,
html: string,
token: string,
): Promise<string> {
const url = new URL(`${WANGYI_API_ORIGIN}/wemedia/article/status/api/publishV2.do`)
url.searchParams.set('_', String(Date.now()))
const body = buildDraftBody(context, account, detail, html, token)
const response =
(await saveDraftWithPage(context, url.toString(), body).catch(() => null)) ??
(await saveDraftWithSession(context, url.toString(), body))
if (String(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()
}
function buildDraftBody(
context: PublishAdapterContext,
account: WangyiAccount,
detail: WangyiPublishDetail,
html: string,
token: string,
): URLSearchParams {
const body = new URLSearchParams()
body.set('wemediaId', detail.wemediaId || account.wemediaId)
body.set('articleId', '-1')
body.set('title', context.article.title?.trim() || '未命名文章')
body.set('content', html)
body.set('cover', 'auto')
body.set('operation', 'saveDraft')
body.set('scheduled', '0')
body.set('ursToken', token)
body.set('picUrl', '')
body.set('original', '0')
body.set('creativeStatement', WANGYI_CREATIVE_STATEMENT)
if (detail.onlineState) {
body.set('onlineState', detail.onlineState)
}
if (detail.hasRecommRight) {
body.set('recommendState', '0')
}
if (detail.allowedRepost) {
body.set('allowedRepost', '1')
}
if (detail.newProminentFlag === '1') {
body.set('newProminentState', '0')
}
return body
}
async function saveDraftWithSession(
context: PublishAdapterContext,
url: string,
body: URLSearchParams,
): Promise<WangyiDraftResponse> {
return await sessionFetchJson<WangyiDraftResponse>(context.session, url, {
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',
}),
)
}
async function saveDraftWithPage(
context: PublishAdapterContext,
url: string,
body: URLSearchParams,
): Promise<WangyiDraftResponse | null> {
const page = context.playwright?.page
if (!page) {
return null
}
const serialized = await page.evaluate(
async ({ requestUrl, requestBody }) => {
const response = await fetch(requestUrl, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-b3-sampled': '1',
'x-b3-spanid': '0',
},
body: requestBody,
})
return await response.text()
},
{
requestUrl: url,
requestBody: body.toString(),
},
)
if (!serialized) {
return null
}
return JSON.parse(serialized) as WangyiDraftResponse
}
type WangyiPublishNetworkResult = {
success: boolean
code: string
message: string
}
function compactText(value: string): string {
return value.trim().replace(/\s+/g, '')
}
function isPublishRequestBody(postData: string): boolean {
if (!postData) {
return false
}
const decoded = (() => {
try {
return decodeURIComponent(postData)
} catch {
return postData
}
})()
return /(?:^|&)operation=publish(?:&|$)/.test(decoded)
}
async function clickFirstVisibleAction(
page: Page,
texts: string[],
timeoutMs = 10_000,
): Promise<boolean> {
const selectors = [
'.ant-modal button',
'.ant-popover button',
'button',
"[role='button']",
'.ant-btn',
'a',
]
const targets = texts.map(compactText)
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: 300 })
.catch(async () => await candidate.textContent({ timeout: 300 }).catch(() => ''))
if (!targets.includes(compactText(text || ''))) {
continue
}
const visible = await candidate.isVisible().catch(() => false)
const enabled = await candidate.isEnabled().catch(() => false)
if (!visible || !enabled) {
continue
}
await candidate.scrollIntoViewIfNeeded({ timeout: 1_000 }).catch(() => undefined)
await candidate.click({ timeout: 5_000 })
return true
}
}
const clicked = await page
.evaluate((targetTexts) => {
const compact = (value: string) => value.trim().replace(/\s+/g, '')
const isVisible = (element: Element) => {
const rect = element.getBoundingClientRect()
if (rect.width <= 0 || rect.height <= 0) {
return false
}
const style = window.getComputedStyle(element)
return style.display !== 'none' && style.visibility !== 'hidden' && style.opacity !== '0'
}
const isDisabled = (element: Element) => {
const htmlElement = element as HTMLElement & { disabled?: boolean }
return (
Boolean(htmlElement.disabled) ||
element.getAttribute('aria-disabled') === 'true' ||
/\b(disabled|is-disabled|ant-btn-disabled)\b/i.test(element.className?.toString() || '')
)
}
const candidates = Array.from(
document.querySelectorAll<HTMLElement>(
".ant-modal button, .ant-popover button, button, [role='button'], .ant-btn, a",
),
)
for (const candidate of candidates) {
if (!targetTexts.includes(compact(candidate.innerText || candidate.textContent || ''))) {
continue
}
const clickable =
candidate.closest<HTMLElement>("button, [role='button'], a, .ant-btn") ?? candidate
if (!isVisible(clickable) || isDisabled(clickable)) {
continue
}
clickable.scrollIntoView({ block: 'center', inline: 'center' })
clickable.click()
return true
}
return false
}, targets)
.catch(() => false)
if (clicked) {
return true
}
await page.waitForTimeout(150).catch(() => undefined)
}
return false
}
async function waitForWangyiEditorReady(page: Page): Promise<void> {
await page
.waitForFunction(
() => {
if (document.readyState === 'loading') {
return false
}
const actions = Array.from(
document.querySelectorAll("button, [role='button'], .ant-btn, a"),
)
return actions.some(
(element) => (element.textContent || '').trim().replace(/\s+/g, '') === '发布',
)
},
undefined,
{ timeout: 20_000 },
)
.catch(() => undefined)
}
async function waitForPublishNetworkResponse(
page: Page,
timeoutMs: number,
): Promise<WangyiPublishNetworkResult | null> {
const response = await page
.waitForResponse(
(candidate) => {
if (!candidate.url().includes('/wemedia/article/status/api/publishV2.do')) {
return false
}
if (candidate.request().method().toUpperCase() !== 'POST') {
return false
}
return isPublishRequestBody(candidate.request().postData() || '')
},
{ timeout: timeoutMs },
)
.catch(() => null)
if (!response) {
return null
}
const text = await response.text().catch(() => '')
let parsed: WangyiDraftResponse | null = null
try {
parsed = text ? (JSON.parse(text) as WangyiDraftResponse) : null
} catch {
parsed = {
code: response.status(),
message: text || `http_${response.status()}`,
}
}
return {
success: String(parsed?.code) === '1',
code: String(parsed?.code ?? response.status()),
message: wangyiResponseMessage(parsed),
}
}
async function readWangyiPageMessage(page: Page): Promise<string> {
return await page
.evaluate(() => {
const text = document.body?.innerText || ''
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => /(发布|发表|提交|审核|成功|失败|错误|验证|登录|确认)/.test(line))
.slice(-8)
.join(' / ')
.slice(0, 300)
})
.catch(() => '')
}
async function waitForPublishCompletion(
page: Page,
oldUrl: string,
responsePromise: Promise<WangyiPublishNetworkResult | null>,
timeoutMs: number,
): Promise<boolean> {
const result = await Promise.race([
responsePromise.then((response) => (response ? { kind: 'network' as const, response } : null)),
page
.waitForURL((url) => url.toString() !== oldUrl, { timeout: timeoutMs })
.then(() => ({ kind: 'navigation' as const }))
.catch(() => null),
page
.waitForFunction(
() =>
/(发布成功|发表成功|提交成功|已提交审核|已进入审核|审核中)/.test(
document.body?.innerText || '',
),
undefined,
{ timeout: timeoutMs },
)
.then(() => ({ kind: 'page' as const }))
.catch(() => null),
page.waitForTimeout(timeoutMs).then(() => null),
])
if (!result) {
return false
}
if (result.kind === 'network') {
if (result.response.success) {
return true
}
throw new Error(`wangyihao_publish_failed:${result.response.message || result.response.code}`)
}
return true
}
async function publishDraft(context: PublishAdapterContext, draftId: string): Promise<boolean> {
const page = context.playwright?.page
if (!page) {
throw new Error('wangyihao_playwright_missing')
}
const draftUrl = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`
await page.goto(draftUrl, {
waitUntil: 'domcontentloaded',
})
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => null)
await waitForWangyiEditorReady(page)
const oldUrl = page.url()
const publishResponsePromise = waitForPublishNetworkResponse(page, 22_000)
const firstClicked = await clickFirstVisibleAction(page, ['发布'], 12_000)
if (!firstClicked) {
const message = await readWangyiPageMessage(page)
throw new Error(`wangyihao_publish_click_failed${message ? `:${message}` : ''}`)
}
await sleep(WANGYI_PUBLISH_DELAY_MS, context.signal)
if (await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 2_500)) {
return true
}
const confirmed = await clickFirstVisibleAction(
page,
['确认发布', '确定发布', '确认', '确定'],
8_000,
)
if (!confirmed) {
return await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 4_000)
}
return (
(await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 10_000)) ||
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: wangyihaoPublicArticleUrl(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 === '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: WANGYIHAO_TOKEN_MISSING_MESSAGE,
error: {
code: 'wangyihao_token_missing',
message: 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'
) {
const isParameterError = /(?:wangyihao_error_)?100002|参数错误/.test(message)
return {
status: 'failed',
summary: isParameterError
? WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE
: '网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。',
error: {
code: 'wangyihao_draft_save_failed',
message: isParameterError ? WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE : message,
},
}
}
if (message.startsWith('wangyihao_publish_click_failed')) {
return {
status: 'failed',
summary: WANGYIHAO_PUBLISH_CLICK_MESSAGE,
error: {
code: 'wangyihao_publish_click_failed',
message: WANGYIHAO_PUBLISH_CLICK_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')
}
const detail = await fetchPublishDetail(context, account)
context.reportProgress('wangyihao.normalize_html')
let html = prepareWangyihaoArticleHtml(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, detail, 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)
}
},
}