diff --git a/apps/desktop-client/src/main/account-binder.ts b/apps/desktop-client/src/main/account-binder.ts index c6ea239..8bf0a8f 100644 --- a/apps/desktop-client/src/main/account-binder.ts +++ b/apps/desktop-client/src/main/account-binder.ts @@ -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 { 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 | 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 = { + ...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 { + 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( + 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 { const cookie = await sessionCookieHeader(session, "bilibili.com"); const response = await sessionFetchJson( 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; + }; +}; + +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 { + return await sessionCookieHeader(context.session, "bilibili.com").catch(() => ""); +} + +async function bilibiliHeaders( + context: PublishAdapterContext, + extra: Record = {}, +): Promise> { + 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 { + return await sessionFetchJson( + 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(input: string, init?: RequestInit): Promise { + 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 = {}): Promise { + 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 = { + ...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 { + 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 { + 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( + `${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 { + const processed = await uploadHtmlImages( + html, + async (sourceUrl) => uploadImage(context, sourceUrl), + ); + return processed.html; +} + +async function fillAndSubmitEditor( + context: PublishAdapterContext, + title: string, + html: string, +): Promise { + 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 => { + const sleep = (ms: number) => new Promise((resolve) => { + setTimeout(resolve, ms); + }); + const normalizeText = (value: string | null | undefined) => (value || "").trim().replace(/\s+/g, ""); + const waitForElement = async ( + selector: string, + timeoutMs: number, + predicate?: (element: T) => boolean, + ): Promise => { + 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 => { + const button = await waitForElement("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( + ".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(".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( + 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 { + 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 extends Promise ? 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/adapters/index.ts b/apps/desktop-client/src/main/adapters/index.ts index 5b3db00..87e0729 100644 --- a/apps/desktop-client/src/main/adapters/index.ts +++ b/apps/desktop-client/src/main/adapters/index.ts @@ -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"; diff --git a/apps/desktop-client/src/main/adapters/qiehao.ts b/apps/desktop-client/src/main/adapters/qiehao.ts new file mode 100644 index 0000000..f5f1e5c --- /dev/null +++ b/apps/desktop-client/src/main/adapters/qiehao.ts @@ -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["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; + }; + }; + 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): 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 { + 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 { + 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 = {}, + cookieDomain?: string, +): Promise> { + 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 { + const response = await sessionFetchJson( + context.session, + QIEHAO_BASIC_INFO_URL, + { + credentials: "include", + headers: await qiehaoHeaders(context, QIEHAO_ORIGIN), + }, + ).catch(() => null); + + return response?.data?.cpInfo ?? null; +} + +async function mainFetchJson(input: string, init?: RequestInit): Promise { + 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 { + 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 { + 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( + `${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( + `${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> { + return uploadedImageUrls.reduce>>((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 { + const list = await sessionFetchJson( + 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 { + 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 extends Promise ? 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( + 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); + } + }, +}; diff --git a/apps/desktop-client/src/main/platform-auth-adapters.test.ts b/apps/desktop-client/src/main/platform-auth-adapters.test.ts index 09761b8..fc93820 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.test.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.test.ts @@ -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"); + }); }); diff --git a/apps/desktop-client/src/main/platform-auth-adapters.ts b/apps/desktop-client/src/main/platform-auth-adapters.ts index c5f06d5..118160f 100644 --- a/apps/desktop-client/src/main/platform-auth-adapters.ts +++ b/apps/desktop-client/src/main/platform-auth-adapters.ts @@ -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( [ ...aiPlatformCatalog.map((platform) => platform.id), @@ -179,7 +224,11 @@ const adapterRegistry = new Map( ? wenxinAdapter : platform === "baijiahao" ? baijiahaoAdapter - : genericAdapter, + : platform === "qiehao" + ? qiehaoAdapter + : platform === "bilibili" + ? bilibiliAdapter + : genericAdapter, ]), ); diff --git a/apps/desktop-client/src/main/runtime-controller.ts b/apps/desktop-client/src/main/runtime-controller.ts index 0c27051..57e6e0b 100644 --- a/apps/desktop-client/src/main/runtime-controller.ts +++ b/apps/desktop-client/src/main/runtime-controller.ts @@ -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; } diff --git a/apps/desktop-client/src/renderer/views/AccountsView.vue b/apps/desktop-client/src/renderer/views/AccountsView.vue index 79f7938..c0dec50 100644 --- a/apps/desktop-client/src/renderer/views/AccountsView.vue +++ b/apps/desktop-client/src/renderer/views/AccountsView.vue @@ -369,7 +369,7 @@ const tableColumns = [
- + {{ accountInitial(record) }}
diff --git a/媒体发布手工自测.md b/媒体发布手工自测.md index ae3ac7d..2d4f8a3 100644 --- a/媒体发布手工自测.md +++ b/媒体发布手工自测.md @@ -23,4 +23,6 @@ 头条号:toutiaohao.ts 知乎: zhihu.ts 百家号:baijiao.ts (表格官方渲染成了图片,不可以的,我们需要把表格降级处理) -简书:jianshu.ts (图片未测,文字,表格 ok) \ No newline at end of file +简书:jianshu.ts (图片未测,文字,表格 ok) +企鹅号:媒体资质未通过,另外要测 +哔哩哔哩:平台表格就不支持;降级了