fix(publish): prevent stuck publish queue and duplicate posting
Desktop Client Build / Resolve Build Metadata (push) Successful in 41s
Backend CI / Backend (push) Failing after 7m14s
Desktop Client Build / Build Desktop Client (push) Successful in 23m46s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 56s

The desktop publish queue could stall for a long time and end users assumed
the software was broken. Root cause: a hung adapter held its execution slot
with no wall-clock timeout while auto-renewing its lease forever, so the
server never reclaimed it and every queued task behind it stayed 等待发布.
Auto-recovery also risked silently re-posting a non-idempotent article.

Client (Electron):
- per-task wall-clock deadline + abort; progress-gated lease renewal that
  stops and aborts a stalled task instead of renewing it forever
- decouple the concurrency cap from CDP-induced CPU/memory pressure
  (admission gate instead of self-throttling collapse); 15s watchdog pump
- all adapter network I/O now has fetch timeouts and honors context.signal;
  bounded image-upload concurrency with per-image timeout
- surface live adapter progress, elapsed time, queue position and a
  working-vs-queued distinction in the publish view

Server (tenant-api):
- publish lease-recovery worker (every 3m) + supporting index: reclaim
  expired in_progress publish leases, requeue (<3 attempts) or terminal-fail
- 3-minute lease TTL with client-presence-gated extension; max 3 attempts

Idempotency (production-grade core):
- durable publish_submit_started_at marker, set before the irreversible
  platform submit POST; recovery and abort route a maybe-submitted task to
  unknown (manual reconcile, kept in the dedup set) instead of re-posting
- desktop UI requires explicit confirmation before retrying a possibly-
  already-published task

Verified: go build/vet/test (incl. resolvePublishRecoveryOutcome), vue-tsc,
vitest 141/141, gofmt all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 19:52:17 +08:00
parent 9d6181260a
commit fa51a3455f
41 changed files with 2124 additions and 358 deletions
@@ -11,6 +11,7 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
normalizeArticleHtml,
sessionFetchJson,
sessionFetchText,
@@ -149,19 +150,25 @@ async function fetchAppInfo(context: PublishAdapterContext): Promise<BaijiahaoUs
accept: 'application/json, text/plain, */*',
}),
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.user ?? null
}
async function getPublishToken(context: PublishAdapterContext): Promise<string> {
const html = await sessionFetchText(context.session, BAIJIAHAO_EDIT_URL, {
credentials: 'include',
headers: baijiahaoHeaders({
accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
}),
})
const html = await sessionFetchText(
context.session,
BAIJIAHAO_EDIT_URL,
{
credentials: 'include',
headers: baijiahaoHeaders({
accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
}),
},
{ signal: context.signal },
)
return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? ''
}
@@ -172,7 +179,7 @@ async function uploadImage(
appId: string,
cropCover: boolean,
): Promise<string | null> {
const image = await fetchBaijiahaoImageBlob(sourceUrl)
const image = await fetchBaijiahaoImageBlob(sourceUrl, context.signal)
if (!image) {
throw new Error(cropCover ? 'baijiahao_cover_fetch_failed' : 'baijiahao_image_fetch_failed')
}
@@ -196,6 +203,7 @@ async function uploadImage(
headers: baijiahaoHeaders(),
body: form,
},
{ signal: context.signal },
).catch((error) => {
throw new Error(
error instanceof Error
@@ -232,6 +240,7 @@ async function uploadImage(
cutting_type: 'cover_image',
}),
},
{ signal: context.signal },
).catch((error) => {
throw new Error(
error instanceof Error
@@ -279,9 +288,14 @@ function buildImageURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchBaijiahaoImageBlob(sourceUrl: string): Promise<BaijiahaoImageBlob | null> {
async function fetchBaijiahaoImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<BaijiahaoImageBlob | null> {
for (const candidate of buildImageURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null)
const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch(
() => null,
)
if (!response?.ok) {
continue
}
@@ -332,7 +346,7 @@ async function processContentImages(
const processed = await uploadHtmlImages(html, async (sourceUrl) => {
await waitForHumanPace(context.signal)
return await uploadImage(context, sourceUrl, appId, false)
})
}, { signal: context.signal })
return processed.html
}
@@ -570,6 +584,7 @@ export const baijiahaoAdapter: PublishAdapter = {
activity_list: buildActivityList(),
}),
},
{ signal: context.signal },
).catch(
(requestError): BaijiahaoPublishResponse => ({
errno: -1,
@@ -7,6 +7,7 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
extractImageSources,
fetchImageBlob,
normalizeArticleHtml,
@@ -117,11 +118,16 @@ async function fetchNav(context: PublishAdapterContext): Promise<BilibiliNavResp
credentials: 'include',
headers: await bilibiliHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init)
async function mainFetchJson<T>(
input: string,
init?: RequestInit,
options: { signal?: AbortSignal } = {},
): Promise<T> {
const response = await adapterFetch(input, init, { signal: options.signal })
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
@@ -230,9 +236,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -244,7 +250,7 @@ async function uploadImage(
context: PublishAdapterContext,
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl)
const blob = await fetchAssetBlob(sourceUrl, context.signal)
if (!blob) {
throw new Error('bilibili_image_fetch_failed')
}
@@ -268,6 +274,7 @@ async function uploadImage(
headers: await bilibiliHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): BilibiliUploadResponse => ({
code: -1,
@@ -284,8 +291,10 @@ async function uploadImage(
}
async function processContentImages(context: PublishAdapterContext, html: string): Promise<string> {
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, sourceUrl),
{ signal: context.signal },
)
return processed.html
}
@@ -547,6 +556,7 @@ async function findPublishedArticle(
credentials: 'include',
headers: await bilibiliHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const items = response?.data?.items ?? []
+231 -9
View File
@@ -4,6 +4,26 @@ import { marked } from 'marked'
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from '../user-agent'
const defaultFetchTimeoutMs = 15_000
const defaultImageFetchTimeoutMs = 10_000
const defaultHtmlImageUploadTimeoutMs = 30_000
const defaultHtmlImageUploadConcurrency = 3
export interface AdapterFetchOptions {
signal?: AbortSignal
timeoutMs?: number
}
export interface UploadHtmlImagesOptions extends AdapterFetchOptions {
imageTimeoutMs?: number
concurrency?: number
}
export interface AbortablePromiseOptions extends AdapterFetchOptions {
abortErrorMessage?: string
timeoutErrorMessage?: string
}
export function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') {
return null
@@ -76,13 +96,133 @@ export function extractImageSources(html: string): string[] {
return [...sources]
}
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
export function mergeAbortSignals(
signals: Array<AbortSignal | null | undefined>,
): AbortSignal | undefined {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal))
if (!activeSignals.length) {
return undefined
}
const anyAbortSignal = (AbortSignal as typeof AbortSignal & {
any?: (signals: AbortSignal[]) => AbortSignal
}).any
if (typeof anyAbortSignal === 'function') {
return anyAbortSignal(activeSignals)
}
const controller = new AbortController()
const abort = (signal: AbortSignal) => {
if (!controller.signal.aborted) {
controller.abort(signal.reason)
}
}
for (const signal of activeSignals) {
if (signal.aborted) {
abort(signal)
break
}
signal.addEventListener('abort', () => abort(signal), { once: true })
}
return controller.signal
}
export function timeoutSignal(timeoutMs = defaultFetchTimeoutMs): AbortSignal {
const timeoutAbortSignal = (AbortSignal as typeof AbortSignal & {
timeout?: (timeoutMs: number) => AbortSignal
}).timeout
if (typeof timeoutAbortSignal === 'function') {
return timeoutAbortSignal(timeoutMs)
}
const controller = new AbortController()
setTimeout(() => controller.abort(new Error('adapter_fetch_timeout')), timeoutMs)
return controller.signal
}
export function buildAdapterAbortSignal(options: AdapterFetchOptions = {}): AbortSignal {
return mergeAbortSignals([
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]) as AbortSignal
}
export function withRequestTimeout(init: RequestInit = {}, options: AdapterFetchOptions = {}): RequestInit {
return {
...init,
signal: mergeAbortSignals([
init.signal,
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]),
}
}
export async function adapterFetch(
input: string | URL | Request,
init: RequestInit = {},
options: AdapterFetchOptions = {},
): Promise<Response> {
return fetch(input, withRequestTimeout(init, options))
}
export async function raceWithAbort<T>(
promise: Promise<T>,
options: AbortablePromiseOptions = {},
): Promise<T> {
const timeoutMs = options.timeoutMs ?? defaultFetchTimeoutMs
const abortErrorMessage = options.abortErrorMessage ?? 'adapter_aborted'
const timeoutErrorMessage = options.timeoutErrorMessage ?? 'adapter_fetch_timeout'
if (options.signal?.aborted) {
throw new Error(abortErrorMessage)
}
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
return await new Promise<T>((resolve, reject) => {
const cleanup = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
timeoutHandle = null
}
options.signal?.removeEventListener('abort', onAbort)
}
const onAbort = () => {
cleanup()
reject(new Error(abortErrorMessage))
}
timeoutHandle = setTimeout(() => {
cleanup()
reject(new Error(timeoutErrorMessage))
}, timeoutMs)
options.signal?.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
cleanup()
resolve(value)
},
(error) => {
cleanup()
reject(error)
},
)
})
}
export async function fetchImageBlob(
sourceUrl: string,
options: AdapterFetchOptions = {},
): Promise<Blob | null> {
const normalizedUrl = normalizeRemoteUrl(sourceUrl)
if (!normalizedUrl) {
return null
}
const response = await fetch(normalizedUrl).catch(() => null)
const response = await adapterFetch(normalizedUrl, {}, {
timeoutMs: options.timeoutMs ?? defaultImageFetchTimeoutMs,
signal: options.signal,
}).catch(() => null)
if (!response?.ok) {
return null
}
@@ -98,6 +238,7 @@ export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
export async function uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
options: UploadHtmlImagesOptions = {},
): Promise<{ html: string; uploaded: Map<string, string> }> {
const sources = extractImageSources(html)
if (!sources.length) {
@@ -108,13 +249,68 @@ export async function uploadHtmlImages(
}
const uploaded = new Map<string, string>()
for (const source of sources) {
const target = await uploader(source)
const overallSignal = buildAdapterAbortSignal({
signal: options.signal,
timeoutMs: options.timeoutMs ?? defaultHtmlImageUploadTimeoutMs,
})
const concurrency = Math.max(
1,
Math.min(options.concurrency ?? defaultHtmlImageUploadConcurrency, sources.length),
)
let cursor = 0
const uploadOne = async (source: string) => {
if (overallSignal.aborted) {
throw new Error('adapter_aborted')
}
const target = await Promise.race([
uploader(source),
new Promise<null>((resolve) => {
const signal = timeoutSignal(options.imageTimeoutMs ?? defaultImageFetchTimeoutMs)
if (signal.aborted) {
resolve(null)
return
}
signal.addEventListener('abort', () => resolve(null), { once: true })
}),
]).catch(() => null)
if (target) {
uploaded.set(source, target)
}
}
const workers = Array.from({ length: concurrency }, async () => {
// Stop pulling new images once the overall signal aborts so abandoned workers
// don't keep firing fresh network requests after the caller has given up.
while (cursor < sources.length && !overallSignal.aborted) {
const index = cursor
cursor += 1
await uploadOne(sources[index])
}
})
try {
await Promise.race([
Promise.all(workers),
new Promise<never>((_, reject) => {
if (overallSignal.aborted) {
reject(new Error('adapter_aborted'))
return
}
overallSignal.addEventListener('abort', () => reject(new Error('adapter_aborted')), {
once: true,
})
}),
])
} catch {
if (options.signal?.aborted) {
throw new Error('adapter_aborted')
}
// Best-effort image replacement: keep successfully uploaded images and leave
// slow or broken image URLs untouched instead of blocking the whole article.
}
let next = html
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to)
@@ -127,6 +323,7 @@ export async function sessionFetchText(
session: Session,
input: string,
init?: RequestInit,
options: AdapterFetchOptions = {},
): Promise<string> {
const headers = new Headers(init?.headers)
if (!headers.has('user-agent')) {
@@ -139,6 +336,11 @@ export async function sessionFetchText(
const response = await session.fetch(input, {
...init,
headers,
signal: mergeAbortSignals([
init?.signal,
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]),
})
const text = await response.text()
if (!response.ok) {
@@ -151,8 +353,9 @@ export async function sessionFetchJson<T>(
session: Session,
input: string,
init?: RequestInit,
options: AdapterFetchOptions = {},
): Promise<T> {
const text = await sessionFetchText(session, input, init)
const text = await sessionFetchText(session, input, init, options)
if (!text) {
return {} as T
}
@@ -169,7 +372,8 @@ export interface PageFetchInit {
export async function sessionCookieFetchJson<T>(
session: Session,
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
options: AdapterFetchOptions = {},
): Promise<T | null> {
let parsedURL: URL
try {
@@ -208,6 +412,11 @@ export async function sessionCookieFetchJson<T>(
method: init?.method ?? 'GET',
headers,
body: init?.body,
signal: mergeAbortSignals([
init?.signal,
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]),
})
} catch {
return null
@@ -238,6 +447,7 @@ export async function pageFetchJson<T>(
webContents: WebContents,
url: string,
init?: PageFetchInit,
options: AdapterFetchOptions = {},
): Promise<T | null> {
if (!webContents || webContents.isDestroyed()) {
return null
@@ -249,8 +459,11 @@ export async function pageFetchJson<T>(
}
const script = `(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ${JSON.stringify(options.timeoutMs ?? defaultFetchTimeoutMs)});
try {
const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)});
const init = ${JSON.stringify(effectiveInit)};
const response = await fetch(${JSON.stringify(url)}, { ...init, signal: controller.signal });
if (!response.ok) {
return JSON.stringify({ __ok: false, status: response.status });
}
@@ -261,12 +474,17 @@ export async function pageFetchJson<T>(
return JSON.stringify({ __ok: true, body: text });
} catch (error) {
return JSON.stringify({ __ok: false, error: String(error && error.message || error) });
} finally {
clearTimeout(timeout);
}
})()`
let serialized: string
try {
serialized = (await webContents.executeJavaScript(script, true)) as string
serialized = (await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
signal: options.signal,
timeoutMs: options.timeoutMs,
})) as string
} catch {
return null
}
@@ -331,7 +549,11 @@ export async function ensureViewLoaded(
return
}
await view.webContents.loadURL(url)
await raceWithAbort(view.webContents.loadURL(url), {
signal,
timeoutMs: 45_000,
timeoutErrorMessage: 'adapter_view_load_timeout',
})
await waitForLoadStop(view, signal)
}
@@ -11,7 +11,9 @@ import {
ensureViewLoaded,
extractImageSources,
normalizeArticleHtml,
raceWithAbort,
sessionFetchJson,
sessionFetchText,
} from './common'
import { countDongchediContentWordCount, prepareDongchediArticleHtml } from './dongchedi-content'
import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob } from './media-image'
@@ -276,6 +278,9 @@ async function dongchediPageFetchJson<T>(
},
): Promise<T> {
await ensureDongchediEditorContext(context)
if (context.signal.aborted) {
throw new Error('adapter_aborted')
}
const effectiveInit = {
credentials: 'include' as const,
@@ -284,20 +289,30 @@ async function dongchediPageFetchJson<T>(
const page = context.playwright?.page
if (page && !page.isClosed()) {
const envelope = await page.evaluate(
const envelope = await raceWithAbort(
page.evaluate(
async ({ fetchInput, fetchInit }) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch(fetchInput, fetchInit)
const response = await fetch(fetchInput, {
...fetchInit,
signal: controller.signal,
})
const text = await response.text().catch(() => '')
return { ok: response.ok, status: response.status, body: text }
} catch (error) {
return { ok: false, error: String((error && (error as Error).message) || error) }
} finally {
clearTimeout(timeout)
}
},
{
fetchInput: input,
fetchInit: effectiveInit,
},
),
{ signal: context.signal, timeoutMs: 35_000 },
)
return parseDongchediFetchEnvelope<T>(envelope)
}
@@ -308,17 +323,24 @@ async function dongchediPageFetchJson<T>(
}
const script = `(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const init = ${JSON.stringify(effectiveInit)};
const response = await fetch(${JSON.stringify(input)}, init);
const response = await fetch(${JSON.stringify(input)}, { ...init, signal: controller.signal });
const text = await response.text().catch(() => "");
return JSON.stringify({ ok: response.ok, status: response.status, body: text });
} catch (error) {
return JSON.stringify({ ok: false, error: String(error && error.message || error) });
} finally {
clearTimeout(timeout);
}
})()`
const serialized = await webContents.executeJavaScript(script, true)
const serialized = await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
signal: context.signal,
timeoutMs: 35_000,
})
if (typeof serialized !== 'string' || !serialized) {
throw new Error('dongchedi_page_fetch_failed')
}
@@ -378,13 +400,20 @@ function parseDongchediFetchEnvelope<T>(envelope: {
}
async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<string | null> {
if (context.signal.aborted) {
throw new Error('adapter_aborted')
}
const page = context.playwright?.page
if (page && !page.isClosed()) {
const token = await page.evaluate(async (url) => {
const token = await raceWithAbort(page.evaluate(async (url) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15_000)
try {
const response = await fetch(url, {
method: 'HEAD',
credentials: 'include',
signal: controller.signal,
headers: {
'x-secsdk-csrf-request': '1',
'x-secsdk-csrf-version': '1.2.10',
@@ -393,8 +422,13 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
return response.headers.get('x-ware-csrf-token') || ''
} catch {
return ''
} finally {
clearTimeout(timeout)
}
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`), {
signal: context.signal,
timeoutMs: 18_000,
})
const parts = token.split(',')
return parts.length >= 2 && parts[1] ? parts[1] : null
}
@@ -405,10 +439,13 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
}
const script = `(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(${JSON.stringify(`${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)}, {
method: "HEAD",
credentials: "include",
signal: controller.signal,
headers: {
"x-secsdk-csrf-request": "1",
"x-secsdk-csrf-version": "1.2.10"
@@ -417,10 +454,15 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
return JSON.stringify({ token: response.headers.get("x-ware-csrf-token") || "" });
} catch {
return JSON.stringify({ token: "" });
} finally {
clearTimeout(timeout);
}
})()`
const serialized = await webContents.executeJavaScript(script, true).catch(() => '')
const serialized = await raceWithAbort(
webContents.executeJavaScript(script, true) as Promise<string>,
{ signal: context.signal, timeoutMs: 18_000 },
).catch(() => '')
if (typeof serialized !== 'string' || !serialized) {
return null
}
@@ -440,12 +482,7 @@ async function imagexFetchText(
input: string,
init?: RequestInit,
): Promise<string> {
const response = await context.session.fetch(input, init)
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
}
return text
return await sessionFetchText(context.session, input, init, { signal: context.signal })
}
async function imagexFetchJson<T>(
@@ -471,6 +508,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<DongchediAc
{
credentials: 'include',
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data
@@ -595,6 +633,7 @@ async function fetchUploadToken(
{
credentials: 'include',
},
{ signal: context.signal },
).catch(() => null)
const token = response?.data?.token
@@ -625,7 +664,7 @@ async function uploadImage(
sourceUrl: string,
ratio?: number,
): Promise<DongchediUploadedImage | null> {
const source = await fetchImageAssetBlob(sourceUrl)
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!source) {
throw new Error(ratio ? 'dongchedi_cover_fetch_failed' : 'dongchedi_image_fetch_failed')
}
@@ -680,11 +719,15 @@ async function uploadImage(
'content-crc32': crc32(new Uint8Array(buffer)),
authorization: storeInfo.Auth,
})
const putResponse = await context.session.fetch(`https://${uploadHost}/${storeUri}`, {
method: 'PUT',
headers: putHeaders,
body: image.blob,
})
const putResponse = await raceWithAbort(
context.session.fetch(`https://${uploadHost}/${storeUri}`, {
method: 'PUT',
headers: putHeaders,
body: image.blob,
signal: context.signal,
}),
{ signal: context.signal, timeoutMs: 30_000 },
)
if (!putResponse.ok) {
const text = await putResponse.text().catch(() => '')
throw new Error(text || `dongchedi_image_tos_upload_failed_${putResponse.status}`)
@@ -729,6 +772,7 @@ async function uploadImage(
credentials: 'include',
body: `img_uris=${storeUri}&img_url_type=2&img_param=noop&img_format=image`,
},
{ signal: context.signal },
).catch(() => null)
const imageUrl = imageUrlResponse?.data?.img_url_map?.[storeUri]?.main_url?.trim() || ''
@@ -765,6 +809,7 @@ async function spiceImage(
imageUrl,
}),
},
{ signal: context.signal },
).catch(() => null)
return response?.data ?? null
@@ -41,9 +41,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -83,8 +83,9 @@ export const jianshuAdapter: PublishAdapter = {
coverAssetUrl: context.article.cover_asset_url,
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob: fetchAssetBlob,
fetchJson: (input, init) =>
sessionFetchJson(context.session, input, init, { signal: context.signal }),
fetchImageBlob: (sourceUrl) => fetchAssetBlob(sourceUrl, context.signal),
reportProgress(stage) {
context.reportProgress(`jianshu.${stage}`)
},
+75 -29
View File
@@ -5,6 +5,7 @@ import type { JsonValue } from '@geo/shared-types'
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
@@ -180,6 +181,7 @@ async function fetchProfile(context: PublishAdapterContext): Promise<JuejinProfi
}),
body: '{}',
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.bui_user ?? null
@@ -233,6 +235,7 @@ function shouldSkipImageSource(source: string): boolean {
async function uploadMarkdownImages(
markdown: string,
uploader: (sourceUrl: string) => Promise<string | null>,
signal?: AbortSignal,
): Promise<string> {
const sources = extractMarkdownImageSources(markdown)
if (!sources.length) {
@@ -241,10 +244,27 @@ async function uploadMarkdownImages(
const uploaded = new Map<string, string>()
for (const source of sources) {
if (signal?.aborted) {
throw new Error('adapter_aborted')
}
if (shouldSkipImageSource(source)) {
continue
}
const target = await uploader(source)
let imageTimeout: ReturnType<typeof setTimeout> | null = null
const target = await Promise.race([
uploader(source),
new Promise<null>((resolve) => {
imageTimeout = setTimeout(() => resolve(null), 10_000)
signal?.addEventListener('abort', () => resolve(null), { once: true })
}),
])
.catch(() => null)
.finally(() => {
if (imageTimeout) {
clearTimeout(imageTimeout)
imageTimeout = null
}
})
if (target) {
uploaded.set(source, target)
}
@@ -386,9 +406,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -504,8 +524,12 @@ function crc32Table(): Uint32Array {
return table
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init)
async function mainFetchJson<T>(
input: string,
init?: RequestInit,
options: { signal?: AbortSignal } = {},
): Promise<T> {
const response = await adapterFetch(input, init, { signal: options.signal })
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
@@ -532,6 +556,7 @@ async function fetchImageXToken(
credentials: 'include',
headers: await juejinHeaders(context),
},
{ signal: context.signal },
)
if (response.err_no && response.err_no !== 0) {
@@ -550,7 +575,7 @@ async function fetchImageXToken(
}
}
async function applyImageUpload(token: JuejinImageXToken) {
async function applyImageUpload(token: JuejinImageXToken, signal?: AbortSignal) {
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`
const signedHeaders = signAWS4({
method: 'GET',
@@ -559,10 +584,14 @@ async function applyImageUpload(token: JuejinImageXToken) {
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
})
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(url, {
method: 'GET',
headers: signedHeaders,
})
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(
url,
{
method: 'GET',
headers: signedHeaders,
},
{ signal },
)
const uploadAddress = response.Result?.UploadAddress
const storeInfo = uploadAddress?.StoreInfos?.[0]
const uploadHost = uploadAddress?.UploadHosts?.[0]
@@ -580,24 +609,33 @@ async function applyImageUpload(token: JuejinImageXToken) {
async function uploadToTOS(
upload: Awaited<ReturnType<typeof applyImageUpload>>,
blob: Blob,
signal?: AbortSignal,
): Promise<void> {
const buffer = Buffer.from(await blob.arrayBuffer())
const response = await fetch(`https://${upload.uploadHost}/${upload.storeUri}`, {
method: 'PUT',
headers: {
authorization: upload.auth,
'content-type': blob.type || 'application/octet-stream',
'content-crc32': crc32(new Uint8Array(buffer)),
const response = await adapterFetch(
`https://${upload.uploadHost}/${upload.storeUri}`,
{
method: 'PUT',
headers: {
authorization: upload.auth,
'content-type': blob.type || 'application/octet-stream',
'content-crc32': crc32(new Uint8Array(buffer)),
},
body: blob,
},
body: blob,
})
{ signal },
)
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(text || `juejin_image_tos_upload_failed_${response.status}`)
}
}
async function commitImageUpload(token: JuejinImageXToken, sessionKey: string): Promise<void> {
async function commitImageUpload(
token: JuejinImageXToken,
sessionKey: string,
signal?: AbortSignal,
): Promise<void> {
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`
const signedHeaders = signAWS4({
method: 'POST',
@@ -606,13 +644,17 @@ async function commitImageUpload(token: JuejinImageXToken, sessionKey: string):
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
})
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(url, {
method: 'POST',
headers: {
...signedHeaders,
'content-length': '0',
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(
url,
{
method: 'POST',
headers: {
...signedHeaders,
'content-length': '0',
},
},
})
{ signal },
)
if (!response.Result) {
throw new Error('juejin_image_commit_upload_failed')
}
@@ -631,6 +673,7 @@ async function resolveImageURL(
credentials: 'include',
headers: await juejinHeaders(context),
},
{ signal: context.signal },
)
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || 'juejin_image_url_failed')
@@ -647,15 +690,15 @@ async function uploadImage(
uuid: string,
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl)
const blob = await fetchAssetBlob(sourceUrl, context.signal)
if (!blob || !blob.type.startsWith('image/')) {
return null
}
const token = await fetchImageXToken(context, uuid)
const upload = await applyImageUpload(token)
await uploadToTOS(upload, blob)
await commitImageUpload(token, upload.sessionKey)
const upload = await applyImageUpload(token, context.signal)
await uploadToTOS(upload, blob, context.signal)
await commitImageUpload(token, upload.sessionKey, context.signal)
return await resolveImageURL(context, uuid, upload.storeUri)
}
@@ -697,6 +740,7 @@ async function createDraft(
pics: [],
}),
},
{ signal: context.signal },
)
if (response.err_no && response.err_no !== 0) {
@@ -892,6 +936,7 @@ async function findPublishedArticle(
page_no: 1,
}),
},
{ signal: context.signal },
).catch(() => null)
const article = extractArticleFromListResponse(response, draftId)
@@ -1012,6 +1057,7 @@ export const juejinAdapter: PublishAdapter = {
markdown = await uploadMarkdownImages(
markdown,
async (sourceUrl) => await uploadImage(context, uuid, sourceUrl).catch(() => null),
context.signal,
)
context.reportProgress('juejin.create_draft')
@@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer'
import { nativeImage } from 'electron'
import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client'
import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common'
export interface ImageAssetBlob {
blob: Blob
@@ -97,10 +98,20 @@ function fileNameForImageType(sourceType: string): string {
return 'image.png'
}
export async function fetchImageAssetBlob(sourceUrl: string): Promise<ImageAssetBlob | null> {
export async function fetchImageAssetBlob(
sourceUrl: string,
options: AdapterFetchOptions = {},
): Promise<ImageAssetBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await (
shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate) : fetch(candidate)
shouldUseDesktopAssetFetch(candidate)
? fetchDesktopApiURL(candidate, {
signal: mergeAbortSignals([
options.signal,
timeoutSignal(options.timeoutMs ?? 10_000),
]),
})
: adapterFetch(candidate, {}, { timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal })
).catch(() => null)
if (!response?.ok) {
continue
+44 -28
View File
@@ -9,6 +9,7 @@ import {
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
@@ -171,13 +172,18 @@ async function fetchBasicInfo(context: PublishAdapterContext): Promise<QiehaoCpI
credentials: 'include',
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.cpInfo ?? null
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init)
async function mainFetchJson<T>(
input: string,
init?: RequestInit,
options: { signal?: AbortSignal } = {},
): Promise<T> {
const response = await adapterFetch(input, init, { signal: options.signal })
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
@@ -224,9 +230,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -239,7 +245,7 @@ async function uploadImage(
sourceUrl: string,
includeCoverMeta: boolean,
): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl)
const blob = await fetchAssetBlob(sourceUrl, context.signal)
if (!blob) {
throw new Error(includeCoverMeta ? 'qiehao_cover_fetch_failed' : 'qiehao_image_fetch_failed')
}
@@ -259,6 +265,7 @@ async function uploadImage(
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {}, 'image.om.qq.com'),
body: form,
},
{ signal: context.signal },
).catch(
(error): QiehaoUploadStepOneResponse => ({
msg: error instanceof Error ? error.message : 'qiehao_image_upload_failed',
@@ -306,6 +313,7 @@ async function uploadImage(
relogin: 1,
}),
},
{ signal: context.signal },
).catch(
(error): QiehaoUploadStepTwoResponse => ({
msg: error instanceof Error ? error.message : 'qiehao_cover_upload_failed',
@@ -341,6 +349,7 @@ async function findArticleUrl(
credentials: 'include',
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
},
{ signal: context.signal },
).catch(() => null)
const article = list?.data?.articles?.find(
@@ -502,8 +511,10 @@ export const qiehaoAdapter: PublishAdapter = {
}
context.reportProgress('qiehao.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, sourceUrl, false),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, sourceUrl, false),
{ signal: context.signal },
)
const coverImages: string[] = []
@@ -524,27 +535,32 @@ export const qiehaoAdapter: PublishAdapter = {
: `${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(
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),
}),
},
{ signal: context.signal },
).catch(
(requestError): QiehaoPublishResponse => ({
code: -1,
msg: requestError instanceof Error ? requestError.message : 'qiehao_publish_failed',
+50 -25
View File
@@ -8,8 +8,10 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
extractImageSources,
normalizeArticleHtml,
raceWithAbort,
sessionCookieHeader,
sessionCookieValue,
sessionFetchJson,
@@ -203,6 +205,7 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise<Smzd
referer: `${SMZDM_ZHIYOU_ORIGIN}/user`,
},
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data ?? response
@@ -257,10 +260,13 @@ async function getArticleId(context: PublishAdapterContext): Promise<string> {
throw new Error('smzdm_playwright_page_missing')
}
await page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
})
await raceWithAbort(
page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined)
const href = await page
@@ -320,9 +326,14 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchSmzdmImageBlob(sourceUrl: string): Promise<SmzdmImageBlob | null> {
async function fetchSmzdmImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<SmzdmImageBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null)
const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch(
() => null,
)
if (!response?.ok) {
continue
}
@@ -386,7 +397,7 @@ async function uploadImage(
articleId: string,
cropCover: boolean,
): Promise<SmzdmUploadedImage | null> {
const image = await fetchSmzdmImageBlob(sourceUrl)
const image = await fetchSmzdmImageBlob(sourceUrl, context.signal)
if (!image) {
throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed')
}
@@ -406,6 +417,7 @@ async function uploadImage(
headers: await smzdmHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): SmzdmImageUploadResponse => ({
error_code: -1,
@@ -425,17 +437,22 @@ async function uploadImage(
const uploadedId = stringId(uploaded.data.id)
if (uploadedId) {
void sessionFetchJson(context.session, `${SMZDM_ORIGIN}/api/editor/image_add_time/record`, {
method: 'POST',
credentials: 'include',
headers: await smzdmHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
}).catch(() => null)
void sessionFetchJson(
context.session,
`${SMZDM_ORIGIN}/api/editor/image_add_time/record`,
{
method: 'POST',
credentials: 'include',
headers: await smzdmHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
},
{ signal: context.signal },
).catch(() => null)
}
if (!cropCover) {
@@ -460,6 +477,7 @@ async function uploadImage(
pic_url: uploaded.data.url,
}),
},
{ signal: context.signal },
).catch(() => null)
const crop = getSmzdmCoverCropRect(image.width, image.height)
@@ -497,6 +515,7 @@ async function uploadImage(
headers: await smzdmHeaders(context),
body: cropForm,
},
{ signal: context.signal },
).catch(
(error): SmzdmCropResponse => ({
error_code: -1,
@@ -570,14 +589,17 @@ async function calculateEditorSignature(
throw new Error('smzdm_playwright_page_missing')
}
await page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
})
await raceWithAbort(
page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined)
const textCount = await page
.evaluate((content) => {
const textCount = await raceWithAbort(
page.evaluate((content) => {
const editorNode = document.querySelector('.ProseMirror[contenteditable]') as
| (HTMLElement & {
editor?: {
@@ -600,7 +622,9 @@ async function calculateEditorSignature(
},
})
return editor.commands.getTextCount()
}, html)
}, html),
{ signal: context.signal, timeoutMs: 20_000 },
)
.catch(() => null)
const normalizedCount =
@@ -904,6 +928,7 @@ export const smzdmAdapter: PublishAdapter = {
}),
),
},
{ signal: context.signal },
).catch(
(requestError): SmzdmSubmitResponse => ({
error_code: -1,
@@ -141,6 +141,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount
credentials: 'include',
headers: await sohuHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const account = accountFromRaw(registerInfo?.data?.account)
@@ -157,6 +158,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount
credentials: 'include',
headers: await sohuHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
for (const group of list?.data?.data ?? []) {
@@ -176,7 +178,7 @@ async function uploadImage(
accountId: string,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
@@ -188,14 +190,19 @@ async function uploadImage(
const url = new URL(`${SOHU_ORIGIN}/commons/front/outerUpload/image/file`)
url.searchParams.set('accountId', accountId)
const response = await sessionFetchJson<SohuUploadResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'sp-cm': await sohuSpCm(context),
}),
body: form,
}).catch(() => null)
const response = await sessionFetchJson<SohuUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'sp-cm': await sohuSpCm(context),
}),
body: form,
},
{ signal: context.signal },
).catch(() => null)
return response?.url?.trim() || null
}
@@ -225,16 +232,21 @@ async function submitArticle(
infoResource: 0,
}
const response = await sessionFetchJson<SohuSubmitResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'content-type': 'application/json',
'dv-id': generateDeviceId(),
'sp-cm': await sohuSpCm(context),
}),
body: JSON.stringify(body),
}).catch(
const response = await sessionFetchJson<SohuSubmitResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'content-type': 'application/json',
'dv-id': generateDeviceId(),
'sp-cm': await sohuSpCm(context),
}),
body: JSON.stringify(body),
},
{ signal: context.signal },
).catch(
(error): SohuSubmitResponse => ({
code: -1,
msg: error instanceof Error ? error.message : 'sohuhao_submit_failed',
@@ -355,8 +367,10 @@ export const sohuhaoAdapter: PublishAdapter = {
}
context.reportProgress('sohuhao.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, account.id, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, account.id, sourceUrl),
{ signal: context.signal },
)
const publishType = resolvePublishType(payload)
@@ -60,7 +60,7 @@ describe('toutiao adapter', () => {
expect(bodyBlob?.type).toBe('image/png')
expect(coverBlob?.type).toBe('image/png')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token', expect.anything())
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token', expect.anything())
})
})
@@ -5,8 +5,11 @@ import type { PublishAdapter } from './base'
import { normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
import { fetchImageAssetBlob } from './media-image'
async function fetchToutiaoImageBlob(sourceUrl: string): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl))?.blob ?? null
async function fetchToutiaoImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl, { signal }))?.blob ?? null
}
function buildResultPayload(
@@ -40,9 +43,11 @@ export const toutiaoAdapter: PublishAdapter = {
publishType: 'publish',
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob: fetchToutiaoImageBlob,
uploadHtmlImages,
fetchJson: (input, init) =>
sessionFetchJson(context.session, input, init, { signal: context.signal }),
fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal),
uploadHtmlImages: (sourceHtml, uploader) =>
uploadHtmlImages(sourceHtml, uploader, { signal: context.signal }),
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`)
},
@@ -9,6 +9,7 @@ import {
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
extractImageSources,
raceWithAbort,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
@@ -180,6 +181,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<WangyiAccou
credentials: 'include',
headers: await wangyiHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const media = response?.data?.mediaInfo
@@ -213,6 +215,7 @@ async function fetchPublishDetail(
credentials: 'include',
headers: await wangyiHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
if (String(response?.code) !== '1' || !response?.data) {
@@ -235,12 +238,13 @@ async function getPublishToken(context: PublishAdapterContext): Promise<string>
}
for (let attempt = 0; attempt < 2; attempt += 1) {
await page
.goto(WANGYI_HOME_URL, {
await raceWithAbort(
page.goto(WANGYI_HOME_URL, {
waitUntil: attempt === 0 ? 'domcontentloaded' : 'load',
timeout: 20_000,
})
.catch(() => null)
}),
{ signal: context.signal, timeoutMs: 20_000 },
).catch(() => null)
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => null)
if (attempt > 0) {
await sleep(800, context.signal)
@@ -445,7 +449,7 @@ async function uploadImage(
account: WangyiAccount,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
@@ -460,15 +464,20 @@ async function uploadImage(
url.searchParams.set('wemediaId', account.wemediaId)
url.searchParams.set('realUserId', account.realUserId)
const response = await sessionFetchJson<WangyiUploadResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await wangyiHeaders(context, {
'x-b3-sampled': '1',
'x-b3-spanid': '0',
}),
body: form,
}).catch(() => null)
const response = await sessionFetchJson<WangyiUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await wangyiHeaders(context, {
'x-b3-sampled': '1',
'x-b3-spanid': '0',
}),
body: form,
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.url?.trim() || null
}
@@ -544,16 +553,21 @@ async function saveDraftWithSession(
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(
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,
},
{ signal: context.signal },
).catch(
(error): WangyiDraftResponse => ({
code: -1,
message: error instanceof Error ? error.message : 'wangyihao_draft_save_failed',
@@ -571,24 +585,34 @@ async function saveDraftWithPage(
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(),
},
const serialized = await raceWithAbort(
page.evaluate(
async ({ requestUrl, requestBody }) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch(requestUrl, {
method: 'POST',
credentials: 'include',
signal: controller.signal,
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-b3-sampled': '1',
'x-b3-spanid': '0',
},
body: requestBody,
})
return await response.text()
} finally {
clearTimeout(timeout)
}
},
{
requestUrl: url,
requestBody: body.toString(),
},
),
{ signal: context.signal, timeoutMs: 35_000 },
)
if (!serialized) {
@@ -835,9 +859,12 @@ async function publishDraft(context: PublishAdapterContext, draftId: string): Pr
}
const draftUrl = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`
await page.goto(draftUrl, {
waitUntil: 'domcontentloaded',
})
await raceWithAbort(
page.goto(draftUrl, {
waitUntil: 'domcontentloaded',
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => null)
await waitForWangyiEditorReady(page)
@@ -997,8 +1024,10 @@ export const wangyihaoAdapter: PublishAdapter = {
}
context.reportProgress('wangyihao.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, account, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, account, sourceUrl),
{ signal: context.signal },
)
context.reportProgress('wangyihao.save_draft')
@@ -9,6 +9,7 @@ import type { PublishAdapter, PublishAdapterContext } from './base'
import {
normalizeArticleHtml,
normalizeRemoteUrl,
raceWithAbort,
sessionFetchJson,
sessionFetchText,
uploadHtmlImages,
@@ -198,12 +199,17 @@ function parseMeta(html: string): WeixinMeta | null {
}
async function fetchMeta(context: PublishAdapterContext): Promise<WeixinMeta | null> {
const html = await sessionFetchText(context.session, WEIXIN_HOME_URL, {
credentials: 'include',
headers: await weixinHeaders(context, {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}),
}).catch(() => '')
const html = await sessionFetchText(
context.session,
WEIXIN_HOME_URL,
{
credentials: 'include',
headers: await weixinHeaders(context, {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}),
},
{ signal: context.signal },
).catch(() => '')
return html ? parseMeta(html) : null
}
@@ -268,7 +274,7 @@ async function uploadImage(
return normalized
}
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
@@ -297,12 +303,17 @@ async function uploadImage(
url.searchParams.set('seq', String(Date.now()))
url.searchParams.set('t', String(Math.random()))
const response = await sessionFetchJson<WeixinUploadResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context),
body: form,
}).catch(() => null)
const response = await sessionFetchJson<WeixinUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(() => null)
if (response?.base_resp?.err_msg && response.base_resp.err_msg !== 'ok') {
const message = response.base_resp.err_msg
@@ -402,7 +413,7 @@ async function uploadCover(
meta: WeixinMeta,
sourceUrl: string,
): Promise<WeixinCover | null> {
const source = await fetchImageAssetBlob(sourceUrl)
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!source) {
return null
}
@@ -412,7 +423,7 @@ async function uploadCover(
return null
}
const cdnImage = await fetchImageAssetBlob(cdnUrl).catch(() => null)
const cdnImage = await fetchImageAssetBlob(cdnUrl, { signal: context.signal }).catch(() => null)
const width = cdnImage?.width || source.width
const height = cdnImage?.height || source.height
const rect235 = cropPercent(width, height, 2.35)
@@ -426,14 +437,19 @@ async function uploadCover(
const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/cropimage`)
url.searchParams.set('action', 'crop_multi')
const response = await sessionFetchJson<WeixinCropResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body,
}).catch(() => null)
const response = await sessionFetchJson<WeixinCropResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body,
},
{ signal: context.signal },
).catch(() => null)
return response ? coverFromCropResponse(cdnUrl, body, response) : null
}
@@ -1014,10 +1030,12 @@ function isWeixinPublishNetworkResponse(response: PlaywrightResponse): boolean {
async function waitForWeixinPublishNetworkResponse(
page: PlaywrightPage,
timeoutMs = WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS,
signal?: AbortSignal,
): Promise<WeixinOperateResponse | null> {
const response = await page
.waitForResponse(isWeixinPublishNetworkResponse, { timeout: timeoutMs })
.catch(() => null)
const response = await raceWithAbort(
page.waitForResponse(isWeixinPublishNetworkResponse, { timeout: timeoutMs }),
{ signal, timeoutMs: timeoutMs + 500 },
).catch(() => null)
if (!response) {
return null
}
@@ -1028,21 +1046,23 @@ async function waitForWeixinPublishNetworkResponse(
return parseWeixinOperateResponseText(text)
}
async function waitForWeixinEditorReady(page: PlaywrightPage): Promise<void> {
await page
.waitForLoadState('domcontentloaded', { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS })
.catch(() => null)
async function waitForWeixinEditorReady(page: PlaywrightPage, signal?: AbortSignal): Promise<void> {
await raceWithAbort(
page.waitForLoadState('domcontentloaded', { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS }),
{ signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS + 500 },
).catch(() => null)
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null)
await page
.waitForFunction(
await raceWithAbort(
page.waitForFunction(
() => {
const text = document.body?.innerText || ''
return /发表|发布|草稿|图文|登录/.test(text)
},
undefined,
{ timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS },
)
.catch(() => null)
),
{ signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS + 500 },
).catch(() => null)
}
async function readWeixinPageMessage(
@@ -1606,7 +1626,11 @@ async function completeWeixinPublishConfirmation(
}
await waitForWeixinDelay(context.signal, 300)
const responsePromise = waitForWeixinPublishNetworkResponse(page)
const responsePromise = waitForWeixinPublishNetworkResponse(
page,
WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS,
context.signal,
)
const confirm = await clickWeixinVisibleAction(page, ['发表', '确认发表', '发布', '确认发布'], {
scope: 'dialog',
timeoutMs: 8_000,
@@ -1831,10 +1855,13 @@ async function publishDraftFromDraftManagePage(
throw new Error('weixin_gzh_playwright_page_missing')
}
await page.goto(buildDraftManageUrl(meta.token), {
waitUntil: 'domcontentloaded',
})
await waitForWeixinEditorReady(page)
await raceWithAbort(
page.goto(buildDraftManageUrl(meta.token), {
waitUntil: 'domcontentloaded',
}),
{ signal: context.signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS },
)
await waitForWeixinEditorReady(page, context.signal)
const initialMessage = await readWeixinPageMessage(page)
if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) {
@@ -1844,6 +1871,7 @@ async function publishDraftFromDraftManagePage(
const directResponsePromise = waitForWeixinPublishNetworkResponse(
page,
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
context.signal,
)
const popupPromise = page.waitForEvent('popup', { timeout: 8_000 }).catch(() => null)
const openDialog = await clickWeixinDraftCardPublish(page, articleId, title, context.signal)
@@ -1858,7 +1886,7 @@ async function publishDraftFromDraftManagePage(
if (popup && !popup.isClosed()) {
popup.setDefaultTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS)
popup.setDefaultNavigationTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS)
await waitForWeixinEditorReady(popup)
await waitForWeixinEditorReady(popup, context.signal)
const popupMessage = await readWeixinPageMessage(popup)
if (/loginpage|\/cgi-bin\/login/i.test(popup.url()) || /登录超时|重新登录/.test(popupMessage)) {
throw new Error('weixin_gzh_not_logged_in')
@@ -1866,7 +1894,11 @@ async function publishDraftFromDraftManagePage(
const popupResponse = await completeWeixinPublishConfirmation(
context,
popup,
waitForWeixinPublishNetworkResponse(popup, WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000),
waitForWeixinPublishNetworkResponse(
popup,
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
context.signal,
),
)
await popup.close().catch(() => undefined)
return popupResponse
@@ -1885,10 +1917,13 @@ async function publishDraftFromEditorPage(
throw new Error('weixin_gzh_playwright_page_missing')
}
await page.goto(buildDraftEditUrl(articleId, meta.token), {
waitUntil: 'domcontentloaded',
})
await waitForWeixinEditorReady(page)
await raceWithAbort(
page.goto(buildDraftEditUrl(articleId, meta.token), {
waitUntil: 'domcontentloaded',
}),
{ signal: context.signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS },
)
await waitForWeixinEditorReady(page, context.signal)
const initialMessage = await readWeixinPageMessage(page)
if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) {
@@ -1898,6 +1933,7 @@ async function publishDraftFromEditorPage(
const directResponsePromise = waitForWeixinPublishNetworkResponse(
page,
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
context.signal,
)
const openDialog = await clickWeixinVisibleAction(page, ['发表', '发布'], {
timeoutMs: 12_000,
@@ -2091,6 +2127,7 @@ export const weixinGzhAdapter: PublishAdapter = {
context.reportProgress('weixin_gzh.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, meta, sourceUrl),
{ signal: context.signal },
)
const content = wrapWeixinContent(processed.html)
@@ -2112,6 +2149,7 @@ export const weixinGzhAdapter: PublishAdapter = {
}),
body: form,
},
{ signal: context.signal },
).catch(
(error): WeixinOperateResponse => ({
ret: -1,
@@ -6,6 +6,7 @@ import { marked } from 'marked'
import type { PublishAdapter } from './base'
import {
adapterFetch,
extractImageSources,
fetchImageBlob,
normalizeRemoteUrl,
@@ -56,7 +57,7 @@ async function zhihuFetch<T>(
return await sessionFetchJson<T>(context.session, input, {
...init,
headers: buildHeaders(init?.headers),
})
}, { signal: context.signal })
}
function markdownToHtml(markdown: string): string {
@@ -134,7 +135,7 @@ async function uploadBinaryImage(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl)
const blob = await fetchImageBlob(sourceUrl, { signal: context.signal })
if (!blob || !blob.type.startsWith('image/')) {
return null
}
@@ -159,7 +160,7 @@ async function uploadBinaryImage(
const stringToSign = `PUT\n\n${blob.type}\n${ossDate}\nx-oss-date:${ossDate}\nx-oss-security-token:${token.upload_token.access_token}\nx-oss-user-agent:${ossUserAgent}\n/zhihu-pics/v2-${imageHash}`
const signature = hmacSha1Base64(token.upload_token.access_key || '', stringToSign)
const uploadResponse = await fetch(
const uploadResponse = await adapterFetch(
`https://zhihu-pics-upload.zhimg.com/${token.upload_file.object_key}`,
{
method: 'PUT',
@@ -173,6 +174,7 @@ async function uploadBinaryImage(
},
body: blob,
},
{ signal: context.signal, timeoutMs: 30_000 },
)
if (!uploadResponse.ok) {
throw new Error(`zhihu_oss_upload_failed_${uploadResponse.status}`)
+17 -6
View File
@@ -168,6 +168,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<ZolAccount
credentials: 'include',
headers: await zolHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data
@@ -217,6 +218,7 @@ async function createDraftId(context: PublishAdapterContext, form: FormData): Pr
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolDraftResponse => ({
errcode: -1,
@@ -254,6 +256,7 @@ async function uploadCoverVariant(
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolImageUploadResponse => ({
errcode: -1,
@@ -281,7 +284,7 @@ async function uploadCover(
context: PublishAdapterContext,
sourceUrl: string,
): Promise<ZolCoverImage[] | null> {
const source = await fetchImageAssetBlob(sourceUrl)
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!source) {
throw new Error('zol_cover_fetch_failed')
}
@@ -327,6 +330,7 @@ async function uploadContentImageByNetworkPic(
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(() => null)
const uploaded = zolUploadedImageUrl(response)
@@ -345,7 +349,7 @@ async function uploadContentImageByFile(
context: PublishAdapterContext,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
throw new Error(
`zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:source_fetch_failed`,
@@ -367,6 +371,7 @@ async function uploadContentImageByFile(
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolImageUploadResponse => ({
errcode: -1,
@@ -421,10 +426,15 @@ async function applyRecommendSubjects(
url.searchParams.set('keyword', '')
url.searchParams.set('page', '1')
const response = await sessionFetchJson<ZolSubjectListResponse>(context.session, url.toString(), {
credentials: 'include',
headers: await zolHeaders(context),
}).catch(() => null)
const response = await sessionFetchJson<ZolSubjectListResponse>(
context.session,
url.toString(),
{
credentials: 'include',
headers: await zolHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const subjects = (response?.data?.list ?? [])
.filter((item) => item.subjectId != null && item.subjectName?.trim())
@@ -598,6 +608,7 @@ export const zolAdapter: PublishAdapter = {
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolPublishResponse => ({
errcode: -1,
@@ -66,6 +66,7 @@ import { initScheduler } from './scheduler'
import { initSessionRegistry } from './session-registry'
import { initSingleInstance } from './single-instance'
import {
cancelDesktopTask,
checkDesktopClientRelease,
initTransport,
listDesktopPublishTasks,
@@ -1013,6 +1014,9 @@ function registerBridgeHandlers(): void {
safeHandle('desktop:retry-publish-task', async (_event, taskId: string) => {
return retryDesktopPublishTask(taskId)
})
safeHandle('desktop:cancel-publish-task', async (_event, taskId: string) => {
return cancelDesktopTask(taskId, { reason: 'user_cancelled_publish' })
})
safeHandle('desktop:open-external-url', async (_event, url: string) => {
if (!isSafeExternalUrl(url)) {
throw new Error('unsupported_external_url')
@@ -118,6 +118,7 @@ import {
leaseDesktopTask,
leaseMonitoringTasks,
listDesktopAccounts,
markDesktopTaskPublishSubmitStarted,
noteTransportHeartbeat,
noteTransportPull,
offlineDesktopClient,
@@ -148,7 +149,11 @@ type MonitorExecutionPhase =
const heartbeatIntervalMs = 15_000
const pullIntervalMs = 60_000
const pumpWatchdogIntervalMs = 15_000
const leaseExtendIntervalMs = 60_000
const defaultPublishTaskTimeoutMs = 5 * 60_000
const publishTaskStaleProgressMs = 2 * 60_000
const publishLongRunningNotificationMs = 15 * 60_000
const maxActivityItems = 60
const monitorBusinessTimeZone = 'Asia/Shanghai'
const authRequiredMonitorPlatforms = new Set(aiPlatformCatalog.map((platform) => platform.id))
@@ -171,6 +176,7 @@ interface RuntimeTaskRecord {
status: RuntimeTaskStatus
routing: RuntimeTaskRouting
leaseExpiresAt: number | null
startedAt: number | null
updatedAt: number
summary: string
payload: Record<string, JsonValue>
@@ -226,6 +232,16 @@ interface ActiveRuntimeExecution {
interruptGeneration: number
interruptReason: string | null
lastSafePointAt: number | null
startedAt: number
lastProgressAt: number
lastProgressSummary: string | null
deadlineAt: number | null
deadlineHandle: ReturnType<typeof setTimeout> | null
longRunningNotifiedAt: number | null
// Set the moment a publish adapter enters its irreversible submit/save_draft stage. Once set,
// an interrupted publish is completed as 'unknown' (manual reconcile) instead of 'failed', so a
// possibly-already-published article is never silently re-posted by a retry.
submitStartedAt: number | null
}
interface RuntimeState {
@@ -241,6 +257,7 @@ interface RuntimeState {
publishFallbackBackoffUntil: number
heartbeatTimer: ReturnType<typeof setInterval> | null
pullTimer: ReturnType<typeof setInterval> | null
pumpWatchdogTimer: ReturnType<typeof setInterval> | null
accountHealthReportTimer: ReturnType<typeof setTimeout> | null
accountHealthReportQueue: Map<string, DesktopAccountHealthReport>
accountHealthReportBadAccounts: Set<string>
@@ -271,6 +288,7 @@ const state: RuntimeState = {
publishFallbackBackoffUntil: 0,
heartbeatTimer: null,
pullTimer: null,
pumpWatchdogTimer: null,
accountHealthReportTimer: null,
accountHealthReportQueue: new Map<string, DesktopAccountHealthReport>(),
accountHealthReportBadAccounts: new Set<string>(),
@@ -659,7 +677,10 @@ export function getRuntimeControllerSnapshot(): RuntimeControllerSnapshot {
),
tasks: [...state.tasks.values()]
.sort((left, right) => right.updatedAt - left.updatedAt)
.map(({ leaseToken: _leaseToken, ...task }) => ({ ...task })),
.map(({ leaseToken: _leaseToken, ...task }) => ({
...task,
startedAt: state.activeExecutions.get(task.id)?.startedAt ?? task.startedAt,
})),
activity: [...state.activity],
lastHeartbeatAt: state.lastHeartbeatAt,
lastHeartbeatStatus: state.lastHeartbeatStatus,
@@ -690,6 +711,7 @@ function startRuntime(): void {
connectDispatchWs()
scheduleHeartbeatLoop()
schedulePullLoop()
schedulePumpWatchdogLoop()
void sendHeartbeat('startup')
void syncAccounts('startup')
@@ -714,6 +736,10 @@ function stopRuntime(): void {
state.running = false
state.leaseInFlightKinds.clear()
for (const execution of state.activeExecutions.values()) {
if (execution.deadlineHandle) {
clearTimeout(execution.deadlineHandle)
execution.deadlineHandle = null
}
execution.abortController.abort()
}
state.activeExecutions.clear()
@@ -726,6 +752,10 @@ function stopRuntime(): void {
clearInterval(state.pullTimer)
state.pullTimer = null
}
if (state.pumpWatchdogTimer) {
clearInterval(state.pumpWatchdogTimer)
state.pumpWatchdogTimer = null
}
if (state.accountHealthReportTimer) {
clearTimeout(state.accountHealthReportTimer)
state.accountHealthReportTimer = null
@@ -793,6 +823,17 @@ function schedulePullLoop(): void {
}, pullIntervalMs)
}
function schedulePumpWatchdogLoop(): void {
if (state.pumpWatchdogTimer) {
clearInterval(state.pumpWatchdogTimer)
}
state.pumpWatchdogTimer = setInterval(() => {
notifyLongRunningPublishTasks()
pumpExecutionLoop()
}, pumpWatchdogIntervalMs)
}
function connectDispatchWs(): void {
if (!canRunLive(state.session)) {
return
@@ -909,6 +950,7 @@ function handleTaskEvent(event: DesktopTaskEventMessage): void {
status: event.status,
routing: existing?.routing ?? 'rabbitmq-primary',
leaseExpiresAt: event.status === 'in_progress' ? (existing?.leaseExpiresAt ?? null) : null,
startedAt: event.status === 'in_progress' ? (existing?.startedAt ?? eventUpdatedAt) : null,
updatedAt: eventUpdatedAt,
summary: summaryFromEventType(event.type, event.status),
payload: existing?.payload ?? {},
@@ -1577,6 +1619,114 @@ function enqueuePublishTask(taskId: string, platform: string, routing: RuntimeTa
syncSchedulerSurface()
}
function createActiveExecution(
taskRecord: RuntimeTaskRecord,
abortController: AbortController,
): ActiveRuntimeExecution {
const now = Date.now()
return {
taskId: taskRecord.id,
kind: taskRecord.kind,
platform: taskRecord.platform,
accountId: taskRecord.accountId,
abortController,
executionPhase: taskRecord.kind === 'monitor' ? 'PREPARING' : null,
interruptRequested:
taskRecord.kind === 'monitor' && Boolean(taskRecord.payload.interrupt_requested),
interruptGeneration:
taskRecord.kind === 'monitor'
? toInterruptGeneration(taskRecord.payload.interrupt_generation)
: 0,
interruptReason: taskRecord.kind === 'monitor' ? interruptReasonFromTask(taskRecord) : null,
lastSafePointAt: taskRecord.kind === 'monitor' ? now : null,
startedAt: now,
lastProgressAt: now,
lastProgressSummary: taskRecord.summary,
deadlineAt: null,
deadlineHandle: null,
longRunningNotifiedAt: null,
submitStartedAt: null,
}
}
function armPublishTaskDeadline(
taskRecord: RuntimeTaskRecord,
active: ActiveRuntimeExecution,
): void {
if (taskRecord.kind !== 'publish') {
return
}
const taskDeadlineMs = resolvePublishTaskTimeoutMs(taskRecord.platform)
active.deadlineAt = active.startedAt + taskDeadlineMs
active.deadlineHandle = setTimeout(() => {
if (active.abortController.signal.aborted) {
return
}
const timeoutError = new Error('publish_task_timeout')
recordActivity(
'warn',
'任务超时',
`${taskRecord.title} 执行超过 ${Math.round(taskDeadlineMs / 1000)}s,已中止并释放。`,
)
updateTaskProgress(taskRecord.id, `${taskRecord.title} 执行超时,正在释放任务。`)
active.abortController.abort(timeoutError)
}, taskDeadlineMs)
}
function resolvePublishTaskTimeoutMs(platform: string): number {
const envKey = `GEO_DESKTOP_PUBLISH_TIMEOUT_${platform.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MS`
return resolvePositiveEnvInteger(envKey) ?? resolvePositiveEnvInteger('GEO_DESKTOP_PUBLISH_TIMEOUT_MS') ?? defaultPublishTaskTimeoutMs
}
function resolvePositiveEnvInteger(key: string): number | null {
const raw = process.env[key]?.trim()
if (!raw) {
return null
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
// Stages emitted by an adapter right before it performs an irreversible platform write (creating
// the article/draft). After this point the article may exist on the platform regardless of whether
// the local task records it, so an interrupted publish must not be auto-retried.
function isIrreversiblePublishStage(stage: string): boolean {
const normalized = stage.trim().toLowerCase()
return (
normalized.endsWith('.submit') ||
normalized.endsWith('.save_draft') ||
normalized === 'submit' ||
normalized === 'save_draft'
)
}
// Persist a durable "submit started" marker (server-side, before/at the irreversible POST) and a
// local flag the abort path reads. Fires at most once per execution and never blocks publishing.
function markPublishSubmitStartedIfNeeded(taskId: string, stage: string): void {
if (!isIrreversiblePublishStage(stage)) {
return
}
const active = state.activeExecutions.get(taskId)
if (!active || active.kind !== 'publish' || active.submitStartedAt !== null) {
return
}
active.submitStartedAt = Date.now()
state.activeExecutions.set(taskId, active)
const leaseToken = state.tasks.get(taskId)?.leaseToken
if (!leaseToken) {
return
}
void markDesktopTaskPublishSubmitStarted(taskId, leaseToken).catch((error) => {
console.warn('[desktop-runtime] mark publish submit started failed', {
taskId,
message: error instanceof Error ? error.message : String(error),
})
})
}
function pumpExecutionLoop(): void {
if (!state.running) {
return
@@ -1837,6 +1987,7 @@ function enqueueLeasedMonitoringTasks(
status: 'queued',
routing,
leaseExpiresAt: parseTimestamp(task.lease_expires_at),
startedAt: null,
updatedAt: now,
summary: `已领取监控租约,等待本地调度执行(${describeRouting(routing)})。`,
payload: {
@@ -1920,18 +2071,7 @@ async function executeLeasedMonitoringTask(
recordActivity('info', '监控任务开始执行', `${taskRecord.title} 已进入本地执行队列。`)
const abortController = new AbortController()
state.activeExecutions.set(taskRecord.id, {
taskId: taskRecord.id,
kind: 'monitor',
platform: taskRecord.platform,
accountId: taskRecord.accountId,
abortController,
executionPhase: 'PREPARING',
interruptRequested: Boolean(taskRecord.payload.interrupt_requested),
interruptGeneration: toInterruptGeneration(taskRecord.payload.interrupt_generation),
interruptReason: interruptReasonFromTask(taskRecord),
lastSafePointAt: Date.now(),
})
state.activeExecutions.set(taskRecord.id, createActiveExecution(taskRecord, abortController))
noteMonitorTaskActivated(taskId)
syncSchedulerSurface()
queueMicrotask(() => pumpExecutionLoop())
@@ -2382,6 +2522,7 @@ function noteMonitorExecutionSafePoint(taskId: string, phase: MonitorExecutionPh
active.executionPhase = phase
}
active.lastSafePointAt = Date.now()
active.lastProgressAt = Date.now()
state.activeExecutions.set(taskId, active)
const taskRecord = state.tasks.get(taskId)
@@ -2406,6 +2547,7 @@ function requestMonitorStaleBusinessDateDrop(taskId: string, staleBusinessDate:
active.interruptReason = 'stale_business_date'
active.lastSafePointAt = Date.now()
active.lastProgressAt = Date.now()
state.activeExecutions.set(taskId, active)
const existing = state.tasks.get(taskId)
@@ -2523,22 +2665,9 @@ async function executeLeasedTask(
)
const abortController = new AbortController()
state.activeExecutions.set(taskRecord.id, {
taskId: taskRecord.id,
kind: taskRecord.kind,
platform: taskRecord.platform,
accountId: taskRecord.accountId,
abortController,
executionPhase: taskRecord.kind === 'monitor' ? 'PREPARING' : null,
interruptRequested:
taskRecord.kind === 'monitor' && Boolean(taskRecord.payload.interrupt_requested),
interruptGeneration:
taskRecord.kind === 'monitor'
? toInterruptGeneration(taskRecord.payload.interrupt_generation)
: 0,
interruptReason: taskRecord.kind === 'monitor' ? interruptReasonFromTask(taskRecord) : null,
lastSafePointAt: taskRecord.kind === 'monitor' ? Date.now() : null,
})
const activeExecution = createActiveExecution(taskRecord, abortController)
armPublishTaskDeadline(taskRecord, activeExecution)
state.activeExecutions.set(taskRecord.id, activeExecution)
if (task.kind === 'monitor') {
noteMonitorTaskLeased(task, routing)
@@ -2598,7 +2727,23 @@ async function executeLeasedTask(
const structuredError = toStructuredError(error)
const accountIdentity = accountIdentityFromTask(taskRecord)
if (accountIdentity) {
// If a publish task was interrupted AFTER it may have entered the irreversible submit phase,
// the article might already be live on the platform. Completing as a clean 'failed' would drop
// it from the server dedup set and let a retry double-post. Complete as 'unknown' instead so it
// stays for manual reconcile. (A pre-submit interruption is a true failure and stays 'failed'.)
const submitMaybeStarted =
taskRecord.kind === 'publish' && activeExecution.submitStartedAt !== null
const terminalStatus: 'failed' | 'unknown' = submitMaybeStarted ? 'unknown' : 'failed'
const interruptedSummary = '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。'
// Tag the persisted error so the desktop UI can require explicit confirmation before a manual
// retry of a possibly-already-published task (mirrors the server recovery payload marker).
const terminalError = submitMaybeStarted
? { ...structuredError, publish_submit_uncertain: true }
: structuredError
// A submit-phase interruption is typically a network/timeout stall, not an auth problem — don't
// wrongly degrade account health for it.
if (accountIdentity && !submitMaybeStarted) {
await reportAccountFailure(accountIdentity, {
summary: failureMessage,
error: structuredError,
@@ -2615,22 +2760,24 @@ async function executeLeasedTask(
try {
const completed = await completeDesktopTask(taskRecord.id, {
lease_token: leased.lease_token,
status: 'failed',
error: structuredError,
status: terminalStatus,
error: terminalError,
})
upsertTaskFromInfo(completed, {
routing,
summary: failureMessage || '桌面任务执行失败。',
summary: submitMaybeStarted ? interruptedSummary : failureMessage || '桌面任务执行失败。',
attemptId: null,
leaseToken: null,
})
} catch (completeError) {
const existing = state.tasks.get(taskRecord.id)
if (existing) {
existing.status = 'failed'
existing.error = structuredError
existing.summary = `${failureMessage || '桌面任务执行失败。'}result 回写失败:${errorMessage(completeError)}`
existing.status = terminalStatus
existing.error = terminalError
existing.summary = submitMaybeStarted
? `${interruptedSummary}result 回写失败:${errorMessage(completeError)}`
: `${failureMessage || '桌面任务执行失败。'}result 回写失败:${errorMessage(completeError)}`
existing.updatedAt = Date.now()
state.tasks.set(taskRecord.id, existing)
}
@@ -2638,12 +2785,18 @@ async function executeLeasedTask(
noteLeaseReleased('failed', taskRecord.id)
recordActivity(
'danger',
'任务执行失败',
`${taskRecord.title} 执行失败:${failureMessage || '未知错误'}`,
submitMaybeStarted ? 'warn' : 'danger',
submitMaybeStarted ? '发布结果待确认' : '任务执行失败',
submitMaybeStarted
? `${taskRecord.title} 进入提交阶段后中断,可能已发布,已转人工确认以避免重复发文。`
: `${taskRecord.title} 执行失败:${failureMessage || '未知错误'}`,
)
} finally {
clearInterval(extendHandle)
if (activeExecution.deadlineHandle) {
clearTimeout(activeExecution.deadlineHandle)
activeExecution.deadlineHandle = null
}
if (taskRecord.kind === 'publish') {
notePublishTaskCompleted(taskRecord.id, taskRecord.platform)
}
@@ -2656,6 +2809,7 @@ async function executeLeasedTask(
async function extendActiveLease(taskId: string, leaseToken: string): Promise<void> {
const existing = state.tasks.get(taskId)
const active = state.activeExecutions.get(taskId)
if (existing?.kind === 'monitor') {
const staleBusinessDate = resolveStaleMonitoringBusinessDate(existing.payload)
if (staleBusinessDate) {
@@ -2663,6 +2817,28 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
return
}
}
if (existing?.kind === 'publish' && active) {
const idleMs = Date.now() - active.lastProgressAt
if (idleMs >= publishTaskStaleProgressMs) {
const detail = active.lastProgressSummary ? `,最后进度:${active.lastProgressSummary}` : ''
existing.summary = `发布任务超过 ${Math.round(idleMs / 1000)} 秒没有新进度,已中止并释放,准备由服务端重排${detail}`
existing.updatedAt = Date.now()
state.tasks.set(taskId, existing)
recordActivity(
'warn',
'发布任务疑似卡住',
`${existing.title} ${existing.summary}`,
)
// Abort the local execution instead of merely letting the lease lapse: this
// releases the slot immediately and, crucially, closes the window where the
// server could re-queue the task while a stalled adapter is still mid-publish
// (which would double-post a non-idempotent article).
if (!active.abortController.signal.aborted) {
active.abortController.abort(new Error('publish_stale_progress'))
}
return
}
}
try {
const task = await extendDesktopTask(taskId, { lease_token: leaseToken })
@@ -2673,7 +2849,10 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
if (existing) {
existing.leaseExpiresAt = parseTimestamp(task.lease_expires_at) ?? existing.leaseExpiresAt
existing.updatedAt = Date.now()
existing.summary = '租约已自动续期,任务仍在执行。'
existing.summary =
existing.kind === 'publish'
? (active?.lastProgressSummary ?? '租约已自动续期,发布任务仍在执行。')
: '租约已自动续期,任务仍在执行。'
state.tasks.set(taskId, existing)
}
} catch (error) {
@@ -2738,6 +2917,7 @@ async function executeTaskAdapter(
reportProgress(stage: string) {
noteMonitorExecutionSafePoint(task.id, null)
updateTaskProgress(task.id, `publish adapter progress: ${stage}`)
markPublishSubmitStartedIfNeeded(task.id, stage)
},
},
payload,
@@ -3077,6 +3257,17 @@ function updateTaskProgress(taskId: string, summary: string): void {
existing.summary = summary
existing.updatedAt = Date.now()
state.tasks.set(taskId, existing)
const active = state.activeExecutions.get(taskId)
if (active) {
active.lastProgressAt = Date.now()
active.lastProgressSummary = summary
state.activeExecutions.set(taskId, active)
}
if (existing.kind === 'publish') {
emitRuntimeInvalidated('publish-task-progress', { taskId })
}
}
function upsertTaskFromInfo(
@@ -3102,6 +3293,7 @@ function upsertTaskFromInfo(
status: task.status,
routing: options.routing ?? existing?.routing ?? 'db-recovery',
leaseExpiresAt: parseTimestamp(task.lease_expires_at) ?? null,
startedAt: task.status === 'in_progress' ? (existing?.startedAt ?? Date.now()) : null,
updatedAt: parseTimestamp(task.updated_at) ?? Date.now(),
summary: options.summary ?? existing?.summary ?? summaryFromTaskStatus(task.status),
payload,
@@ -3149,6 +3341,42 @@ function syncSchedulerSurface(): void {
setSchedulerCurrentTask(primaryActiveTaskId())
}
function notifyLongRunningPublishTasks(): void {
const now = Date.now()
for (const active of state.activeExecutions.values()) {
if (active.kind !== 'publish' || active.longRunningNotifiedAt) {
continue
}
if (now - active.startedAt < publishLongRunningNotificationMs) {
continue
}
active.longRunningNotifiedAt = now
state.activeExecutions.set(active.taskId, active)
const task = state.tasks.get(active.taskId)
if (task) {
task.summary = `${task.summary || '发布任务仍在执行。'}(已执行 ${formatDurationText(now - active.startedAt)},如网络异常可取消后重试。)`
task.updatedAt = now
state.tasks.set(task.id, task)
recordActivity('warn', '发布等待较久', `${task.title} 已执行 ${formatDurationText(now - active.startedAt)},可能存在网络异常。`)
emitRuntimeInvalidated('publish-task-progress', { taskId: task.id })
}
}
}
function formatDurationText(ms: number): string {
const seconds = Math.max(0, Math.floor(ms / 1000))
const minutes = Math.floor(seconds / 60)
const restSeconds = seconds % 60
if (minutes <= 0) {
return `${restSeconds}`
}
if (restSeconds === 0) {
return `${minutes} 分钟`
}
return `${minutes}${restSeconds}`
}
function localQueueDepth(): number {
const publish = getPublishSchedulerSnapshot()
const monitor = getMonitorSchedulerSnapshot()
@@ -3229,7 +3457,10 @@ function activeAdmissionCount(): number {
}
function hasTotalCapacityForNewExecution(): boolean {
return activeAdmissionCount() < resolveAdaptiveTotalConcurrency()
return (
activeAdmissionCount() < resolveAdaptiveTotalConcurrency() &&
shouldAdmitNewExecutionUnderRuntimePressure()
)
}
function canStartAnotherPublishExecution(): boolean {
@@ -3355,9 +3586,13 @@ function resolveHardwareTotalConcurrency(): number {
}
function resolveRuntimeHealthTotalConcurrency(hardwareCap: number): number {
return hardwareCap
}
function shouldAdmitNewExecutionUnderRuntimePressure(): boolean {
const metrics = getProcessMetricsSnapshot().latestSample
if (!metrics) {
return Math.min(hardwareCap, minimumForegroundTotalConcurrency)
return true
}
const totalCPUUsage = metrics.processes.reduce((sum, item) => sum + item.percentCPUUsage, 0)
@@ -3371,19 +3606,10 @@ function resolveRuntimeHealthTotalConcurrency(hardwareCap: number): number {
mainPrivateBytesKB >= 1_000_000 ||
processCount >= 40
) {
return minimumForegroundTotalConcurrency
return activeAdmissionCount() < minimumForegroundTotalConcurrency
}
if (
totalCPUUsage >= 120 ||
totalPrivateBytesKB >= 1_800_000 ||
mainPrivateBytesKB >= 750_000 ||
processCount >= 32
) {
return Math.min(hardwareCap, 3)
}
return hardwareCap
return true
}
function resolveFeatureFlagTotalConcurrency(): number | null {
@@ -1,4 +1,4 @@
type RuntimeInvalidationReason = 'account-health' | 'publish-task-lease'
type RuntimeInvalidationReason = 'account-health' | 'publish-task-lease' | 'publish-task-progress'
type RuntimeInvalidationListener = (event: {
reason: RuntimeInvalidationReason
@@ -690,6 +690,16 @@ export async function completeDesktopTask(
)
}
export async function markDesktopTaskPublishSubmitStarted(
taskId: string,
leaseToken: string,
): Promise<void> {
await desktopPost<{ ok: boolean }, { lease_token: string }>(
`/api/desktop/tasks/${taskId}/publish-submit-marker`,
{ lease_token: leaseToken },
)
}
export async function cancelDesktopTask(
taskId: string,
payload: CancelDesktopTaskRequest,
+5 -2
View File
@@ -7,6 +7,7 @@ import type {
DesktopClientUpdateResult,
DesktopPublishTaskListResponse,
DesktopRuntimeSessionSyncRequest,
DesktopTaskInfo,
ListDesktopPublishTasksParams,
} from '@geo/shared-types'
import type { IpcRendererEvent } from 'electron'
@@ -167,6 +168,8 @@ const desktopBridge = {
) as Promise<DesktopPublishTaskListResponse>,
retryPublishTask: (taskId: string) =>
ipcRenderer.invoke('desktop:retry-publish-task', taskId) as Promise<CreatePublishJobResponse>,
cancelPublishTask: (taskId: string) =>
ipcRenderer.invoke('desktop:cancel-publish-task', taskId) as Promise<DesktopTaskInfo>,
openExternalUrl: (url: string) =>
ipcRenderer.invoke('desktop:open-external-url', url) as Promise<null>,
openSettingsWindow: () => ipcRenderer.invoke('desktop:open-settings-window') as Promise<null>,
@@ -197,7 +200,7 @@ const desktopBridge = {
ipcRenderer.invoke('desktop:set-window-mode', mode, request) as Promise<null>,
onRuntimeInvalidated: (
listener: (event: {
reason: 'account-health' | 'publish-task-lease'
reason: 'account-health' | 'publish-task-lease' | 'publish-task-progress'
at: number
accountId?: string
taskId?: string
@@ -206,7 +209,7 @@ const desktopBridge = {
const wrapped = (
_event: IpcRendererEvent,
payload: {
reason: 'account-health' | 'publish-task-lease'
reason: 'account-health' | 'publish-task-lease' | 'publish-task-progress'
at: number
accountId?: string
taskId?: string
+2 -1
View File
@@ -77,6 +77,7 @@ declare global {
params?: ListDesktopPublishTasksParams,
): Promise<DesktopPublishTaskListResponse>
retryPublishTask(taskId: string): Promise<CreatePublishJobResponse>
cancelPublishTask(taskId: string): Promise<DesktopTaskInfo>
openExternalUrl(url: string): Promise<null>
openSettingsWindow(): Promise<null>
getAppSettings(): Promise<DesktopAppSettings>
@@ -96,7 +97,7 @@ declare global {
): Promise<null>
onRuntimeInvalidated(
listener: (event: {
reason: 'account-health' | 'publish-task-lease'
reason: 'account-health' | 'publish-task-lease' | 'publish-task-progress'
at: number
accountId?: string
taskId?: string
@@ -59,6 +59,7 @@ export interface RuntimeTask {
status: 'queued' | 'in_progress' | 'succeeded' | 'failed' | 'unknown' | 'aborted'
routing: 'rabbitmq-primary' | 'db-recovery'
leaseExpiresAt: number | null
startedAt: number | null
updatedAt: number
summary: string
}
@@ -7,9 +7,10 @@ import {
ReloadOutlined,
RetweetOutlined,
SearchOutlined,
StopOutlined,
} from '@ant-design/icons-vue'
import type { DesktopPublishTaskListResponse, DesktopTaskInfo, JsonValue } from '@geo/shared-types'
import { notification } from 'ant-design-vue'
import { Modal, notification } from 'ant-design-vue'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { normalizePublisherErrorMessage } from '../../shared/publisher-errors'
@@ -27,6 +28,8 @@ const PAGE_SIZE = 10
const INLINE_ERROR_HEAD_CHARS = 48
const INLINE_ERROR_TAIL_CHARS = 10
const ACTIVE_TASK_POLL_INTERVAL_MS = 5_000
const DEFAULT_ESTIMATED_TASK_MS = 3 * 60_000
const PROGRESS_REFRESH_THROTTLE_MS = 3_000
const publishPlatformIndex: Record<string, (typeof desktopPublishMediaCatalog)[number]> =
Object.fromEntries(desktopPublishMediaCatalog.map((item) => [item.id, item]))
@@ -40,6 +43,8 @@ interface PublishTaskItem {
accountName: string
status: DesktopTaskInfo['status']
summary: string
queueSummary: string | null
running: boolean
attempts: number
leaseExpiresAt: number | null
createdAt: number
@@ -48,11 +53,13 @@ interface PublishTaskItem {
publishType: string | null
fallbackReason: string | null
errorMessage: string | null
submitUncertain: boolean
complianceBlockedRecordId: number | null
complianceBlockedAt: number | null
}
let refreshTimer: ReturnType<typeof setInterval> | null = null
let lastProgressRefreshAt = 0
let runtimeInvalidationUnsubscribe: (() => void) | null = null
function platformMeta(platform: string) {
@@ -114,8 +121,9 @@ function hasActivePublishingTask(page: DesktopPublishTaskListResponse | null): b
function statusTone(status: DesktopTaskInfo['status']): 'info' | 'warn' | 'success' | 'danger' {
switch (status) {
case 'queued':
case 'in_progress':
return 'info'
case 'queued':
return 'warn'
case 'succeeded':
return 'success'
@@ -311,6 +319,58 @@ function summaryForTask(
}
}
function elapsedTimeLabel(since: number, now = Date.now()): string {
const elapsedMs = Math.max(0, now - since)
if (elapsedMs < 60_000) {
return '不到 1 分钟'
}
if (elapsedMs < 60 * 60_000) {
return `${Math.max(1, Math.floor(elapsedMs / 60_000))} 分钟`
}
const hours = Math.max(1, Math.floor(elapsedMs / 3_600_000))
const minutes = Math.floor((elapsedMs % 3_600_000) / 60_000)
return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`
}
function stageSummaryFromRuntime(raw: string | null | undefined): string | null {
const summary = raw?.trim()
if (!summary) {
return null
}
const stage = summary.match(/publish adapter progress:\s*([a-z0-9_.-]+)/i)?.[1] ?? summary
const stageKey = stage.trim().toLowerCase()
const stageMap: Record<string, string> = {
detect_login: '正在校验登录态',
normalize_html: '正在整理正文',
upload_content_images: '正在上传正文图片',
upload_cover: '正在上传封面图',
submit: '正在提交发布',
save_draft: '正在保存草稿',
create_draft: '正在创建草稿',
create_article_id: '正在打开投稿入口',
calculate_signature: '正在计算页面签名',
get_token: '正在获取发布凭证',
publish: '正在确认发表',
publish_draft: '正在发表草稿',
resolve_public_url: '正在确认公开链接',
resolve_article_url: '正在确认文章链接',
apply_subjects: '正在匹配推荐话题',
disable_mass_notification: '正在关闭群发通知',
continue_publish_without_notification: '正在继续确认发表',
}
const suffix = stageKey.split('.').pop() ?? stageKey
if (stageMap[stageKey]) {
return stageMap[stageKey]
}
if (stageMap[suffix]) {
return stageMap[suffix]
}
if (summary.startsWith('publish adapter progress:')) {
return `正在执行 ${stage}`
}
return summary
}
function parseTimestamp(value: string | null): number {
if (!value) {
return Date.now()
@@ -330,6 +390,7 @@ const error = ref<string | null>(null)
const consolePendingTaskId = ref<string | null>(null)
const copiedErrorTaskId = ref<string | null>(null)
const retryingTaskId = ref<string | null>(null)
const cancelingTaskId = ref<string | null>(null)
function notifySuccess(message: string) {
notification.success({
@@ -428,11 +489,22 @@ function bindPublishLeaseListener() {
}
runtimeInvalidationUnsubscribe = window.desktopBridge.app.onRuntimeInvalidated((event) => {
if (event.reason !== 'publish-task-lease') {
if (event.reason !== 'publish-task-lease' && event.reason !== 'publish-task-progress') {
return
}
void refreshTasks(1)
if (event.reason === 'publish-task-lease') {
void refreshTasks(1)
return
}
// publish-task-progress fires once per adapter stage; throttle the server-list
// refresh so the status badge / queue position stay fresh without hammering the API.
const now = Date.now()
if (now - lastProgressRefreshAt >= PROGRESS_REFRESH_THROTTLE_MS) {
lastProgressRefreshAt = now
void refreshTasks()
}
})
}
@@ -480,7 +552,25 @@ function canRetryTask(record: PublishTaskItem): boolean {
return record.status === 'failed' || record.status === 'aborted' || record.status === 'unknown'
}
function confirmUncertainRetry(record: PublishTaskItem): Promise<boolean> {
return new Promise((resolve) => {
Modal.confirm({
title: '该文章可能已发布',
content: `${record.title}」上次执行已进入提交阶段后中断,可能已经发布到平台。重新提交可能导致重复发文。请先到平台核实,确认尚未发布后再继续。`,
okText: '已核实,仍要重新提交',
okType: 'danger',
cancelText: '取消',
onOk: () => resolve(true),
onCancel: () => resolve(false),
})
})
}
async function retryTask(record: PublishTaskItem) {
if (record.submitUncertain && !(await confirmUncertainRetry(record))) {
return
}
retryingTaskId.value = record.id
try {
@@ -512,6 +602,27 @@ async function retryTask(record: PublishTaskItem) {
}
}
function canCancelTask(record: PublishTaskItem): boolean {
return record.status === 'queued'
}
async function cancelTask(record: PublishTaskItem) {
cancelingTaskId.value = record.id
try {
await window.desktopBridge.app.cancelPublishTask(record.id)
notifySuccess('已取消排队中的发布任务')
await refreshTasks(currentPage.value)
} catch (err) {
notifyActionError(
normalizeTaskErrorMessage(unwrapClientErrorMessage(err, '取消发布任务失败')) ??
'取消发布任务失败',
)
} finally {
cancelingTaskId.value = null
}
}
async function copyTextToClipboard(text: string): Promise<void> {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
@@ -592,9 +703,14 @@ const accountMap = computed(() => {
return new Map((snapshot.value?.accounts ?? []).map((item) => [item.id, item]))
})
const runtimeTaskMap = computed(() => {
return new Map((snapshot.value?.tasks ?? []).map((item) => [item.id, item]))
})
const publishTasks = computed<PublishTaskItem[]>(() =>
(taskPage.value?.items ?? []).map((task) => {
const normalizedStatus = normalizePublishTaskStatus(task.status)
const runtimeTask = runtimeTaskMap.value.get(task.id)
const payload = task.payload ?? {}
const result = task.result ?? {}
const taskError = task.error ?? {}
@@ -619,6 +735,44 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
const articleId =
extractNumber(payload.article_id) ??
extractNumber(nestedContentRef?.article_id ?? nestedContentRef?.id)
const pendingTasks =
taskPage.value?.items.filter((item) =>
isPendingStatus(normalizePublishTaskStatus(item.status)),
) ?? []
const queuedTasks = pendingTasks.filter(
(item) => normalizePublishTaskStatus(item.status) === 'queued',
)
const queuedIndex = queuedTasks.findIndex((item) => item.id === task.id)
const runningCount = pendingTasks.filter(
(item) => normalizePublishTaskStatus(item.status) === 'in_progress',
).length
// Queue position / ahead-count are only globally accurate on page 1, where all
// pending tasks (which the server always sorts first) are present. On later pages
// the per-page index would restart from 1 and mislead the user, so we drop the
// position there and keep only the always-correct queued elapsed time.
const positionIsAccurate = currentPage.value === 1
const queuePosition = queuedIndex >= 0 && positionIsAccurate ? queuedIndex + 1 : null
const aheadCount = queuePosition === null ? null : runningCount + Math.max(0, queuePosition - 1)
const estimatedMs =
aheadCount === null ? null : Math.max(0, aheadCount) * DEFAULT_ESTIMATED_TASK_MS
const queuedSummary =
normalizedStatus === 'queued'
? `${
queuePosition !== null ? `${queuePosition} 个,前面还有 ${aheadCount} 个 / ` : ''
}已排队 ${elapsedTimeLabel(parseTimestamp(task.created_at))}${
estimatedMs && estimatedMs > 0
? ` / 预计 ${Math.max(1, Math.ceil(estimatedMs / 60_000))} 分钟后执行`
: ''
}`
: null
const runningSummary =
normalizedStatus === 'in_progress'
? `已执行 ${elapsedTimeLabel(
runtimeTask?.startedAt ?? runtimeTask?.updatedAt ?? parseTimestamp(task.updated_at),
)}`
: null
const runtimeSummary =
normalizedStatus === 'in_progress' ? stageSummaryFromRuntime(runtimeTask?.summary) : null
return {
id: task.id,
@@ -628,7 +782,9 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
accountId: task.target_account_id,
accountName: accountNameMap.value.get(task.target_account_id) ?? '待同步账号',
status: normalizedStatus,
summary: summaryForTask(normalizedStatus, publishType, fallbackReason),
summary: runtimeSummary ?? summaryForTask(normalizedStatus, publishType, fallbackReason),
queueSummary: queuedSummary ?? runningSummary,
running: normalizedStatus === 'in_progress',
attempts: task.attempts,
leaseExpiresAt: task.lease_expires_at ? parseTimestamp(task.lease_expires_at) : null,
createdAt: parseTimestamp(task.created_at),
@@ -648,6 +804,7 @@ const publishTasks = computed<PublishTaskItem[]>(() =>
extractString(taskError.code),
task.platform,
),
submitUncertain: task.status === 'unknown' && taskError.publish_submit_uncertain === true,
complianceBlockedRecordId,
complianceBlockedAt: task.compliance_blocked_at ? parseTimestamp(task.compliance_blocked_at) : null,
}
@@ -791,7 +948,13 @@ const tableScroll = { x: 1080 } as const
</template>
<template v-else-if="column.key === 'status'">
<StatusBadge :tone="statusToneForTask(record)" :label="statusLabelForTask(record)" />
<span class="status-cell">
<span v-if="record.running" class="status-spinner" aria-hidden="true" />
<StatusBadge
:tone="statusToneForTask(record)"
:label="statusLabelForTask(record)"
/>
</span>
</template>
<template v-else-if="column.key === 'meta'">
@@ -827,6 +990,9 @@ const tableScroll = { x: 1080 } as const
</a-popover>
</span>
<span v-else class="cell-sub strong-text">{{ record.summary }}</span>
<span v-if="record.queueSummary" class="cell-sub queue-text">
{{ record.queueSummary }}
</span>
<span class="cell-sub meta-footer">
<span class="attempt-chip">尝试 {{ record.attempts }} </span>
<template v-if="record.complianceBlockedRecordId">
@@ -885,7 +1051,11 @@ const tableScroll = { x: 1080 } as const
<template #icon><DesktopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="canRetryTask(record)" title="重新提交发布任务" placement="top">
<a-tooltip
v-if="canRetryTask(record)"
:title="record.submitUncertain ? '该文章可能已发布,重试前请先核实平台侧结果' : '重新提交发布任务'"
placement="top"
>
<a-button
type="text"
class="icon-action-btn"
@@ -896,7 +1066,18 @@ const tableScroll = { x: 1080 } as const
<template #icon><RetweetOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-if="isPendingStatus(record.status)" title="排队中...">
<a-tooltip v-if="canCancelTask(record)" title="取消发布任务" placement="top">
<a-button
type="text"
class="icon-action-btn icon-action-btn--danger"
:loading="cancelingTaskId === record.id"
:aria-label="`取消发布任务:${record.title}`"
@click.stop="cancelTask(record)"
>
<template #icon><StopOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip v-else-if="isPendingStatus(record.status)" title="执行中">
<div class="action-placeholder-icon">
<ClockCircleOutlined />
</div>
@@ -1191,6 +1372,28 @@ const tableScroll = { x: 1080 } as const
width: 100%;
}
.status-cell {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.status-spinner {
width: 12px;
height: 12px;
flex: 0 0 auto;
border: 2px solid #bfdbfe;
border-top-color: #2563eb;
border-radius: 999px;
animation: publish-spin 0.8s linear infinite;
}
.queue-text {
color: #475569;
font-size: 12px;
}
.meta-footer {
display: inline-flex;
align-items: center;
@@ -1274,6 +1477,12 @@ const tableScroll = { x: 1080 } as const
border-color: #dbeafe;
}
.icon-action-btn--danger:hover {
background: #fef2f2;
color: #dc2626;
border-color: #fecaca;
}
.action-placeholder-icon {
width: 28px;
height: 28px;
@@ -1292,6 +1501,12 @@ const tableScroll = { x: 1080 } as const
background: transparent;
}
@keyframes publish-spin {
to {
transform: rotate(360deg);
}
}
.feedback-banner {
padding: 12px 16px;
border-radius: 10px;