|
|
|
@@ -3,13 +3,18 @@ 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";
|
|
|
|
|
import { prepareWangyihaoArticleHtml } from "./wangyihao-content";
|
|
|
|
|
import {
|
|
|
|
|
WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE,
|
|
|
|
|
WANGYIHAO_PUBLISH_CLICK_MESSAGE,
|
|
|
|
|
WANGYIHAO_TOKEN_MISSING_MESSAGE,
|
|
|
|
|
} from "../../shared/publisher-errors";
|
|
|
|
|
|
|
|
|
|
type WangyiInfoResponse = {
|
|
|
|
|
data?: {
|
|
|
|
@@ -31,21 +36,58 @@ type WangyiUploadResponse = {
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type WangyiDraftResponse = {
|
|
|
|
|
code?: number;
|
|
|
|
|
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_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_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") {
|
|
|
|
@@ -61,8 +103,23 @@ 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 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 {
|
|
|
|
@@ -122,6 +179,45 @@ async function fetchAccount(context: PublishAdapterContext): Promise<WangyiAccou
|
|
|
|
|
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_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) {
|
|
|
|
@@ -131,19 +227,177 @@ async function getPublishToken(context: PublishAdapterContext): Promise<string>
|
|
|
|
|
await page.goto(WANGYI_HOME_URL, {
|
|
|
|
|
waitUntil: "domcontentloaded",
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// 网易号的 neg 有时是顶层绑定,不一定挂在 window 上;这里保持和旧版插件一致的读取方式。
|
|
|
|
|
const token = await readPublishTokenFromPage(page);
|
|
|
|
|
return token.trim();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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(
|
|
|
|
|
"() => Boolean(window.neg && typeof window.neg.getToken === 'function')",
|
|
|
|
|
undefined,
|
|
|
|
|
{ timeout: 12_000 },
|
|
|
|
|
({ 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(async () => {
|
|
|
|
|
const neg = (window as unknown as { neg?: { getToken?: () => Promise<{ token?: string }> } }).neg;
|
|
|
|
|
const result = await neg?.getToken?.().catch(() => null);
|
|
|
|
|
return result?.token ?? "";
|
|
|
|
|
}).catch(() => "");
|
|
|
|
|
const token = await page.evaluate((attr) => document.documentElement.getAttribute(attr) ?? "", WANGYI_TOKEN_ATTR)
|
|
|
|
|
.catch(() => "");
|
|
|
|
|
|
|
|
|
|
return token.trim();
|
|
|
|
|
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(
|
|
|
|
@@ -186,54 +440,19 @@ async function uploadImage(
|
|
|
|
|
async function saveDraft(
|
|
|
|
|
context: PublishAdapterContext,
|
|
|
|
|
account: WangyiAccount,
|
|
|
|
|
detail: WangyiPublishDetail,
|
|
|
|
|
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: "个人观点,仅供参考",
|
|
|
|
|
ursToken: token,
|
|
|
|
|
});
|
|
|
|
|
const body = buildDraftBody(context, account, detail, html, token);
|
|
|
|
|
const response =
|
|
|
|
|
await saveDraftWithPage(context, url.toString(), body).catch(() => null)
|
|
|
|
|
?? await saveDraftWithSession(context, url.toString(), body);
|
|
|
|
|
|
|
|
|
|
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) {
|
|
|
|
|
if (String(response.code) !== "1" || !response.data) {
|
|
|
|
|
const message = wangyiResponseMessage(response);
|
|
|
|
|
if (isWangyiChallengeMessage(message)) {
|
|
|
|
|
throw new Error(`wangyihao_challenge_required:${message}`);
|
|
|
|
@@ -248,12 +467,303 @@ async function saveDraft(
|
|
|
|
|
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,
|
|
|
|
|
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> {
|
|
|
|
@@ -262,29 +772,33 @@ async function publishDraft(context: PublishAdapterContext, draftId: string): Pr
|
|
|
|
|
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, {
|
|
|
|
|
const draftUrl = `https://mp.163.com/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);
|
|
|
|
|
|
|
|
|
|
await clickFirstVisibleButton(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);
|
|
|
|
|
|
|
|
|
|
const firstNavigated = await page.waitForURL((url) => url.toString() !== oldUrl, { timeout: 5_000 }).then(
|
|
|
|
|
() => true,
|
|
|
|
|
() => false,
|
|
|
|
|
);
|
|
|
|
|
if (firstNavigated) {
|
|
|
|
|
if (await waitForPublishCompletion(page, oldUrl, publishResponsePromise, 2_500)) {
|
|
|
|
|
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;
|
|
|
|
|
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> {
|
|
|
|
@@ -326,10 +840,10 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
|
|
|
|
|
if (message === "wangyihao_token_missing") {
|
|
|
|
|
return {
|
|
|
|
|
status: "failed",
|
|
|
|
|
summary: "网易号发布 token 获取失败,请重新打开网易号后台确认登录状态。",
|
|
|
|
|
summary: WANGYIHAO_TOKEN_MISSING_MESSAGE,
|
|
|
|
|
error: {
|
|
|
|
|
code: "wangyihao_token_missing",
|
|
|
|
|
message,
|
|
|
|
|
message: WANGYIHAO_TOKEN_MISSING_MESSAGE,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
@@ -346,23 +860,26 @@ function failureResult(error: unknown): ReturnType<PublishAdapter["publish"]> ex
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (message.startsWith("wangyihao_draft_save_failed") || message === "wangyihao_draft_id_missing") {
|
|
|
|
|
const isParameterError = /(?:wangyihao_error_)?100002|参数错误/.test(message);
|
|
|
|
|
return {
|
|
|
|
|
status: "failed",
|
|
|
|
|
summary: "网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。",
|
|
|
|
|
summary: isParameterError
|
|
|
|
|
? WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE
|
|
|
|
|
: "网易号草稿保存失败,请稍后重试或打开网易号后台确认内容状态。",
|
|
|
|
|
error: {
|
|
|
|
|
code: "wangyihao_draft_save_failed",
|
|
|
|
|
message,
|
|
|
|
|
message: isParameterError ? WANGYIHAO_DRAFT_SAVE_PARAMETER_MESSAGE : message,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (message === "wangyihao_publish_click_failed") {
|
|
|
|
|
if (message.startsWith("wangyihao_publish_click_failed")) {
|
|
|
|
|
return {
|
|
|
|
|
status: "failed",
|
|
|
|
|
summary: "网易号草稿已保存,但页面自动点击发布失败,请到网易号后台手动发布。",
|
|
|
|
|
summary: WANGYIHAO_PUBLISH_CLICK_MESSAGE,
|
|
|
|
|
error: {
|
|
|
|
|
code: "wangyihao_publish_click_failed",
|
|
|
|
|
message,
|
|
|
|
|
message: WANGYIHAO_PUBLISH_CLICK_MESSAGE,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
@@ -387,9 +904,10 @@ export const wangyihaoAdapter: PublishAdapter = {
|
|
|
|
|
if (!account) {
|
|
|
|
|
throw new Error("wangyihao_not_logged_in");
|
|
|
|
|
}
|
|
|
|
|
const detail = await fetchPublishDetail(context, account);
|
|
|
|
|
|
|
|
|
|
context.reportProgress("wangyihao.normalize_html");
|
|
|
|
|
let html = normalizeArticleHtml(context.article);
|
|
|
|
|
let html = prepareWangyihaoArticleHtml(context.article);
|
|
|
|
|
if (!html) {
|
|
|
|
|
throw new Error("article_content_empty");
|
|
|
|
|
}
|
|
|
|
@@ -409,7 +927,7 @@ export const wangyihaoAdapter: PublishAdapter = {
|
|
|
|
|
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);
|
|
|
|
|
const draftId = await saveDraft(context, account, detail, processed.html, token);
|
|
|
|
|
|
|
|
|
|
context.reportProgress("wangyihao.publish_draft");
|
|
|
|
|
const published = await publishDraft(context, draftId);
|
|
|
|
|