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
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:
@@ -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 ?? []
|
||||
|
||||
@@ -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}`)
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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}`)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user