feat(publish): add bilibili and qiehao desktop adapters
Wire bilibili and qiehao (om.qq.com) into the publish adapter registry and auth-failure classifier. Bilibili bind uses WBI-signed space-info fallback to retrieve the avatar when nav lacks a face URL, and the account list image renders with a no-referrer policy so bilibili CDN serves the avatar cross-origin. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -175,6 +175,17 @@ type BilibiliNavResponse = {
|
||||
mid?: string | number;
|
||||
uname?: string;
|
||||
face?: string;
|
||||
wbi_img?: {
|
||||
img_url?: string;
|
||||
sub_url?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BilibiliSpaceInfoResponse = {
|
||||
code?: number;
|
||||
data?: {
|
||||
face?: string;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -218,6 +229,17 @@ const MAX_CONCURRENT_BIND_WINDOWS = 2;
|
||||
const TOUTIAO_LOGIN_URL = "https://mp.toutiao.com/auth/page/login";
|
||||
const TOUTIAO_CONSOLE_HOME_URL = "https://mp.toutiao.com/profile_v4/index";
|
||||
const TOUTIAO_WORKBENCH_URL_PATTERN = /^https:\/\/mp\.toutiao\.com\/profile_v4\//i;
|
||||
const BILIBILI_API_ORIGIN = "https://api.bilibili.com";
|
||||
const BILIBILI_WBI_MIXIN_KEY_ENC_TAB = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32,
|
||||
15, 50, 10, 31, 58, 3, 45, 35,
|
||||
27, 43, 5, 49, 33, 9, 42, 19,
|
||||
29, 28, 14, 39, 12, 38, 41, 13,
|
||||
37, 48, 7, 16, 24, 55, 40, 61,
|
||||
26, 17, 0, 1, 60, 51, 30, 4,
|
||||
22, 25, 54, 21, 56, 59, 6, 63,
|
||||
57, 62, 11, 36, 20, 34, 44, 52,
|
||||
];
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
@@ -276,6 +298,59 @@ function hashText(value: string): string {
|
||||
return createHash("sha1").update(value).digest("hex").slice(0, 20);
|
||||
}
|
||||
|
||||
function buildBilibiliMixinKey(imgKey: string, subKey: string): string {
|
||||
const raw = imgKey + subKey;
|
||||
return BILIBILI_WBI_MIXIN_KEY_ENC_TAB
|
||||
.map((index) => raw.charAt(index))
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function bilibiliKeyFromWbiURL(value: string | undefined): string {
|
||||
const source = value?.trim() || "";
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
const slashIndex = source.lastIndexOf("/");
|
||||
const dotIndex = source.lastIndexOf(".");
|
||||
if (slashIndex < 0 || dotIndex <= slashIndex) {
|
||||
return "";
|
||||
}
|
||||
return source.slice(slashIndex + 1, dotIndex);
|
||||
}
|
||||
|
||||
function buildBilibiliWbiQuery(
|
||||
nav: BilibiliNavResponse | null | undefined,
|
||||
params: Record<string, string | number>,
|
||||
): string | null {
|
||||
const imgKey = bilibiliKeyFromWbiURL(nav?.data?.wbi_img?.img_url);
|
||||
const subKey = bilibiliKeyFromWbiURL(nav?.data?.wbi_img?.sub_url);
|
||||
if (!imgKey || !subKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const mixinKey = buildBilibiliMixinKey(imgKey, subKey);
|
||||
const next: Record<string, string | number> = {
|
||||
...params,
|
||||
wts: Math.round(Date.now() / 1000),
|
||||
};
|
||||
const illegalChars = /[!'()*]/g;
|
||||
const query = Object.keys(next)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const value = String(next[key]).replace(illegalChars, "");
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
})
|
||||
.join("&");
|
||||
const wRid = createHash("md5").update(`${query}${mixinKey}`).digest("hex");
|
||||
return `${query}&w_rid=${wRid}`;
|
||||
}
|
||||
|
||||
function normalizeBilibiliAvatarUrl(value?: string | null): string | null {
|
||||
return normalizeRemoteUrl(value)?.replace(/^http:\/\//i, "https://") ?? null;
|
||||
}
|
||||
|
||||
function boundedIdentity(prefix: string, raw: string): string {
|
||||
const normalizedPrefix = prefix.trim().replace(/:+$/, "");
|
||||
const normalizedRaw = raw.trim();
|
||||
@@ -2242,11 +2317,56 @@ async function detectJianshu({ session }: DetectContext): Promise<DetectedAccoun
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBilibiliSpaceAvatar(
|
||||
session: Session,
|
||||
uid: string,
|
||||
nav: BilibiliNavResponse | null,
|
||||
cookie: string,
|
||||
): Promise<string | null> {
|
||||
const query = buildBilibiliWbiQuery(nav, {
|
||||
mid: uid,
|
||||
platform: "web",
|
||||
token: "",
|
||||
web_location: 1550101,
|
||||
});
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 1800);
|
||||
|
||||
try {
|
||||
const response = await sessionFetchJson<BilibiliSpaceInfoResponse>(
|
||||
session,
|
||||
`${BILIBILI_API_ORIGIN}/x/space/wbi/acc/info?${query}`,
|
||||
{
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
referer: `https://space.bilibili.com/${encodeURIComponent(uid)}`,
|
||||
...(cookie ? { cookie } : {}),
|
||||
},
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
if (response?.code !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return normalizeBilibiliAvatarUrl(response.data?.face);
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectBilibili({ session }: DetectContext): Promise<DetectedAccount | null> {
|
||||
const cookie = await sessionCookieHeader(session, "bilibili.com");
|
||||
const response = await sessionFetchJson<BilibiliNavResponse>(
|
||||
session,
|
||||
"https://api.bilibili.com/x/web-interface/nav?build=0&mobi_app=web",
|
||||
`${BILIBILI_API_ORIGIN}/x/web-interface/nav?build=0&mobi_app=web`,
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
@@ -2261,10 +2381,14 @@ async function detectBilibili({ session }: DetectContext): Promise<DetectedAccou
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarUrl =
|
||||
normalizeBilibiliAvatarUrl(user.face)
|
||||
?? await fetchBilibiliSpaceAvatar(session, uid, response, cookie);
|
||||
|
||||
return {
|
||||
platformUid: uid,
|
||||
displayName: user.uname,
|
||||
avatarUrl: normalizeRemoteUrl(user.face),
|
||||
avatarUrl,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,667 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import type { JsonValue } from "@geo/shared-types";
|
||||
import { renderTablesAsParagraphRows } from "../../../../../packages/publisher-platforms/src/baijiahao";
|
||||
|
||||
import { resolveDesktopApiURL } from "../transport/api-client";
|
||||
import {
|
||||
fetchImageBlob,
|
||||
normalizeArticleHtml,
|
||||
sessionCookieHeader,
|
||||
sessionCookieValue,
|
||||
sessionFetchJson,
|
||||
uploadHtmlImages,
|
||||
} from "./common";
|
||||
import type { PublishAdapter, PublishAdapterContext } from "./base";
|
||||
|
||||
const BILIBILI_API_ORIGIN = "https://api.bilibili.com";
|
||||
const BILIBILI_MEMBER_ORIGIN = "https://member.bilibili.com";
|
||||
const BILIBILI_EDITOR_URL = `${BILIBILI_MEMBER_ORIGIN}/read/editor/#/web?newEditor=-1`;
|
||||
const BILIBILI_MANAGE_URL = `${BILIBILI_MEMBER_ORIGIN}/platform/upload/text/manage`;
|
||||
const BILIBILI_CREATION_LIST_URL =
|
||||
`${BILIBILI_API_ORIGIN}/x/polymer/web-dynamic/v1/opus/creationlist?ps=10&pn=1&classification_type=0`;
|
||||
|
||||
type BilibiliNavResponse = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
isLogin?: boolean;
|
||||
mid?: string | number;
|
||||
uname?: string;
|
||||
face?: string;
|
||||
wbi_img?: {
|
||||
img_url?: string;
|
||||
sub_url?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type BilibiliUploadResponse = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
msg?: string;
|
||||
data?: {
|
||||
image_url?: string;
|
||||
};
|
||||
};
|
||||
|
||||
type BilibiliCreationListResponse = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: {
|
||||
items?: Array<{
|
||||
dyn_id?: string | number;
|
||||
opus_id?: string | number;
|
||||
title?: string;
|
||||
pub_time?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type BilibiliAccount = {
|
||||
uid: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type BilibiliEditorResult = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const mixinKeyEncTab = [
|
||||
46, 47, 18, 2, 53, 8, 23, 32,
|
||||
15, 50, 10, 31, 58, 3, 45, 35,
|
||||
27, 43, 5, 49, 33, 9, 42, 19,
|
||||
29, 28, 14, 39, 12, 38, 41, 13,
|
||||
37, 48, 7, 16, 24, 55, 40, 61,
|
||||
26, 17, 0, 1, 60, 51, 30, 4,
|
||||
22, 25, 54, 21, 56, 59, 6, 63,
|
||||
57, 62, 11, 36, 20, 34, 44, 52,
|
||||
];
|
||||
|
||||
function stringId(value: unknown): string {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : "";
|
||||
}
|
||||
if (typeof value !== "string") {
|
||||
return "";
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
async function bilibiliCookieHeader(context: PublishAdapterContext): Promise<string> {
|
||||
return await sessionCookieHeader(context.session, "bilibili.com").catch(() => "");
|
||||
}
|
||||
|
||||
async function bilibiliHeaders(
|
||||
context: PublishAdapterContext,
|
||||
extra: Record<string, string> = {},
|
||||
): Promise<Record<string, string>> {
|
||||
const cookie = await bilibiliCookieHeader(context);
|
||||
return {
|
||||
accept: "application/json, text/plain, */*",
|
||||
origin: BILIBILI_MEMBER_ORIGIN,
|
||||
referer: BILIBILI_MEMBER_ORIGIN,
|
||||
...(cookie ? { cookie } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchNav(context: PublishAdapterContext): Promise<BilibiliNavResponse | null> {
|
||||
return await sessionFetchJson<BilibiliNavResponse>(
|
||||
context.session,
|
||||
`${BILIBILI_API_ORIGIN}/x/web-interface/nav?build=0&mobi_app=web`,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: await bilibiliHeaders(context),
|
||||
},
|
||||
).catch(() => null);
|
||||
}
|
||||
|
||||
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`);
|
||||
}
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
function accountFromNav(response: BilibiliNavResponse | null): BilibiliAccount | null {
|
||||
const user = response?.data;
|
||||
const uid = stringId(user?.mid);
|
||||
if (response?.code !== 0 || !user?.isLogin || !uid || !user.uname) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
uid,
|
||||
name: user.uname,
|
||||
};
|
||||
}
|
||||
|
||||
function buildMixinKey(imgKey: string, subKey: string): string {
|
||||
const raw = imgKey + subKey;
|
||||
return mixinKeyEncTab
|
||||
.map((index) => raw.charAt(index))
|
||||
.filter(Boolean)
|
||||
.join("")
|
||||
.slice(0, 32);
|
||||
}
|
||||
|
||||
function keyFromWbiURL(value: string | undefined): string {
|
||||
const source = value?.trim() || "";
|
||||
if (!source) {
|
||||
return "";
|
||||
}
|
||||
const slashIndex = source.lastIndexOf("/");
|
||||
const dotIndex = source.lastIndexOf(".");
|
||||
if (slashIndex < 0 || dotIndex <= slashIndex) {
|
||||
return "";
|
||||
}
|
||||
return source.slice(slashIndex + 1, dotIndex);
|
||||
}
|
||||
|
||||
async function buildWbiQuery(context: PublishAdapterContext, params: Record<string, string | number> = {}): Promise<string> {
|
||||
const nav = await fetchNav(context);
|
||||
const imgKey = keyFromWbiURL(nav?.data?.wbi_img?.img_url);
|
||||
const subKey = keyFromWbiURL(nav?.data?.wbi_img?.sub_url);
|
||||
if (!imgKey || !subKey) {
|
||||
throw new Error("bilibili_wbi_key_missing");
|
||||
}
|
||||
|
||||
const mixinKey = buildMixinKey(imgKey, subKey);
|
||||
const next: Record<string, string | number> = {
|
||||
...params,
|
||||
wts: Math.round(Date.now() / 1000),
|
||||
};
|
||||
const illegalChars = /[!'()*]/g;
|
||||
const query = Object.keys(next)
|
||||
.sort()
|
||||
.map((key) => {
|
||||
const value = String(next[key]).replace(illegalChars, "");
|
||||
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
||||
})
|
||||
.join("&");
|
||||
const wRid = createHash("md5").update(`${query}${mixinKey}`).digest("hex");
|
||||
return `${query}&w_rid=${wRid}`;
|
||||
}
|
||||
|
||||
function buildAssetURLCandidates(sourceUrl: string): string[] {
|
||||
const trimmed = sourceUrl.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (/^(data|blob):/i.test(trimmed)) {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const pushCandidate = (value: string) => {
|
||||
if (!candidates.includes(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
|
||||
|
||||
if (parsed.pathname.startsWith("/api/")) {
|
||||
const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`));
|
||||
if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) {
|
||||
apiAssetUrl.searchParams.set("format", "png");
|
||||
}
|
||||
pushCandidate(apiAssetUrl.toString());
|
||||
}
|
||||
|
||||
pushCandidate(parsed.toString());
|
||||
} catch {
|
||||
pushCandidate(trimmed);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const blob = await fetchImageBlob(candidate);
|
||||
if (blob) {
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function uploadImage(context: PublishAdapterContext, sourceUrl: string): Promise<string | null> {
|
||||
const blob = await fetchAssetBlob(sourceUrl);
|
||||
if (!blob) {
|
||||
throw new Error("bilibili_image_fetch_failed");
|
||||
}
|
||||
|
||||
const csrf = await sessionCookieValue(context.session, "bilibili.com", "bili_jct").catch(() => "");
|
||||
if (!csrf) {
|
||||
throw new Error("bilibili_csrf_missing");
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file_up", blob, "blob");
|
||||
form.append("biz", "article");
|
||||
form.append("category", "daily");
|
||||
form.append("csrf", csrf);
|
||||
|
||||
const query = await buildWbiQuery(context);
|
||||
const response = await mainFetchJson<BilibiliUploadResponse>(
|
||||
`${BILIBILI_API_ORIGIN}/x/dynamic/feed/draw/upload_bfs?${query}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: await bilibiliHeaders(context),
|
||||
body: form,
|
||||
},
|
||||
).catch((error): BilibiliUploadResponse => ({
|
||||
code: -1,
|
||||
message: error instanceof Error ? error.message : "bilibili_image_upload_failed",
|
||||
}));
|
||||
|
||||
const imageUrl = response.data?.image_url?.trim() || "";
|
||||
if (response.code !== 0 || !imageUrl) {
|
||||
throw new Error(response.message || response.msg || "bilibili_image_upload_failed");
|
||||
}
|
||||
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
async function processContentImages(context: PublishAdapterContext, html: string): Promise<string> {
|
||||
const processed = await uploadHtmlImages(
|
||||
html,
|
||||
async (sourceUrl) => uploadImage(context, sourceUrl),
|
||||
);
|
||||
return processed.html;
|
||||
}
|
||||
|
||||
async function fillAndSubmitEditor(
|
||||
context: PublishAdapterContext,
|
||||
title: string,
|
||||
html: string,
|
||||
): Promise<BilibiliEditorResult> {
|
||||
const page = context.playwright?.page;
|
||||
if (!page) {
|
||||
return {
|
||||
success: false,
|
||||
message: "bilibili_playwright_page_missing",
|
||||
};
|
||||
}
|
||||
|
||||
await page.goto(BILIBILI_EDITOR_URL, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 45_000,
|
||||
});
|
||||
await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
|
||||
|
||||
return await page.evaluate(
|
||||
async ({ title: nextTitle, html: nextHtml }): Promise<BilibiliEditorResult> => {
|
||||
const sleep = (ms: number) => new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
const normalizeText = (value: string | null | undefined) => (value || "").trim().replace(/\s+/g, "");
|
||||
const waitForElement = async <T extends Element>(
|
||||
selector: string,
|
||||
timeoutMs: number,
|
||||
predicate?: (element: T) => boolean,
|
||||
): Promise<T | null> => {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const elements = Array.from(document.querySelectorAll(selector)) as T[];
|
||||
const found = predicate ? elements.find(predicate) : elements[0];
|
||||
if (found) {
|
||||
return found;
|
||||
}
|
||||
await sleep(150);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const inputTextareaValue = (selector: string, value: string): boolean => {
|
||||
const element = document.querySelector(selector) as HTMLTextAreaElement | HTMLInputElement | null;
|
||||
if (!element) {
|
||||
return false;
|
||||
}
|
||||
const prototype = element instanceof HTMLTextAreaElement
|
||||
? HTMLTextAreaElement.prototype
|
||||
: HTMLInputElement.prototype;
|
||||
const setter = Object.getOwnPropertyDescriptor(prototype, "value")?.set;
|
||||
if (setter) {
|
||||
setter.call(element, value);
|
||||
} else {
|
||||
element.value = value;
|
||||
}
|
||||
element.focus();
|
||||
element.dispatchEvent(new InputEvent("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: "insertText",
|
||||
data: value,
|
||||
}));
|
||||
element.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
element.blur();
|
||||
return true;
|
||||
};
|
||||
|
||||
const prepareEditorHtml = (source: string): string => {
|
||||
const parsed = new DOMParser().parseFromString(source, "text/html");
|
||||
parsed.querySelectorAll("img").forEach((image) => {
|
||||
image.setAttribute("alt", "read-normal-img");
|
||||
image.setAttribute("data-aigc", "false");
|
||||
image.setAttribute("data-status", "loaded");
|
||||
const parent = image.parentElement;
|
||||
if (parent?.tagName === "P") {
|
||||
parent.className = "normal-img";
|
||||
parent.setAttribute("contenteditable", "false");
|
||||
return;
|
||||
}
|
||||
if (parent) {
|
||||
const wrapper = parsed.createElement("p");
|
||||
wrapper.className = "normal-img";
|
||||
wrapper.setAttribute("contenteditable", "false");
|
||||
parent.replaceChild(wrapper, image);
|
||||
wrapper.appendChild(image);
|
||||
}
|
||||
});
|
||||
return parsed.body.innerHTML;
|
||||
};
|
||||
|
||||
const pasteHtmlIntoEditor = (source: string): boolean => {
|
||||
const editor = document.querySelector(".rql-editor") as (HTMLElement & { __quill?: {
|
||||
setText?: (text: string) => void;
|
||||
clipboard?: { dangerouslyPasteHTML?: (index: number, html: string) => void };
|
||||
} }) | null;
|
||||
if (editor?.__quill?.clipboard?.dangerouslyPasteHTML) {
|
||||
editor.__quill.setText?.("");
|
||||
editor.__quill.clipboard.dangerouslyPasteHTML(0, prepareEditorHtml(source));
|
||||
return true;
|
||||
}
|
||||
|
||||
const fallback = document.querySelector(".ql-editor, [contenteditable='true']") as HTMLElement | null;
|
||||
if (!fallback) {
|
||||
return false;
|
||||
}
|
||||
fallback.innerHTML = prepareEditorHtml(source);
|
||||
fallback.dispatchEvent(new InputEvent("input", {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
inputType: "insertFromPaste",
|
||||
data: source,
|
||||
}));
|
||||
return true;
|
||||
};
|
||||
|
||||
const clickButtonByText = async (texts: string[], timeoutMs: number): Promise<boolean> => {
|
||||
const button = await waitForElement<HTMLButtonElement>("button", timeoutMs, (candidate) => {
|
||||
const text = normalizeText(candidate.innerText || candidate.textContent);
|
||||
return texts.some((target) => text.includes(normalizeText(target)));
|
||||
});
|
||||
if (!button) {
|
||||
return false;
|
||||
}
|
||||
button.click();
|
||||
return true;
|
||||
};
|
||||
|
||||
const titleReady = await waitForElement<HTMLTextAreaElement>(
|
||||
".b-read-editor__title textarea, textarea",
|
||||
15_000,
|
||||
);
|
||||
if (!titleReady) {
|
||||
return {
|
||||
success: false,
|
||||
message: "bilibili_editor_not_ready",
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
const titleFilled = inputTextareaValue(".b-read-editor__title textarea", nextTitle)
|
||||
|| inputTextareaValue("textarea", nextTitle);
|
||||
if (!titleFilled) {
|
||||
return {
|
||||
success: false,
|
||||
message: "bilibili_title_input_missing",
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
const editorReady = await waitForElement<HTMLElement>(".rql-editor, .ql-editor, [contenteditable='true']", 15_000);
|
||||
if (!editorReady || !pasteHtmlIntoEditor(nextHtml)) {
|
||||
return {
|
||||
success: false,
|
||||
message: "bilibili_editor_input_missing",
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(500);
|
||||
const submitted = await clickButtonByText(["提交文章", "发布"], 8_000);
|
||||
if (!submitted) {
|
||||
return {
|
||||
success: false,
|
||||
message: "bilibili_submit_button_missing",
|
||||
url: location.href,
|
||||
};
|
||||
}
|
||||
|
||||
await sleep(3_000);
|
||||
const confirmed = await clickButtonByText(["确认提交", "确定", "确认"], 1_500);
|
||||
if (confirmed) {
|
||||
await sleep(2_000);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
url: location.href,
|
||||
};
|
||||
},
|
||||
{
|
||||
title,
|
||||
html,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function titleEquals(left: string | undefined, right: string): boolean {
|
||||
return (left || "").trim() === right.trim();
|
||||
}
|
||||
|
||||
function parseBilibiliPubTime(value: string | undefined): number | null {
|
||||
const source = value?.trim();
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const parsed = Date.parse(source.replace(/-/g, "/"));
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
async function findPublishedArticle(
|
||||
context: PublishAdapterContext,
|
||||
title: string,
|
||||
startedAt: number,
|
||||
): Promise<{ id: string; url: string } | null> {
|
||||
const response = await sessionFetchJson<BilibiliCreationListResponse>(
|
||||
context.session,
|
||||
BILIBILI_CREATION_LIST_URL,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: await bilibiliHeaders(context),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
const items = response?.data?.items ?? [];
|
||||
const matched = items.find((item) => {
|
||||
if (!titleEquals(item.title, title)) {
|
||||
return false;
|
||||
}
|
||||
const publishedAt = parseBilibiliPubTime(item.pub_time);
|
||||
return publishedAt === null || publishedAt + 60_000 >= startedAt;
|
||||
});
|
||||
|
||||
const id = stringId(matched?.opus_id ?? matched?.dyn_id);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
url: `https://www.bilibili.com/opus/${encodeURIComponent(id)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function buildResultPayload(
|
||||
articleId: string,
|
||||
mediaName: string,
|
||||
externalManageUrl: string,
|
||||
externalArticleUrl: string | null,
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
platform: "bilibili",
|
||||
media_name: mediaName,
|
||||
external_article_id: articleId,
|
||||
external_manage_url: externalManageUrl,
|
||||
external_article_url: externalArticleUrl,
|
||||
publish_type: "publish",
|
||||
};
|
||||
}
|
||||
|
||||
function buildFailureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
|
||||
if (message === "bilibili_not_logged_in") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili 登录态失效,无法执行发布。",
|
||||
error: {
|
||||
code: "bilibili_not_logged_in",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "bilibili_csrf_missing") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili CSRF Cookie 缺失,请重新绑定账号后再试。",
|
||||
error: {
|
||||
code: "bilibili_csrf_missing",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "bilibili_wbi_key_missing") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili WBI 签名参数获取失败,请重新打开 B 站后再试。",
|
||||
error: {
|
||||
code: "bilibili_wbi_key_missing",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "article_content_empty") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "文章内容为空,无法推送到 bilibili。",
|
||||
error: {
|
||||
code: "article_content_empty",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "bilibili_image_fetch_failed" || message.startsWith("bilibili_image_fetch_failed:")) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili 正文图片读取失败,请检查文章图片后重试。",
|
||||
error: {
|
||||
code: "bilibili_image_fetch_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.includes("editor_not_ready") || message.includes("input_missing") || message.includes("button_missing")) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili 专栏编辑器未就绪,请打开创作中心确认账号状态后重试。",
|
||||
error: {
|
||||
code: "bilibili_editor_unavailable",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "bilibili_publish_result_missing") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili 已执行提交,但未在作品列表中查到新文章,请手动检查创作中心。",
|
||||
error: {
|
||||
code: "bilibili_publish_result_missing",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "bilibili 发布失败。",
|
||||
error: {
|
||||
code: "bilibili_publish_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const bilibiliAdapter: PublishAdapter = {
|
||||
platform: "bilibili",
|
||||
executionMode: "playwright",
|
||||
async publish(context) {
|
||||
try {
|
||||
context.reportProgress("bilibili.detect_login");
|
||||
const account = accountFromNav(await fetchNav(context));
|
||||
if (!account) {
|
||||
throw new Error("bilibili_not_logged_in");
|
||||
}
|
||||
|
||||
const html = renderTablesAsParagraphRows(normalizeArticleHtml(context.article));
|
||||
if (!html) {
|
||||
throw new Error("article_content_empty");
|
||||
}
|
||||
|
||||
context.reportProgress("bilibili.upload_content_images");
|
||||
const processedHtml = await processContentImages(context, html);
|
||||
const title = context.article.title?.trim() || "未命名文章";
|
||||
const startedAt = Date.now();
|
||||
|
||||
context.reportProgress("bilibili.submit_editor");
|
||||
const editorResult = await fillAndSubmitEditor(context, title, processedHtml);
|
||||
if (!editorResult.success) {
|
||||
throw new Error(editorResult.message || "bilibili_publish_failed");
|
||||
}
|
||||
|
||||
context.reportProgress("bilibili.resolve_article_url");
|
||||
const article = await findPublishedArticle(context, title, startedAt);
|
||||
if (!article) {
|
||||
throw new Error("bilibili_publish_result_missing");
|
||||
}
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
payload: buildResultPayload(article.id, account.name, BILIBILI_MANAGE_URL, article.url),
|
||||
summary: "bilibili 发布成功。",
|
||||
};
|
||||
} catch (error) {
|
||||
return buildFailureResult(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -1,9 +1,11 @@
|
||||
export * from "./base";
|
||||
export * from "./baijiahao";
|
||||
export * from "./bilibili";
|
||||
export * from "./deepseek";
|
||||
export * from "./doubao";
|
||||
export * from "./jianshu";
|
||||
export * from "./kimi";
|
||||
export * from "./qiehao";
|
||||
export * from "./qwen";
|
||||
export * from "./toutiao";
|
||||
export * from "./wenxin";
|
||||
|
||||
@@ -0,0 +1,548 @@
|
||||
import type { JsonValue } from "@geo/shared-types";
|
||||
|
||||
import { resolveDesktopApiURL } from "../transport/api-client";
|
||||
import {
|
||||
fetchImageBlob,
|
||||
normalizeArticleHtml,
|
||||
sessionCookieHeader,
|
||||
sessionFetchJson,
|
||||
uploadHtmlImages,
|
||||
} from "./common";
|
||||
import type { PublishAdapter, PublishAdapterContext } from "./base";
|
||||
|
||||
const QIEHAO_APP_ID = "LA6zXi1lWzAioIzdiAD6iM10aHarlHF6";
|
||||
const QIEHAO_ORIGIN = "https://om.qq.com";
|
||||
const QIEHAO_IMAGE_ORIGIN = "https://image.om.qq.com";
|
||||
const QIEHAO_BASIC_INFO_URL = `${QIEHAO_ORIGIN}/maccountsetting/basicinfo?relogin=1`;
|
||||
const QIEHAO_MANAGE_URL = `${QIEHAO_ORIGIN}/article/manage`;
|
||||
const QIEHAO_REFERER_URL = `${QIEHAO_ORIGIN}/main.html#/article/add`;
|
||||
const QIEHAO_ARTICLE_LIST_URL =
|
||||
`${QIEHAO_ORIGIN}/marticle/article/list?category=&search=&source=&startDate=&endDate=&num=20&ftype=&readChannel=qb&dstChannel=&isPartDst=0&refreshField=&relogin=1`;
|
||||
const QIEHAO_PUBLISH_DELAY_MS = 1500;
|
||||
|
||||
type QiehaoPublishType = "publish" | "draft";
|
||||
|
||||
type QiehaoBasicInfoResponse = {
|
||||
data?: {
|
||||
cpInfo?: {
|
||||
mediaId?: string | number;
|
||||
mediaName?: string;
|
||||
header?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
type QiehaoCpInfo = NonNullable<QiehaoBasicInfoResponse["data"]>["cpInfo"];
|
||||
|
||||
type QiehaoPublishResponse = {
|
||||
code?: number;
|
||||
response?: {
|
||||
code?: number;
|
||||
msg?: string;
|
||||
};
|
||||
data?: {
|
||||
articleId?: string | number;
|
||||
};
|
||||
msg?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type QiehaoArticleListResponse = {
|
||||
data?: {
|
||||
articles?: Array<{
|
||||
article_id?: string | number;
|
||||
articleId?: string | number;
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type QiehaoUploadStepOneResponse = {
|
||||
data?: {
|
||||
url?: {
|
||||
size?: Record<string, { imageUrl?: string }>;
|
||||
};
|
||||
};
|
||||
msg?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type QiehaoUploadStepTwoResponse = {
|
||||
data?: {
|
||||
url?: string;
|
||||
};
|
||||
msg?: string;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
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>): QiehaoPublishType {
|
||||
return payload.publish_type === "draft" || payload.publishType === "draft" ? "draft" : "publish";
|
||||
}
|
||||
|
||||
function qiehaoResponseMessage(response: {
|
||||
msg?: string;
|
||||
message?: string;
|
||||
response?: { msg?: string };
|
||||
} | null | undefined): string {
|
||||
return response?.msg || response?.message || response?.response?.msg || "qiehao_publish_failed";
|
||||
}
|
||||
|
||||
function isQiehaoChallengeMessage(message: string): boolean {
|
||||
return /(验证码|滑块|人机验证|安全验证|请完成验证|短信验证|扫码验证|captcha|challenge|verification required|风险|风控)/i
|
||||
.test(message);
|
||||
}
|
||||
|
||||
async function waitForHumanPace(signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
throw new Error("adapter_aborted");
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const cleanup = () => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
};
|
||||
const timeout = setTimeout(() => {
|
||||
cleanup();
|
||||
resolve();
|
||||
}, QIEHAO_PUBLISH_DELAY_MS);
|
||||
const onAbort = () => {
|
||||
clearTimeout(timeout);
|
||||
cleanup();
|
||||
reject(new Error("adapter_aborted"));
|
||||
};
|
||||
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function qiehaoCookieHeader(context: PublishAdapterContext, domain = "om.qq.com"): Promise<string> {
|
||||
const cookie = await sessionCookieHeader(context.session, domain).catch(() => "");
|
||||
if (cookie || domain === "om.qq.com") {
|
||||
return cookie;
|
||||
}
|
||||
return await sessionCookieHeader(context.session, "om.qq.com").catch(() => "");
|
||||
}
|
||||
|
||||
async function qiehaoHeaders(
|
||||
context: PublishAdapterContext,
|
||||
origin: string,
|
||||
extra: Record<string, string> = {},
|
||||
cookieDomain?: string,
|
||||
): Promise<Record<string, string>> {
|
||||
const cookie = await qiehaoCookieHeader(context, cookieDomain ?? new URL(origin).hostname);
|
||||
return {
|
||||
accept: "application/json, text/plain, */*",
|
||||
origin,
|
||||
referer: QIEHAO_REFERER_URL,
|
||||
...(cookie ? { cookie } : {}),
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchBasicInfo(context: PublishAdapterContext): Promise<QiehaoCpInfo | null> {
|
||||
const response = await sessionFetchJson<QiehaoBasicInfoResponse>(
|
||||
context.session,
|
||||
QIEHAO_BASIC_INFO_URL,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
return response?.data?.cpInfo ?? null;
|
||||
}
|
||||
|
||||
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(input, init);
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `request_failed_${response.status}`);
|
||||
}
|
||||
if (!text) {
|
||||
return {} as T;
|
||||
}
|
||||
return JSON.parse(text) as T;
|
||||
}
|
||||
|
||||
function buildAssetURLCandidates(sourceUrl: string): string[] {
|
||||
const trimmed = sourceUrl.trim();
|
||||
if (!trimmed) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (/^(data|blob):/i.test(trimmed)) {
|
||||
return [trimmed];
|
||||
}
|
||||
|
||||
const candidates: string[] = [];
|
||||
const pushCandidate = (value: string) => {
|
||||
if (!candidates.includes(value)) {
|
||||
candidates.push(value);
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
|
||||
|
||||
if (parsed.pathname.startsWith("/api/")) {
|
||||
const apiAssetUrl = new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`));
|
||||
if (apiAssetUrl.pathname.startsWith("/api/public/assets/")) {
|
||||
apiAssetUrl.searchParams.set("format", "png");
|
||||
}
|
||||
pushCandidate(apiAssetUrl.toString());
|
||||
}
|
||||
|
||||
pushCandidate(parsed.toString());
|
||||
} catch {
|
||||
pushCandidate(trimmed);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
|
||||
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
|
||||
const blob = await fetchImageBlob(candidate);
|
||||
if (blob) {
|
||||
return blob;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function uploadImage(
|
||||
context: PublishAdapterContext,
|
||||
sourceUrl: string,
|
||||
includeCoverMeta: boolean,
|
||||
): Promise<string | null> {
|
||||
const blob = await fetchAssetBlob(sourceUrl);
|
||||
if (!blob) {
|
||||
throw new Error(includeCoverMeta ? "qiehao_cover_fetch_failed" : "qiehao_image_fetch_failed");
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", blob, "image.png");
|
||||
form.append("appid", QIEHAO_APP_ID);
|
||||
form.append("isUpOrg", "1");
|
||||
form.append("endpoint", "1");
|
||||
form.append("isRetImgAttr", "1");
|
||||
form.append("opCode", "151");
|
||||
|
||||
const first = await mainFetchJson<QiehaoUploadStepOneResponse>(
|
||||
`${QIEHAO_IMAGE_ORIGIN}/cpom_pimage/ArchacaleUploadViaFile`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {}, "image.om.qq.com"),
|
||||
body: form,
|
||||
},
|
||||
).catch((error): QiehaoUploadStepOneResponse => ({
|
||||
msg: error instanceof Error ? error.message : "qiehao_image_upload_failed",
|
||||
}));
|
||||
|
||||
const imageUrl = first?.data?.url?.size?.["641"]?.imageUrl ?? null;
|
||||
if (!imageUrl) {
|
||||
const message = qiehaoResponseMessage(first);
|
||||
if (isQiehaoChallengeMessage(message)) {
|
||||
throw new Error(`qiehao_challenge_required:${message}`);
|
||||
}
|
||||
throw new Error(`qiehao_image_upload_failed:${message}`);
|
||||
}
|
||||
|
||||
if (!includeCoverMeta) {
|
||||
return imageUrl;
|
||||
}
|
||||
|
||||
const second = await mainFetchJson<QiehaoUploadStepTwoResponse>(
|
||||
`${QIEHAO_IMAGE_ORIGIN}/cpom_pimage/ListImageUploadViaUrl`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: await qiehaoHeaders(
|
||||
context,
|
||||
QIEHAO_ORIGIN,
|
||||
{
|
||||
"content-type": "application/json",
|
||||
},
|
||||
"image.om.qq.com",
|
||||
),
|
||||
body: JSON.stringify({
|
||||
auth: {
|
||||
appid: QIEHAO_APP_ID,
|
||||
endpoint: 1,
|
||||
},
|
||||
reqData: {
|
||||
appid: QIEHAO_APP_ID,
|
||||
isUpOrg: 1,
|
||||
endpoint: 1,
|
||||
isRetImgAttr: 1,
|
||||
opCode: 151,
|
||||
imageUrl,
|
||||
},
|
||||
relogin: 1,
|
||||
}),
|
||||
},
|
||||
).catch((error): QiehaoUploadStepTwoResponse => ({
|
||||
msg: error instanceof Error ? error.message : "qiehao_cover_upload_failed",
|
||||
}));
|
||||
|
||||
return second?.data?.url ?? imageUrl;
|
||||
}
|
||||
|
||||
function buildResourceMarkInfo(uploadedImageUrls: string[]): Record<string, Record<string, unknown>> {
|
||||
return uploadedImageUrls.reduce<Record<string, Record<string, unknown>>>((result, imageUrl) => {
|
||||
result[imageUrl] = {
|
||||
image_url: imageUrl,
|
||||
id: imageUrl,
|
||||
resource_type: "image",
|
||||
can_modify: 1,
|
||||
aigc_user_statement: 2,
|
||||
};
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
async function findArticleUrl(
|
||||
context: PublishAdapterContext,
|
||||
articleId: string,
|
||||
): Promise<string | null> {
|
||||
const list = await sessionFetchJson<QiehaoArticleListResponse>(
|
||||
context.session,
|
||||
QIEHAO_ARTICLE_LIST_URL,
|
||||
{
|
||||
credentials: "include",
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
const article = list?.data?.articles?.find((item) =>
|
||||
String(item.article_id ?? item.articleId ?? "") === articleId,
|
||||
);
|
||||
return article?.url?.trim() || null;
|
||||
}
|
||||
|
||||
function externalArticleIdFromUrl(articleId: string, articleUrl: string | null): string {
|
||||
if (!articleUrl) {
|
||||
return articleId;
|
||||
}
|
||||
return articleUrl.split("/").filter(Boolean).pop() || articleId;
|
||||
}
|
||||
|
||||
function buildResultPayload(
|
||||
publishType: QiehaoPublishType,
|
||||
articleId: string,
|
||||
mediaName: string,
|
||||
externalManageUrl: string,
|
||||
externalArticleUrl: string | null,
|
||||
): Record<string, JsonValue> {
|
||||
return {
|
||||
platform: "qiehao",
|
||||
media_name: mediaName,
|
||||
external_article_id: articleId,
|
||||
external_manage_url: externalManageUrl,
|
||||
external_article_url: externalArticleUrl,
|
||||
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 === "qiehao_not_logged_in") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号登录态失效,无法执行发布。",
|
||||
error: {
|
||||
code: "qiehao_not_logged_in",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "qiehao_cookie_missing") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号 Cookie 缺失,请重新绑定账号后再试。",
|
||||
error: {
|
||||
code: "qiehao_cookie_missing",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "article_content_empty") {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "文章内容为空,无法推送到企鹅号。",
|
||||
error: {
|
||||
code: "article_content_empty",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "qiehao_cover_fetch_failed" || message.startsWith("qiehao_cover_fetch_failed:")) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号封面文件读取失败,请重新上传封面图后重试。",
|
||||
error: {
|
||||
code: "qiehao_cover_fetch_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message === "qiehao_image_fetch_failed" || message.startsWith("qiehao_image_fetch_failed:")) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号正文图片读取失败,请检查文章图片后重试。",
|
||||
error: {
|
||||
code: "qiehao_image_fetch_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.startsWith("qiehao_image_upload_failed")) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号图片上传失败,请稍后重试或更换图片。",
|
||||
error: {
|
||||
code: "qiehao_image_upload_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (message.startsWith("qiehao_challenge_required") || isQiehaoChallengeMessage(message)) {
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号触发平台验证,请在企鹅号后台完成验证后重试。",
|
||||
error: {
|
||||
code: "qiehao_challenge_required",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: "failed",
|
||||
summary: "企鹅号发布失败。",
|
||||
error: {
|
||||
code: "qiehao_publish_failed",
|
||||
message,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const qiehaoAdapter: PublishAdapter = {
|
||||
platform: "qiehao",
|
||||
executionMode: "session",
|
||||
async publish(context, payload) {
|
||||
try {
|
||||
const publishType = resolvePublishType(payload);
|
||||
|
||||
context.reportProgress("qiehao.detect_login");
|
||||
const cpInfo = await fetchBasicInfo(context);
|
||||
const mediaId = stringId(cpInfo?.mediaId);
|
||||
if (!mediaId) {
|
||||
throw new Error("qiehao_not_logged_in");
|
||||
}
|
||||
|
||||
const cookie = await qiehaoCookieHeader(context);
|
||||
if (!cookie) {
|
||||
throw new Error("qiehao_cookie_missing");
|
||||
}
|
||||
|
||||
const html = normalizeArticleHtml(context.article);
|
||||
if (!html) {
|
||||
throw new Error("article_content_empty");
|
||||
}
|
||||
|
||||
context.reportProgress("qiehao.upload_content_images");
|
||||
const processed = await uploadHtmlImages(
|
||||
html,
|
||||
async (sourceUrl) => uploadImage(context, sourceUrl, false),
|
||||
);
|
||||
|
||||
const coverImages: string[] = [];
|
||||
const coverAssetUrl = context.article.cover_asset_url?.trim() || "";
|
||||
if (coverAssetUrl) {
|
||||
context.reportProgress("qiehao.upload_cover");
|
||||
const coverUrl = await uploadImage(context, coverAssetUrl, true);
|
||||
if (coverUrl) {
|
||||
coverImages.push(coverUrl);
|
||||
}
|
||||
}
|
||||
|
||||
const uploadedImageList = Array.from(processed.uploaded.values());
|
||||
const resourceMarkInfo = buildResourceMarkInfo(uploadedImageList);
|
||||
const endpoint =
|
||||
publishType === "publish"
|
||||
? `${QIEHAO_ORIGIN}/marticlepublish/omPublish`
|
||||
: `${QIEHAO_ORIGIN}/marticlepublish/omSave`;
|
||||
|
||||
context.reportProgress("qiehao.submit");
|
||||
const response = await sessionFetchJson<QiehaoPublishResponse>(
|
||||
context.session,
|
||||
endpoint,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {
|
||||
"content-type": "application/json",
|
||||
}),
|
||||
body: JSON.stringify({
|
||||
title: context.article.title?.trim() || "未命名文章",
|
||||
content: processed.html,
|
||||
imgurl_ext: JSON.stringify(coverImages),
|
||||
cover_type: "1",
|
||||
imgurlsrc: "custom",
|
||||
orignal: 0,
|
||||
user_original: 0,
|
||||
mediaId,
|
||||
needpub: 1,
|
||||
type: 0,
|
||||
relogin: 1,
|
||||
resource_aigc_mark_info: JSON.stringify(resourceMarkInfo),
|
||||
}),
|
||||
},
|
||||
).catch((requestError): QiehaoPublishResponse => ({
|
||||
code: -1,
|
||||
msg: requestError instanceof Error ? requestError.message : "qiehao_publish_failed",
|
||||
}));
|
||||
|
||||
const articleId = response.data?.articleId != null ? String(response.data.articleId) : "";
|
||||
const responseCode = response.response?.code ?? response.code;
|
||||
if (responseCode !== 0 || !articleId) {
|
||||
const message = qiehaoResponseMessage(response);
|
||||
if (isQiehaoChallengeMessage(message)) {
|
||||
throw new Error(`qiehao_challenge_required:${message}`);
|
||||
}
|
||||
throw new Error(message || "qiehao_publish_failed");
|
||||
}
|
||||
|
||||
context.reportProgress("qiehao.resolve_article_url");
|
||||
await waitForHumanPace(context.signal);
|
||||
const articleUrl = await findArticleUrl(context, articleId);
|
||||
const externalArticleId = externalArticleIdFromUrl(articleId, articleUrl);
|
||||
const mediaName = cpInfo?.mediaName?.trim() || "企鹅号账号";
|
||||
|
||||
return {
|
||||
status: "succeeded",
|
||||
payload: buildResultPayload(publishType, externalArticleId, mediaName, QIEHAO_MANAGE_URL, articleUrl),
|
||||
summary: publishType === "draft" ? "企鹅号草稿保存成功。" : "企鹅号发布成功。",
|
||||
};
|
||||
} catch (error) {
|
||||
return failureResult(error);
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -21,4 +21,28 @@ describe("platform auth adapters", () => {
|
||||
}),
|
||||
).toBe("risk_control");
|
||||
});
|
||||
|
||||
it("classifies Qiehao missing cookie as auth failure", () => {
|
||||
const adapter = getPlatformAdapter("qiehao");
|
||||
|
||||
expect(
|
||||
adapter.classifyFailure({
|
||||
error: {
|
||||
code: "qiehao_cookie_missing",
|
||||
},
|
||||
}),
|
||||
).toBe("auth_failure");
|
||||
});
|
||||
|
||||
it("classifies Bilibili missing csrf as auth failure", () => {
|
||||
const adapter = getPlatformAdapter("bilibili");
|
||||
|
||||
expect(
|
||||
adapter.classifyFailure({
|
||||
error: {
|
||||
code: "bilibili_csrf_missing",
|
||||
},
|
||||
}),
|
||||
).toBe("auth_failure");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -115,6 +115,31 @@ function classifyBaijiahaoFailure(input: PlatformFailureInput): PlatformFailureC
|
||||
return classifyFailure(input);
|
||||
}
|
||||
|
||||
function classifyQiehaoFailure(input: PlatformFailureInput): PlatformFailureClassification {
|
||||
const code = typeof input.error?.code === "string" ? input.error.code : "";
|
||||
if (code === "qiehao_challenge_required") {
|
||||
return "challenge";
|
||||
}
|
||||
if (code === "qiehao_not_logged_in" || code === "qiehao_cookie_missing") {
|
||||
return "auth_failure";
|
||||
}
|
||||
if (code.startsWith("qiehao_")) {
|
||||
return "ok";
|
||||
}
|
||||
return classifyFailure(input);
|
||||
}
|
||||
|
||||
function classifyBilibiliFailure(input: PlatformFailureInput): PlatformFailureClassification {
|
||||
const code = typeof input.error?.code === "string" ? input.error.code : "";
|
||||
if (code === "bilibili_not_logged_in" || code === "bilibili_csrf_missing") {
|
||||
return "auth_failure";
|
||||
}
|
||||
if (code.startsWith("bilibili_")) {
|
||||
return "ok";
|
||||
}
|
||||
return classifyFailure(input);
|
||||
}
|
||||
|
||||
const genericAdapter: PlatformAdapter = {
|
||||
async probe(account, partition) {
|
||||
return await probePublishAccountSession(account, partition);
|
||||
@@ -155,6 +180,26 @@ const baijiahaoAdapter: PlatformAdapter = {
|
||||
classifyFailure: classifyBaijiahaoFailure,
|
||||
};
|
||||
|
||||
const qiehaoAdapter: PlatformAdapter = {
|
||||
async probe(account, partition) {
|
||||
return await probePublishAccountSession(account, partition);
|
||||
},
|
||||
async silentRefresh(account, partition) {
|
||||
return await silentRefreshPublishAccountSession(account, partition);
|
||||
},
|
||||
classifyFailure: classifyQiehaoFailure,
|
||||
};
|
||||
|
||||
const bilibiliAdapter: PlatformAdapter = {
|
||||
async probe(account, partition) {
|
||||
return await probePublishAccountSession(account, partition);
|
||||
},
|
||||
async silentRefresh(account, partition) {
|
||||
return await silentRefreshPublishAccountSession(account, partition);
|
||||
},
|
||||
classifyFailure: classifyBilibiliFailure,
|
||||
};
|
||||
|
||||
const adapterRegistry = new Map<string, PlatformAdapter>(
|
||||
[
|
||||
...aiPlatformCatalog.map((platform) => platform.id),
|
||||
@@ -179,7 +224,11 @@ const adapterRegistry = new Map<string, PlatformAdapter>(
|
||||
? wenxinAdapter
|
||||
: platform === "baijiahao"
|
||||
? baijiahaoAdapter
|
||||
: genericAdapter,
|
||||
: platform === "qiehao"
|
||||
? qiehaoAdapter
|
||||
: platform === "bilibili"
|
||||
? bilibiliAdapter
|
||||
: genericAdapter,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -18,8 +18,10 @@ import { getAIPlatformCatalogItem, isAIPlatformId } from "@geo/shared-types";
|
||||
|
||||
import {
|
||||
baijiahaoAdapter,
|
||||
bilibiliAdapter,
|
||||
getMonitorAdapter,
|
||||
jianshuAdapter,
|
||||
qiehaoAdapter,
|
||||
toutiaoAdapter,
|
||||
zhihuAdapter,
|
||||
type AdapterExecutionResult,
|
||||
@@ -2868,6 +2870,12 @@ function selectPublishAdapter(platform: string): PublishAdapter | null {
|
||||
if (platform === jianshuAdapter.platform) {
|
||||
return jianshuAdapter;
|
||||
}
|
||||
if (platform === qiehaoAdapter.platform) {
|
||||
return qiehaoAdapter;
|
||||
}
|
||||
if (platform === bilibiliAdapter.platform) {
|
||||
return bilibiliAdapter;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ const tableColumns = [
|
||||
<div class="cell-primary-secondary">
|
||||
<div class="user-avatar-wrap">
|
||||
<span class="avatar-circle" :style="{ background: platformMeta(record.platform).accent }">
|
||||
<img v-if="record.avatarUrl" :src="record.avatarUrl" :alt="record.displayName" />
|
||||
<img v-if="record.avatarUrl" :src="record.avatarUrl" :alt="record.displayName" referrerpolicy="no-referrer" />
|
||||
<span v-else class="avatar-initial">{{ accountInitial(record) }}</span>
|
||||
</span>
|
||||
<div class="info-stack">
|
||||
|
||||
+3
-1
@@ -23,4 +23,6 @@
|
||||
头条号:toutiaohao.ts
|
||||
知乎: zhihu.ts
|
||||
百家号:baijiao.ts (表格官方渲染成了图片,不可以的,我们需要把表格降级处理)
|
||||
简书:jianshu.ts (图片未测,文字,表格 ok)
|
||||
简书:jianshu.ts (图片未测,文字,表格 ok)
|
||||
企鹅号:媒体资质未通过,另外要测
|
||||
哔哩哔哩:平台表格就不支持;降级了
|
||||
|
||||
Reference in New Issue
Block a user