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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-30 19:52:17 +08:00
parent 9d6181260a
commit fa51a3455f
41 changed files with 2124 additions and 358 deletions
@@ -11,6 +11,7 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
normalizeArticleHtml,
sessionFetchJson,
sessionFetchText,
@@ -149,19 +150,25 @@ async function fetchAppInfo(context: PublishAdapterContext): Promise<BaijiahaoUs
accept: 'application/json, text/plain, */*',
}),
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.user ?? null
}
async function getPublishToken(context: PublishAdapterContext): Promise<string> {
const html = await sessionFetchText(context.session, BAIJIAHAO_EDIT_URL, {
credentials: 'include',
headers: baijiahaoHeaders({
accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
}),
})
const html = await sessionFetchText(
context.session,
BAIJIAHAO_EDIT_URL,
{
credentials: 'include',
headers: baijiahaoHeaders({
accept:
'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8',
}),
},
{ signal: context.signal },
)
return /window\.__BJH__INIT__AUTH__\s*=\s*(["'])(.*?)\1/.exec(html)?.[2] ?? ''
}
@@ -172,7 +179,7 @@ async function uploadImage(
appId: string,
cropCover: boolean,
): Promise<string | null> {
const image = await fetchBaijiahaoImageBlob(sourceUrl)
const image = await fetchBaijiahaoImageBlob(sourceUrl, context.signal)
if (!image) {
throw new Error(cropCover ? 'baijiahao_cover_fetch_failed' : 'baijiahao_image_fetch_failed')
}
@@ -196,6 +203,7 @@ async function uploadImage(
headers: baijiahaoHeaders(),
body: form,
},
{ signal: context.signal },
).catch((error) => {
throw new Error(
error instanceof Error
@@ -232,6 +240,7 @@ async function uploadImage(
cutting_type: 'cover_image',
}),
},
{ signal: context.signal },
).catch((error) => {
throw new Error(
error instanceof Error
@@ -279,9 +288,14 @@ function buildImageURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchBaijiahaoImageBlob(sourceUrl: string): Promise<BaijiahaoImageBlob | null> {
async function fetchBaijiahaoImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<BaijiahaoImageBlob | null> {
for (const candidate of buildImageURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null)
const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch(
() => null,
)
if (!response?.ok) {
continue
}
@@ -332,7 +346,7 @@ async function processContentImages(
const processed = await uploadHtmlImages(html, async (sourceUrl) => {
await waitForHumanPace(context.signal)
return await uploadImage(context, sourceUrl, appId, false)
})
}, { signal: context.signal })
return processed.html
}
@@ -570,6 +584,7 @@ export const baijiahaoAdapter: PublishAdapter = {
activity_list: buildActivityList(),
}),
},
{ signal: context.signal },
).catch(
(requestError): BaijiahaoPublishResponse => ({
errno: -1,
@@ -7,6 +7,7 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
extractImageSources,
fetchImageBlob,
normalizeArticleHtml,
@@ -117,11 +118,16 @@ async function fetchNav(context: PublishAdapterContext): Promise<BilibiliNavResp
credentials: 'include',
headers: await bilibiliHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init)
async function mainFetchJson<T>(
input: string,
init?: RequestInit,
options: { signal?: AbortSignal } = {},
): Promise<T> {
const response = await adapterFetch(input, init, { signal: options.signal })
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
@@ -230,9 +236,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -244,7 +250,7 @@ async function uploadImage(
context: PublishAdapterContext,
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl)
const blob = await fetchAssetBlob(sourceUrl, context.signal)
if (!blob) {
throw new Error('bilibili_image_fetch_failed')
}
@@ -268,6 +274,7 @@ async function uploadImage(
headers: await bilibiliHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): BilibiliUploadResponse => ({
code: -1,
@@ -284,8 +291,10 @@ async function uploadImage(
}
async function processContentImages(context: PublishAdapterContext, html: string): Promise<string> {
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, sourceUrl),
{ signal: context.signal },
)
return processed.html
}
@@ -547,6 +556,7 @@ async function findPublishedArticle(
credentials: 'include',
headers: await bilibiliHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const items = response?.data?.items ?? []
+231 -9
View File
@@ -4,6 +4,26 @@ import { marked } from 'marked'
import { STANDARD_ACCEPT_LANGUAGES, STANDARD_USER_AGENT } from '../user-agent'
const defaultFetchTimeoutMs = 15_000
const defaultImageFetchTimeoutMs = 10_000
const defaultHtmlImageUploadTimeoutMs = 30_000
const defaultHtmlImageUploadConcurrency = 3
export interface AdapterFetchOptions {
signal?: AbortSignal
timeoutMs?: number
}
export interface UploadHtmlImagesOptions extends AdapterFetchOptions {
imageTimeoutMs?: number
concurrency?: number
}
export interface AbortablePromiseOptions extends AdapterFetchOptions {
abortErrorMessage?: string
timeoutErrorMessage?: string
}
export function normalizeText(value: unknown): string | null {
if (typeof value !== 'string') {
return null
@@ -76,13 +96,133 @@ export function extractImageSources(html: string): string[] {
return [...sources]
}
export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
export function mergeAbortSignals(
signals: Array<AbortSignal | null | undefined>,
): AbortSignal | undefined {
const activeSignals = signals.filter((signal): signal is AbortSignal => Boolean(signal))
if (!activeSignals.length) {
return undefined
}
const anyAbortSignal = (AbortSignal as typeof AbortSignal & {
any?: (signals: AbortSignal[]) => AbortSignal
}).any
if (typeof anyAbortSignal === 'function') {
return anyAbortSignal(activeSignals)
}
const controller = new AbortController()
const abort = (signal: AbortSignal) => {
if (!controller.signal.aborted) {
controller.abort(signal.reason)
}
}
for (const signal of activeSignals) {
if (signal.aborted) {
abort(signal)
break
}
signal.addEventListener('abort', () => abort(signal), { once: true })
}
return controller.signal
}
export function timeoutSignal(timeoutMs = defaultFetchTimeoutMs): AbortSignal {
const timeoutAbortSignal = (AbortSignal as typeof AbortSignal & {
timeout?: (timeoutMs: number) => AbortSignal
}).timeout
if (typeof timeoutAbortSignal === 'function') {
return timeoutAbortSignal(timeoutMs)
}
const controller = new AbortController()
setTimeout(() => controller.abort(new Error('adapter_fetch_timeout')), timeoutMs)
return controller.signal
}
export function buildAdapterAbortSignal(options: AdapterFetchOptions = {}): AbortSignal {
return mergeAbortSignals([
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]) as AbortSignal
}
export function withRequestTimeout(init: RequestInit = {}, options: AdapterFetchOptions = {}): RequestInit {
return {
...init,
signal: mergeAbortSignals([
init.signal,
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]),
}
}
export async function adapterFetch(
input: string | URL | Request,
init: RequestInit = {},
options: AdapterFetchOptions = {},
): Promise<Response> {
return fetch(input, withRequestTimeout(init, options))
}
export async function raceWithAbort<T>(
promise: Promise<T>,
options: AbortablePromiseOptions = {},
): Promise<T> {
const timeoutMs = options.timeoutMs ?? defaultFetchTimeoutMs
const abortErrorMessage = options.abortErrorMessage ?? 'adapter_aborted'
const timeoutErrorMessage = options.timeoutErrorMessage ?? 'adapter_fetch_timeout'
if (options.signal?.aborted) {
throw new Error(abortErrorMessage)
}
let timeoutHandle: ReturnType<typeof setTimeout> | null = null
return await new Promise<T>((resolve, reject) => {
const cleanup = () => {
if (timeoutHandle) {
clearTimeout(timeoutHandle)
timeoutHandle = null
}
options.signal?.removeEventListener('abort', onAbort)
}
const onAbort = () => {
cleanup()
reject(new Error(abortErrorMessage))
}
timeoutHandle = setTimeout(() => {
cleanup()
reject(new Error(timeoutErrorMessage))
}, timeoutMs)
options.signal?.addEventListener('abort', onAbort, { once: true })
promise.then(
(value) => {
cleanup()
resolve(value)
},
(error) => {
cleanup()
reject(error)
},
)
})
}
export async function fetchImageBlob(
sourceUrl: string,
options: AdapterFetchOptions = {},
): Promise<Blob | null> {
const normalizedUrl = normalizeRemoteUrl(sourceUrl)
if (!normalizedUrl) {
return null
}
const response = await fetch(normalizedUrl).catch(() => null)
const response = await adapterFetch(normalizedUrl, {}, {
timeoutMs: options.timeoutMs ?? defaultImageFetchTimeoutMs,
signal: options.signal,
}).catch(() => null)
if (!response?.ok) {
return null
}
@@ -98,6 +238,7 @@ export async function fetchImageBlob(sourceUrl: string): Promise<Blob | null> {
export async function uploadHtmlImages(
html: string,
uploader: (sourceUrl: string) => Promise<string | null>,
options: UploadHtmlImagesOptions = {},
): Promise<{ html: string; uploaded: Map<string, string> }> {
const sources = extractImageSources(html)
if (!sources.length) {
@@ -108,13 +249,68 @@ export async function uploadHtmlImages(
}
const uploaded = new Map<string, string>()
for (const source of sources) {
const target = await uploader(source)
const overallSignal = buildAdapterAbortSignal({
signal: options.signal,
timeoutMs: options.timeoutMs ?? defaultHtmlImageUploadTimeoutMs,
})
const concurrency = Math.max(
1,
Math.min(options.concurrency ?? defaultHtmlImageUploadConcurrency, sources.length),
)
let cursor = 0
const uploadOne = async (source: string) => {
if (overallSignal.aborted) {
throw new Error('adapter_aborted')
}
const target = await Promise.race([
uploader(source),
new Promise<null>((resolve) => {
const signal = timeoutSignal(options.imageTimeoutMs ?? defaultImageFetchTimeoutMs)
if (signal.aborted) {
resolve(null)
return
}
signal.addEventListener('abort', () => resolve(null), { once: true })
}),
]).catch(() => null)
if (target) {
uploaded.set(source, target)
}
}
const workers = Array.from({ length: concurrency }, async () => {
// Stop pulling new images once the overall signal aborts so abandoned workers
// don't keep firing fresh network requests after the caller has given up.
while (cursor < sources.length && !overallSignal.aborted) {
const index = cursor
cursor += 1
await uploadOne(sources[index])
}
})
try {
await Promise.race([
Promise.all(workers),
new Promise<never>((_, reject) => {
if (overallSignal.aborted) {
reject(new Error('adapter_aborted'))
return
}
overallSignal.addEventListener('abort', () => reject(new Error('adapter_aborted')), {
once: true,
})
}),
])
} catch {
if (options.signal?.aborted) {
throw new Error('adapter_aborted')
}
// Best-effort image replacement: keep successfully uploaded images and leave
// slow or broken image URLs untouched instead of blocking the whole article.
}
let next = html
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to)
@@ -127,6 +323,7 @@ export async function sessionFetchText(
session: Session,
input: string,
init?: RequestInit,
options: AdapterFetchOptions = {},
): Promise<string> {
const headers = new Headers(init?.headers)
if (!headers.has('user-agent')) {
@@ -139,6 +336,11 @@ export async function sessionFetchText(
const response = await session.fetch(input, {
...init,
headers,
signal: mergeAbortSignals([
init?.signal,
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]),
})
const text = await response.text()
if (!response.ok) {
@@ -151,8 +353,9 @@ export async function sessionFetchJson<T>(
session: Session,
input: string,
init?: RequestInit,
options: AdapterFetchOptions = {},
): Promise<T> {
const text = await sessionFetchText(session, input, init)
const text = await sessionFetchText(session, input, init, options)
if (!text) {
return {} as T
}
@@ -169,7 +372,8 @@ export interface PageFetchInit {
export async function sessionCookieFetchJson<T>(
session: Session,
url: string,
init?: { method?: string; headers?: Record<string, string>; body?: string },
init?: { method?: string; headers?: Record<string, string>; body?: string; signal?: AbortSignal },
options: AdapterFetchOptions = {},
): Promise<T | null> {
let parsedURL: URL
try {
@@ -208,6 +412,11 @@ export async function sessionCookieFetchJson<T>(
method: init?.method ?? 'GET',
headers,
body: init?.body,
signal: mergeAbortSignals([
init?.signal,
options.signal,
timeoutSignal(options.timeoutMs ?? defaultFetchTimeoutMs),
]),
})
} catch {
return null
@@ -238,6 +447,7 @@ export async function pageFetchJson<T>(
webContents: WebContents,
url: string,
init?: PageFetchInit,
options: AdapterFetchOptions = {},
): Promise<T | null> {
if (!webContents || webContents.isDestroyed()) {
return null
@@ -249,8 +459,11 @@ export async function pageFetchJson<T>(
}
const script = `(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), ${JSON.stringify(options.timeoutMs ?? defaultFetchTimeoutMs)});
try {
const response = await fetch(${JSON.stringify(url)}, ${JSON.stringify(effectiveInit)});
const init = ${JSON.stringify(effectiveInit)};
const response = await fetch(${JSON.stringify(url)}, { ...init, signal: controller.signal });
if (!response.ok) {
return JSON.stringify({ __ok: false, status: response.status });
}
@@ -261,12 +474,17 @@ export async function pageFetchJson<T>(
return JSON.stringify({ __ok: true, body: text });
} catch (error) {
return JSON.stringify({ __ok: false, error: String(error && error.message || error) });
} finally {
clearTimeout(timeout);
}
})()`
let serialized: string
try {
serialized = (await webContents.executeJavaScript(script, true)) as string
serialized = (await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
signal: options.signal,
timeoutMs: options.timeoutMs,
})) as string
} catch {
return null
}
@@ -331,7 +549,11 @@ export async function ensureViewLoaded(
return
}
await view.webContents.loadURL(url)
await raceWithAbort(view.webContents.loadURL(url), {
signal,
timeoutMs: 45_000,
timeoutErrorMessage: 'adapter_view_load_timeout',
})
await waitForLoadStop(view, signal)
}
@@ -11,7 +11,9 @@ import {
ensureViewLoaded,
extractImageSources,
normalizeArticleHtml,
raceWithAbort,
sessionFetchJson,
sessionFetchText,
} from './common'
import { countDongchediContentWordCount, prepareDongchediArticleHtml } from './dongchedi-content'
import { cropImageAssetBlob, fetchImageAssetBlob, type ImageAssetBlob } from './media-image'
@@ -276,6 +278,9 @@ async function dongchediPageFetchJson<T>(
},
): Promise<T> {
await ensureDongchediEditorContext(context)
if (context.signal.aborted) {
throw new Error('adapter_aborted')
}
const effectiveInit = {
credentials: 'include' as const,
@@ -284,20 +289,30 @@ async function dongchediPageFetchJson<T>(
const page = context.playwright?.page
if (page && !page.isClosed()) {
const envelope = await page.evaluate(
const envelope = await raceWithAbort(
page.evaluate(
async ({ fetchInput, fetchInit }) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch(fetchInput, fetchInit)
const response = await fetch(fetchInput, {
...fetchInit,
signal: controller.signal,
})
const text = await response.text().catch(() => '')
return { ok: response.ok, status: response.status, body: text }
} catch (error) {
return { ok: false, error: String((error && (error as Error).message) || error) }
} finally {
clearTimeout(timeout)
}
},
{
fetchInput: input,
fetchInit: effectiveInit,
},
),
{ signal: context.signal, timeoutMs: 35_000 },
)
return parseDongchediFetchEnvelope<T>(envelope)
}
@@ -308,17 +323,24 @@ async function dongchediPageFetchJson<T>(
}
const script = `(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 30000);
try {
const init = ${JSON.stringify(effectiveInit)};
const response = await fetch(${JSON.stringify(input)}, init);
const response = await fetch(${JSON.stringify(input)}, { ...init, signal: controller.signal });
const text = await response.text().catch(() => "");
return JSON.stringify({ ok: response.ok, status: response.status, body: text });
} catch (error) {
return JSON.stringify({ ok: false, error: String(error && error.message || error) });
} finally {
clearTimeout(timeout);
}
})()`
const serialized = await webContents.executeJavaScript(script, true)
const serialized = await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
signal: context.signal,
timeoutMs: 35_000,
})
if (typeof serialized !== 'string' || !serialized) {
throw new Error('dongchedi_page_fetch_failed')
}
@@ -378,13 +400,20 @@ function parseDongchediFetchEnvelope<T>(envelope: {
}
async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<string | null> {
if (context.signal.aborted) {
throw new Error('adapter_aborted')
}
const page = context.playwright?.page
if (page && !page.isClosed()) {
const token = await page.evaluate(async (url) => {
const token = await raceWithAbort(page.evaluate(async (url) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 15_000)
try {
const response = await fetch(url, {
method: 'HEAD',
credentials: 'include',
signal: controller.signal,
headers: {
'x-secsdk-csrf-request': '1',
'x-secsdk-csrf-version': '1.2.10',
@@ -393,8 +422,13 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
return response.headers.get('x-ware-csrf-token') || ''
} catch {
return ''
} finally {
clearTimeout(timeout)
}
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`), {
signal: context.signal,
timeoutMs: 18_000,
})
const parts = token.split(',')
return parts.length >= 2 && parts[1] ? parts[1] : null
}
@@ -405,10 +439,13 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
}
const script = `(async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
const response = await fetch(${JSON.stringify(`${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`)}, {
method: "HEAD",
credentials: "include",
signal: controller.signal,
headers: {
"x-secsdk-csrf-request": "1",
"x-secsdk-csrf-version": "1.2.10"
@@ -417,10 +454,15 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
return JSON.stringify({ token: response.headers.get("x-ware-csrf-token") || "" });
} catch {
return JSON.stringify({ token: "" });
} finally {
clearTimeout(timeout);
}
})()`
const serialized = await webContents.executeJavaScript(script, true).catch(() => '')
const serialized = await raceWithAbort(
webContents.executeJavaScript(script, true) as Promise<string>,
{ signal: context.signal, timeoutMs: 18_000 },
).catch(() => '')
if (typeof serialized !== 'string' || !serialized) {
return null
}
@@ -440,12 +482,7 @@ async function imagexFetchText(
input: string,
init?: RequestInit,
): Promise<string> {
const response = await context.session.fetch(input, init)
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
}
return text
return await sessionFetchText(context.session, input, init, { signal: context.signal })
}
async function imagexFetchJson<T>(
@@ -471,6 +508,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<DongchediAc
{
credentials: 'include',
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data
@@ -595,6 +633,7 @@ async function fetchUploadToken(
{
credentials: 'include',
},
{ signal: context.signal },
).catch(() => null)
const token = response?.data?.token
@@ -625,7 +664,7 @@ async function uploadImage(
sourceUrl: string,
ratio?: number,
): Promise<DongchediUploadedImage | null> {
const source = await fetchImageAssetBlob(sourceUrl)
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!source) {
throw new Error(ratio ? 'dongchedi_cover_fetch_failed' : 'dongchedi_image_fetch_failed')
}
@@ -680,11 +719,15 @@ async function uploadImage(
'content-crc32': crc32(new Uint8Array(buffer)),
authorization: storeInfo.Auth,
})
const putResponse = await context.session.fetch(`https://${uploadHost}/${storeUri}`, {
method: 'PUT',
headers: putHeaders,
body: image.blob,
})
const putResponse = await raceWithAbort(
context.session.fetch(`https://${uploadHost}/${storeUri}`, {
method: 'PUT',
headers: putHeaders,
body: image.blob,
signal: context.signal,
}),
{ signal: context.signal, timeoutMs: 30_000 },
)
if (!putResponse.ok) {
const text = await putResponse.text().catch(() => '')
throw new Error(text || `dongchedi_image_tos_upload_failed_${putResponse.status}`)
@@ -729,6 +772,7 @@ async function uploadImage(
credentials: 'include',
body: `img_uris=${storeUri}&img_url_type=2&img_param=noop&img_format=image`,
},
{ signal: context.signal },
).catch(() => null)
const imageUrl = imageUrlResponse?.data?.img_url_map?.[storeUri]?.main_url?.trim() || ''
@@ -765,6 +809,7 @@ async function spiceImage(
imageUrl,
}),
},
{ signal: context.signal },
).catch(() => null)
return response?.data ?? null
@@ -41,9 +41,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -83,8 +83,9 @@ export const jianshuAdapter: PublishAdapter = {
coverAssetUrl: context.article.cover_asset_url,
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob: fetchAssetBlob,
fetchJson: (input, init) =>
sessionFetchJson(context.session, input, init, { signal: context.signal }),
fetchImageBlob: (sourceUrl) => fetchAssetBlob(sourceUrl, context.signal),
reportProgress(stage) {
context.reportProgress(`jianshu.${stage}`)
},
+75 -29
View File
@@ -5,6 +5,7 @@ import type { JsonValue } from '@geo/shared-types'
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
@@ -180,6 +181,7 @@ async function fetchProfile(context: PublishAdapterContext): Promise<JuejinProfi
}),
body: '{}',
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.bui_user ?? null
@@ -233,6 +235,7 @@ function shouldSkipImageSource(source: string): boolean {
async function uploadMarkdownImages(
markdown: string,
uploader: (sourceUrl: string) => Promise<string | null>,
signal?: AbortSignal,
): Promise<string> {
const sources = extractMarkdownImageSources(markdown)
if (!sources.length) {
@@ -241,10 +244,27 @@ async function uploadMarkdownImages(
const uploaded = new Map<string, string>()
for (const source of sources) {
if (signal?.aborted) {
throw new Error('adapter_aborted')
}
if (shouldSkipImageSource(source)) {
continue
}
const target = await uploader(source)
let imageTimeout: ReturnType<typeof setTimeout> | null = null
const target = await Promise.race([
uploader(source),
new Promise<null>((resolve) => {
imageTimeout = setTimeout(() => resolve(null), 10_000)
signal?.addEventListener('abort', () => resolve(null), { once: true })
}),
])
.catch(() => null)
.finally(() => {
if (imageTimeout) {
clearTimeout(imageTimeout)
imageTimeout = null
}
})
if (target) {
uploaded.set(source, target)
}
@@ -386,9 +406,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -504,8 +524,12 @@ function crc32Table(): Uint32Array {
return table
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init)
async function mainFetchJson<T>(
input: string,
init?: RequestInit,
options: { signal?: AbortSignal } = {},
): Promise<T> {
const response = await adapterFetch(input, init, { signal: options.signal })
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
@@ -532,6 +556,7 @@ async function fetchImageXToken(
credentials: 'include',
headers: await juejinHeaders(context),
},
{ signal: context.signal },
)
if (response.err_no && response.err_no !== 0) {
@@ -550,7 +575,7 @@ async function fetchImageXToken(
}
}
async function applyImageUpload(token: JuejinImageXToken) {
async function applyImageUpload(token: JuejinImageXToken, signal?: AbortSignal) {
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`
const signedHeaders = signAWS4({
method: 'GET',
@@ -559,10 +584,14 @@ async function applyImageUpload(token: JuejinImageXToken) {
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
})
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(url, {
method: 'GET',
headers: signedHeaders,
})
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(
url,
{
method: 'GET',
headers: signedHeaders,
},
{ signal },
)
const uploadAddress = response.Result?.UploadAddress
const storeInfo = uploadAddress?.StoreInfos?.[0]
const uploadHost = uploadAddress?.UploadHosts?.[0]
@@ -580,24 +609,33 @@ async function applyImageUpload(token: JuejinImageXToken) {
async function uploadToTOS(
upload: Awaited<ReturnType<typeof applyImageUpload>>,
blob: Blob,
signal?: AbortSignal,
): Promise<void> {
const buffer = Buffer.from(await blob.arrayBuffer())
const response = await fetch(`https://${upload.uploadHost}/${upload.storeUri}`, {
method: 'PUT',
headers: {
authorization: upload.auth,
'content-type': blob.type || 'application/octet-stream',
'content-crc32': crc32(new Uint8Array(buffer)),
const response = await adapterFetch(
`https://${upload.uploadHost}/${upload.storeUri}`,
{
method: 'PUT',
headers: {
authorization: upload.auth,
'content-type': blob.type || 'application/octet-stream',
'content-crc32': crc32(new Uint8Array(buffer)),
},
body: blob,
},
body: blob,
})
{ signal },
)
if (!response.ok) {
const text = await response.text().catch(() => '')
throw new Error(text || `juejin_image_tos_upload_failed_${response.status}`)
}
}
async function commitImageUpload(token: JuejinImageXToken, sessionKey: string): Promise<void> {
async function commitImageUpload(
token: JuejinImageXToken,
sessionKey: string,
signal?: AbortSignal,
): Promise<void> {
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`
const signedHeaders = signAWS4({
method: 'POST',
@@ -606,13 +644,17 @@ async function commitImageUpload(token: JuejinImageXToken, sessionKey: string):
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
})
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(url, {
method: 'POST',
headers: {
...signedHeaders,
'content-length': '0',
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(
url,
{
method: 'POST',
headers: {
...signedHeaders,
'content-length': '0',
},
},
})
{ signal },
)
if (!response.Result) {
throw new Error('juejin_image_commit_upload_failed')
}
@@ -631,6 +673,7 @@ async function resolveImageURL(
credentials: 'include',
headers: await juejinHeaders(context),
},
{ signal: context.signal },
)
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || 'juejin_image_url_failed')
@@ -647,15 +690,15 @@ async function uploadImage(
uuid: string,
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl)
const blob = await fetchAssetBlob(sourceUrl, context.signal)
if (!blob || !blob.type.startsWith('image/')) {
return null
}
const token = await fetchImageXToken(context, uuid)
const upload = await applyImageUpload(token)
await uploadToTOS(upload, blob)
await commitImageUpload(token, upload.sessionKey)
const upload = await applyImageUpload(token, context.signal)
await uploadToTOS(upload, blob, context.signal)
await commitImageUpload(token, upload.sessionKey, context.signal)
return await resolveImageURL(context, uuid, upload.storeUri)
}
@@ -697,6 +740,7 @@ async function createDraft(
pics: [],
}),
},
{ signal: context.signal },
)
if (response.err_no && response.err_no !== 0) {
@@ -892,6 +936,7 @@ async function findPublishedArticle(
page_no: 1,
}),
},
{ signal: context.signal },
).catch(() => null)
const article = extractArticleFromListResponse(response, draftId)
@@ -1012,6 +1057,7 @@ export const juejinAdapter: PublishAdapter = {
markdown = await uploadMarkdownImages(
markdown,
async (sourceUrl) => await uploadImage(context, uuid, sourceUrl).catch(() => null),
context.signal,
)
context.reportProgress('juejin.create_draft')
@@ -3,6 +3,7 @@ import { Buffer } from 'node:buffer'
import { nativeImage } from 'electron'
import { fetchDesktopApiURL, resolveDesktopApiURL } from '../transport/api-client'
import { adapterFetch, mergeAbortSignals, timeoutSignal, type AdapterFetchOptions } from './common'
export interface ImageAssetBlob {
blob: Blob
@@ -97,10 +98,20 @@ function fileNameForImageType(sourceType: string): string {
return 'image.png'
}
export async function fetchImageAssetBlob(sourceUrl: string): Promise<ImageAssetBlob | null> {
export async function fetchImageAssetBlob(
sourceUrl: string,
options: AdapterFetchOptions = {},
): Promise<ImageAssetBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await (
shouldUseDesktopAssetFetch(candidate) ? fetchDesktopApiURL(candidate) : fetch(candidate)
shouldUseDesktopAssetFetch(candidate)
? fetchDesktopApiURL(candidate, {
signal: mergeAbortSignals([
options.signal,
timeoutSignal(options.timeoutMs ?? 10_000),
]),
})
: adapterFetch(candidate, {}, { timeoutMs: options.timeoutMs ?? 10_000, signal: options.signal })
).catch(() => null)
if (!response?.ok) {
continue
+44 -28
View File
@@ -9,6 +9,7 @@ import {
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
@@ -171,13 +172,18 @@ async function fetchBasicInfo(context: PublishAdapterContext): Promise<QiehaoCpI
credentials: 'include',
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.cpInfo ?? null
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init)
async function mainFetchJson<T>(
input: string,
init?: RequestInit,
options: { signal?: AbortSignal } = {},
): Promise<T> {
const response = await adapterFetch(input, init, { signal: options.signal })
const text = await response.text()
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`)
@@ -224,9 +230,9 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
async function fetchAssetBlob(sourceUrl: string, signal?: AbortSignal): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate)
const blob = await fetchImageBlob(candidate, { signal })
if (blob) {
return blob
}
@@ -239,7 +245,7 @@ async function uploadImage(
sourceUrl: string,
includeCoverMeta: boolean,
): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl)
const blob = await fetchAssetBlob(sourceUrl, context.signal)
if (!blob) {
throw new Error(includeCoverMeta ? 'qiehao_cover_fetch_failed' : 'qiehao_image_fetch_failed')
}
@@ -259,6 +265,7 @@ async function uploadImage(
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {}, 'image.om.qq.com'),
body: form,
},
{ signal: context.signal },
).catch(
(error): QiehaoUploadStepOneResponse => ({
msg: error instanceof Error ? error.message : 'qiehao_image_upload_failed',
@@ -306,6 +313,7 @@ async function uploadImage(
relogin: 1,
}),
},
{ signal: context.signal },
).catch(
(error): QiehaoUploadStepTwoResponse => ({
msg: error instanceof Error ? error.message : 'qiehao_cover_upload_failed',
@@ -341,6 +349,7 @@ async function findArticleUrl(
credentials: 'include',
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN),
},
{ signal: context.signal },
).catch(() => null)
const article = list?.data?.articles?.find(
@@ -502,8 +511,10 @@ export const qiehaoAdapter: PublishAdapter = {
}
context.reportProgress('qiehao.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, sourceUrl, false),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, sourceUrl, false),
{ signal: context.signal },
)
const coverImages: string[] = []
@@ -524,27 +535,32 @@ export const qiehaoAdapter: PublishAdapter = {
: `${QIEHAO_ORIGIN}/marticlepublish/omSave`
context.reportProgress('qiehao.submit')
const response = await sessionFetchJson<QiehaoPublishResponse>(context.session, endpoint, {
method: 'POST',
credentials: 'include',
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {
'content-type': 'application/json',
}),
body: JSON.stringify({
title: context.article.title?.trim() || '未命名文章',
content: processed.html,
imgurl_ext: JSON.stringify(coverImages),
cover_type: '1',
imgurlsrc: 'custom',
orignal: 0,
user_original: 0,
mediaId,
needpub: 1,
type: 0,
relogin: 1,
resource_aigc_mark_info: JSON.stringify(resourceMarkInfo),
}),
}).catch(
const response = await sessionFetchJson<QiehaoPublishResponse>(
context.session,
endpoint,
{
method: 'POST',
credentials: 'include',
headers: await qiehaoHeaders(context, QIEHAO_ORIGIN, {
'content-type': 'application/json',
}),
body: JSON.stringify({
title: context.article.title?.trim() || '未命名文章',
content: processed.html,
imgurl_ext: JSON.stringify(coverImages),
cover_type: '1',
imgurlsrc: 'custom',
orignal: 0,
user_original: 0,
mediaId,
needpub: 1,
type: 0,
relogin: 1,
resource_aigc_mark_info: JSON.stringify(resourceMarkInfo),
}),
},
{ signal: context.signal },
).catch(
(requestError): QiehaoPublishResponse => ({
code: -1,
msg: requestError instanceof Error ? requestError.message : 'qiehao_publish_failed',
+50 -25
View File
@@ -8,8 +8,10 @@ import { PUBLISH_IMAGE_FETCH_FAILED_MESSAGE } from '../../shared/publisher-error
import { resolveDesktopApiURL } from '../transport/api-client'
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
adapterFetch,
extractImageSources,
normalizeArticleHtml,
raceWithAbort,
sessionCookieHeader,
sessionCookieValue,
sessionFetchJson,
@@ -203,6 +205,7 @@ async function fetchCurrentAccount(context: PublishAdapterContext): Promise<Smzd
referer: `${SMZDM_ZHIYOU_ORIGIN}/user`,
},
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data ?? response
@@ -257,10 +260,13 @@ async function getArticleId(context: PublishAdapterContext): Promise<string> {
throw new Error('smzdm_playwright_page_missing')
}
await page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
})
await raceWithAbort(
page.goto(SMZDM_TOU_GAO_URL, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined)
const href = await page
@@ -320,9 +326,14 @@ function buildAssetURLCandidates(sourceUrl: string): string[] {
return candidates
}
async function fetchSmzdmImageBlob(sourceUrl: string): Promise<SmzdmImageBlob | null> {
async function fetchSmzdmImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<SmzdmImageBlob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const response = await fetch(candidate).catch(() => null)
const response = await adapterFetch(candidate, {}, { signal, timeoutMs: 10_000 }).catch(
() => null,
)
if (!response?.ok) {
continue
}
@@ -386,7 +397,7 @@ async function uploadImage(
articleId: string,
cropCover: boolean,
): Promise<SmzdmUploadedImage | null> {
const image = await fetchSmzdmImageBlob(sourceUrl)
const image = await fetchSmzdmImageBlob(sourceUrl, context.signal)
if (!image) {
throw new Error(cropCover ? 'smzdm_cover_fetch_failed' : 'smzdm_image_fetch_failed')
}
@@ -406,6 +417,7 @@ async function uploadImage(
headers: await smzdmHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): SmzdmImageUploadResponse => ({
error_code: -1,
@@ -425,17 +437,22 @@ async function uploadImage(
const uploadedId = stringId(uploaded.data.id)
if (uploadedId) {
void sessionFetchJson(context.session, `${SMZDM_ORIGIN}/api/editor/image_add_time/record`, {
method: 'POST',
credentials: 'include',
headers: await smzdmHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
}).catch(() => null)
void sessionFetchJson(
context.session,
`${SMZDM_ORIGIN}/api/editor/image_add_time/record`,
{
method: 'POST',
credentials: 'include',
headers: await smzdmHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body: new URLSearchParams({
article_id: articleId,
ids: uploadedId,
}),
},
{ signal: context.signal },
).catch(() => null)
}
if (!cropCover) {
@@ -460,6 +477,7 @@ async function uploadImage(
pic_url: uploaded.data.url,
}),
},
{ signal: context.signal },
).catch(() => null)
const crop = getSmzdmCoverCropRect(image.width, image.height)
@@ -497,6 +515,7 @@ async function uploadImage(
headers: await smzdmHeaders(context),
body: cropForm,
},
{ signal: context.signal },
).catch(
(error): SmzdmCropResponse => ({
error_code: -1,
@@ -570,14 +589,17 @@ async function calculateEditorSignature(
throw new Error('smzdm_playwright_page_missing')
}
await page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
})
await raceWithAbort(
page.goto(`${SMZDM_ORIGIN}/edit/${encodeURIComponent(articleId)}`, {
waitUntil: 'domcontentloaded',
timeout: 45_000,
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => undefined)
const textCount = await page
.evaluate((content) => {
const textCount = await raceWithAbort(
page.evaluate((content) => {
const editorNode = document.querySelector('.ProseMirror[contenteditable]') as
| (HTMLElement & {
editor?: {
@@ -600,7 +622,9 @@ async function calculateEditorSignature(
},
})
return editor.commands.getTextCount()
}, html)
}, html),
{ signal: context.signal, timeoutMs: 20_000 },
)
.catch(() => null)
const normalizedCount =
@@ -904,6 +928,7 @@ export const smzdmAdapter: PublishAdapter = {
}),
),
},
{ signal: context.signal },
).catch(
(requestError): SmzdmSubmitResponse => ({
error_code: -1,
@@ -141,6 +141,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount
credentials: 'include',
headers: await sohuHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const account = accountFromRaw(registerInfo?.data?.account)
@@ -157,6 +158,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<SohuAccount
credentials: 'include',
headers: await sohuHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
for (const group of list?.data?.data ?? []) {
@@ -176,7 +178,7 @@ async function uploadImage(
accountId: string,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
@@ -188,14 +190,19 @@ async function uploadImage(
const url = new URL(`${SOHU_ORIGIN}/commons/front/outerUpload/image/file`)
url.searchParams.set('accountId', accountId)
const response = await sessionFetchJson<SohuUploadResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'sp-cm': await sohuSpCm(context),
}),
body: form,
}).catch(() => null)
const response = await sessionFetchJson<SohuUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'sp-cm': await sohuSpCm(context),
}),
body: form,
},
{ signal: context.signal },
).catch(() => null)
return response?.url?.trim() || null
}
@@ -225,16 +232,21 @@ async function submitArticle(
infoResource: 0,
}
const response = await sessionFetchJson<SohuSubmitResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'content-type': 'application/json',
'dv-id': generateDeviceId(),
'sp-cm': await sohuSpCm(context),
}),
body: JSON.stringify(body),
}).catch(
const response = await sessionFetchJson<SohuSubmitResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await sohuHeaders(context, {
'content-type': 'application/json',
'dv-id': generateDeviceId(),
'sp-cm': await sohuSpCm(context),
}),
body: JSON.stringify(body),
},
{ signal: context.signal },
).catch(
(error): SohuSubmitResponse => ({
code: -1,
msg: error instanceof Error ? error.message : 'sohuhao_submit_failed',
@@ -355,8 +367,10 @@ export const sohuhaoAdapter: PublishAdapter = {
}
context.reportProgress('sohuhao.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, account.id, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, account.id, sourceUrl),
{ signal: context.signal },
)
const publishType = resolvePublishType(payload)
@@ -60,7 +60,7 @@ describe('toutiao adapter', () => {
expect(bodyBlob?.type).toBe('image/png')
expect(coverBlob?.type).toBe('image/png')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token')
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/body-token', expect.anything())
expect(fetchImageAssetBlob).toHaveBeenCalledWith('/api/public/assets/cover-token', expect.anything())
})
})
@@ -5,8 +5,11 @@ import type { PublishAdapter } from './base'
import { normalizeArticleHtml, sessionFetchJson, uploadHtmlImages } from './common'
import { fetchImageAssetBlob } from './media-image'
async function fetchToutiaoImageBlob(sourceUrl: string): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl))?.blob ?? null
async function fetchToutiaoImageBlob(
sourceUrl: string,
signal?: AbortSignal,
): Promise<Blob | null> {
return (await fetchImageAssetBlob(sourceUrl, { signal }))?.blob ?? null
}
function buildResultPayload(
@@ -40,9 +43,11 @@ export const toutiaoAdapter: PublishAdapter = {
publishType: 'publish',
},
{
fetchJson: (input, init) => sessionFetchJson(context.session, input, init),
fetchImageBlob: fetchToutiaoImageBlob,
uploadHtmlImages,
fetchJson: (input, init) =>
sessionFetchJson(context.session, input, init, { signal: context.signal }),
fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal),
uploadHtmlImages: (sourceHtml, uploader) =>
uploadHtmlImages(sourceHtml, uploader, { signal: context.signal }),
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`)
},
@@ -9,6 +9,7 @@ import {
import type { PublishAdapter, PublishAdapterContext } from './base'
import {
extractImageSources,
raceWithAbort,
sessionCookieHeader,
sessionFetchJson,
uploadHtmlImages,
@@ -180,6 +181,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<WangyiAccou
credentials: 'include',
headers: await wangyiHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const media = response?.data?.mediaInfo
@@ -213,6 +215,7 @@ async function fetchPublishDetail(
credentials: 'include',
headers: await wangyiHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
if (String(response?.code) !== '1' || !response?.data) {
@@ -235,12 +238,13 @@ async function getPublishToken(context: PublishAdapterContext): Promise<string>
}
for (let attempt = 0; attempt < 2; attempt += 1) {
await page
.goto(WANGYI_HOME_URL, {
await raceWithAbort(
page.goto(WANGYI_HOME_URL, {
waitUntil: attempt === 0 ? 'domcontentloaded' : 'load',
timeout: 20_000,
})
.catch(() => null)
}),
{ signal: context.signal, timeoutMs: 20_000 },
).catch(() => null)
await page.waitForLoadState('networkidle', { timeout: 3_000 }).catch(() => null)
if (attempt > 0) {
await sleep(800, context.signal)
@@ -445,7 +449,7 @@ async function uploadImage(
account: WangyiAccount,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
@@ -460,15 +464,20 @@ async function uploadImage(
url.searchParams.set('wemediaId', account.wemediaId)
url.searchParams.set('realUserId', account.realUserId)
const response = await sessionFetchJson<WangyiUploadResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await wangyiHeaders(context, {
'x-b3-sampled': '1',
'x-b3-spanid': '0',
}),
body: form,
}).catch(() => null)
const response = await sessionFetchJson<WangyiUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await wangyiHeaders(context, {
'x-b3-sampled': '1',
'x-b3-spanid': '0',
}),
body: form,
},
{ signal: context.signal },
).catch(() => null)
return response?.data?.url?.trim() || null
}
@@ -544,16 +553,21 @@ async function saveDraftWithSession(
url: string,
body: URLSearchParams,
): Promise<WangyiDraftResponse> {
return await sessionFetchJson<WangyiDraftResponse>(context.session, url, {
method: 'POST',
credentials: 'include',
headers: await wangyiHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
'x-b3-sampled': '1',
'x-b3-spanid': '0',
}),
body,
}).catch(
return await sessionFetchJson<WangyiDraftResponse>(
context.session,
url,
{
method: 'POST',
credentials: 'include',
headers: await wangyiHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
'x-b3-sampled': '1',
'x-b3-spanid': '0',
}),
body,
},
{ signal: context.signal },
).catch(
(error): WangyiDraftResponse => ({
code: -1,
message: error instanceof Error ? error.message : 'wangyihao_draft_save_failed',
@@ -571,24 +585,34 @@ async function saveDraftWithPage(
return null
}
const serialized = await page.evaluate(
async ({ requestUrl, requestBody }) => {
const response = await fetch(requestUrl, {
method: 'POST',
credentials: 'include',
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-b3-sampled': '1',
'x-b3-spanid': '0',
},
body: requestBody,
})
return await response.text()
},
{
requestUrl: url,
requestBody: body.toString(),
},
const serialized = await raceWithAbort(
page.evaluate(
async ({ requestUrl, requestBody }) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
try {
const response = await fetch(requestUrl, {
method: 'POST',
credentials: 'include',
signal: controller.signal,
headers: {
'content-type': 'application/x-www-form-urlencoded',
'x-b3-sampled': '1',
'x-b3-spanid': '0',
},
body: requestBody,
})
return await response.text()
} finally {
clearTimeout(timeout)
}
},
{
requestUrl: url,
requestBody: body.toString(),
},
),
{ signal: context.signal, timeoutMs: 35_000 },
)
if (!serialized) {
@@ -835,9 +859,12 @@ async function publishDraft(context: PublishAdapterContext, draftId: string): Pr
}
const draftUrl = `${WANGYI_CONSOLE_ORIGIN}/subscribe_v4/index.html#/article-publish/${encodeURIComponent(draftId)}?option=editDraft`
await page.goto(draftUrl, {
waitUntil: 'domcontentloaded',
})
await raceWithAbort(
page.goto(draftUrl, {
waitUntil: 'domcontentloaded',
}),
{ signal: context.signal, timeoutMs: 45_000 },
)
await page.waitForLoadState('networkidle', { timeout: 8_000 }).catch(() => null)
await waitForWangyiEditorReady(page)
@@ -997,8 +1024,10 @@ export const wangyihaoAdapter: PublishAdapter = {
}
context.reportProgress('wangyihao.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, account, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, account, sourceUrl),
{ signal: context.signal },
)
context.reportProgress('wangyihao.save_draft')
@@ -9,6 +9,7 @@ import type { PublishAdapter, PublishAdapterContext } from './base'
import {
normalizeArticleHtml,
normalizeRemoteUrl,
raceWithAbort,
sessionFetchJson,
sessionFetchText,
uploadHtmlImages,
@@ -198,12 +199,17 @@ function parseMeta(html: string): WeixinMeta | null {
}
async function fetchMeta(context: PublishAdapterContext): Promise<WeixinMeta | null> {
const html = await sessionFetchText(context.session, WEIXIN_HOME_URL, {
credentials: 'include',
headers: await weixinHeaders(context, {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}),
}).catch(() => '')
const html = await sessionFetchText(
context.session,
WEIXIN_HOME_URL,
{
credentials: 'include',
headers: await weixinHeaders(context, {
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
}),
},
{ signal: context.signal },
).catch(() => '')
return html ? parseMeta(html) : null
}
@@ -268,7 +274,7 @@ async function uploadImage(
return normalized
}
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
return null
}
@@ -297,12 +303,17 @@ async function uploadImage(
url.searchParams.set('seq', String(Date.now()))
url.searchParams.set('t', String(Math.random()))
const response = await sessionFetchJson<WeixinUploadResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context),
body: form,
}).catch(() => null)
const response = await sessionFetchJson<WeixinUploadResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(() => null)
if (response?.base_resp?.err_msg && response.base_resp.err_msg !== 'ok') {
const message = response.base_resp.err_msg
@@ -402,7 +413,7 @@ async function uploadCover(
meta: WeixinMeta,
sourceUrl: string,
): Promise<WeixinCover | null> {
const source = await fetchImageAssetBlob(sourceUrl)
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!source) {
return null
}
@@ -412,7 +423,7 @@ async function uploadCover(
return null
}
const cdnImage = await fetchImageAssetBlob(cdnUrl).catch(() => null)
const cdnImage = await fetchImageAssetBlob(cdnUrl, { signal: context.signal }).catch(() => null)
const width = cdnImage?.width || source.width
const height = cdnImage?.height || source.height
const rect235 = cropPercent(width, height, 2.35)
@@ -426,14 +437,19 @@ async function uploadCover(
const url = new URL(`${WEIXIN_ORIGIN}/cgi-bin/cropimage`)
url.searchParams.set('action', 'crop_multi')
const response = await sessionFetchJson<WeixinCropResponse>(context.session, url.toString(), {
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body,
}).catch(() => null)
const response = await sessionFetchJson<WeixinCropResponse>(
context.session,
url.toString(),
{
method: 'POST',
credentials: 'include',
headers: await weixinHeaders(context, {
'content-type': 'application/x-www-form-urlencoded',
}),
body,
},
{ signal: context.signal },
).catch(() => null)
return response ? coverFromCropResponse(cdnUrl, body, response) : null
}
@@ -1014,10 +1030,12 @@ function isWeixinPublishNetworkResponse(response: PlaywrightResponse): boolean {
async function waitForWeixinPublishNetworkResponse(
page: PlaywrightPage,
timeoutMs = WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS,
signal?: AbortSignal,
): Promise<WeixinOperateResponse | null> {
const response = await page
.waitForResponse(isWeixinPublishNetworkResponse, { timeout: timeoutMs })
.catch(() => null)
const response = await raceWithAbort(
page.waitForResponse(isWeixinPublishNetworkResponse, { timeout: timeoutMs }),
{ signal, timeoutMs: timeoutMs + 500 },
).catch(() => null)
if (!response) {
return null
}
@@ -1028,21 +1046,23 @@ async function waitForWeixinPublishNetworkResponse(
return parseWeixinOperateResponseText(text)
}
async function waitForWeixinEditorReady(page: PlaywrightPage): Promise<void> {
await page
.waitForLoadState('domcontentloaded', { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS })
.catch(() => null)
async function waitForWeixinEditorReady(page: PlaywrightPage, signal?: AbortSignal): Promise<void> {
await raceWithAbort(
page.waitForLoadState('domcontentloaded', { timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS }),
{ signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS + 500 },
).catch(() => null)
await page.waitForLoadState('networkidle', { timeout: 10_000 }).catch(() => null)
await page
.waitForFunction(
await raceWithAbort(
page.waitForFunction(
() => {
const text = document.body?.innerText || ''
return /发表|发布|草稿|图文|登录/.test(text)
},
undefined,
{ timeout: WEIXIN_EDITOR_READY_TIMEOUT_MS },
)
.catch(() => null)
),
{ signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS + 500 },
).catch(() => null)
}
async function readWeixinPageMessage(
@@ -1606,7 +1626,11 @@ async function completeWeixinPublishConfirmation(
}
await waitForWeixinDelay(context.signal, 300)
const responsePromise = waitForWeixinPublishNetworkResponse(page)
const responsePromise = waitForWeixinPublishNetworkResponse(
page,
WEIXIN_PUBLISH_RESPONSE_TIMEOUT_MS,
context.signal,
)
const confirm = await clickWeixinVisibleAction(page, ['发表', '确认发表', '发布', '确认发布'], {
scope: 'dialog',
timeoutMs: 8_000,
@@ -1831,10 +1855,13 @@ async function publishDraftFromDraftManagePage(
throw new Error('weixin_gzh_playwright_page_missing')
}
await page.goto(buildDraftManageUrl(meta.token), {
waitUntil: 'domcontentloaded',
})
await waitForWeixinEditorReady(page)
await raceWithAbort(
page.goto(buildDraftManageUrl(meta.token), {
waitUntil: 'domcontentloaded',
}),
{ signal: context.signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS },
)
await waitForWeixinEditorReady(page, context.signal)
const initialMessage = await readWeixinPageMessage(page)
if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) {
@@ -1844,6 +1871,7 @@ async function publishDraftFromDraftManagePage(
const directResponsePromise = waitForWeixinPublishNetworkResponse(
page,
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
context.signal,
)
const popupPromise = page.waitForEvent('popup', { timeout: 8_000 }).catch(() => null)
const openDialog = await clickWeixinDraftCardPublish(page, articleId, title, context.signal)
@@ -1858,7 +1886,7 @@ async function publishDraftFromDraftManagePage(
if (popup && !popup.isClosed()) {
popup.setDefaultTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS)
popup.setDefaultNavigationTimeout(WEIXIN_EDITOR_READY_TIMEOUT_MS)
await waitForWeixinEditorReady(popup)
await waitForWeixinEditorReady(popup, context.signal)
const popupMessage = await readWeixinPageMessage(popup)
if (/loginpage|\/cgi-bin\/login/i.test(popup.url()) || /登录超时|重新登录/.test(popupMessage)) {
throw new Error('weixin_gzh_not_logged_in')
@@ -1866,7 +1894,11 @@ async function publishDraftFromDraftManagePage(
const popupResponse = await completeWeixinPublishConfirmation(
context,
popup,
waitForWeixinPublishNetworkResponse(popup, WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000),
waitForWeixinPublishNetworkResponse(
popup,
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
context.signal,
),
)
await popup.close().catch(() => undefined)
return popupResponse
@@ -1885,10 +1917,13 @@ async function publishDraftFromEditorPage(
throw new Error('weixin_gzh_playwright_page_missing')
}
await page.goto(buildDraftEditUrl(articleId, meta.token), {
waitUntil: 'domcontentloaded',
})
await waitForWeixinEditorReady(page)
await raceWithAbort(
page.goto(buildDraftEditUrl(articleId, meta.token), {
waitUntil: 'domcontentloaded',
}),
{ signal: context.signal, timeoutMs: WEIXIN_EDITOR_READY_TIMEOUT_MS },
)
await waitForWeixinEditorReady(page, context.signal)
const initialMessage = await readWeixinPageMessage(page)
if (/loginpage|\/cgi-bin\/login/i.test(page.url()) || /登录超时|重新登录/.test(initialMessage)) {
@@ -1898,6 +1933,7 @@ async function publishDraftFromEditorPage(
const directResponsePromise = waitForWeixinPublishNetworkResponse(
page,
WEIXIN_PUBLISH_DIALOG_TIMEOUT_MS + 4_000,
context.signal,
)
const openDialog = await clickWeixinVisibleAction(page, ['发表', '发布'], {
timeoutMs: 12_000,
@@ -2091,6 +2127,7 @@ export const weixinGzhAdapter: PublishAdapter = {
context.reportProgress('weixin_gzh.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, meta, sourceUrl),
{ signal: context.signal },
)
const content = wrapWeixinContent(processed.html)
@@ -2112,6 +2149,7 @@ export const weixinGzhAdapter: PublishAdapter = {
}),
body: form,
},
{ signal: context.signal },
).catch(
(error): WeixinOperateResponse => ({
ret: -1,
@@ -6,6 +6,7 @@ import { marked } from 'marked'
import type { PublishAdapter } from './base'
import {
adapterFetch,
extractImageSources,
fetchImageBlob,
normalizeRemoteUrl,
@@ -56,7 +57,7 @@ async function zhihuFetch<T>(
return await sessionFetchJson<T>(context.session, input, {
...init,
headers: buildHeaders(init?.headers),
})
}, { signal: context.signal })
}
function markdownToHtml(markdown: string): string {
@@ -134,7 +135,7 @@ async function uploadBinaryImage(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl)
const blob = await fetchImageBlob(sourceUrl, { signal: context.signal })
if (!blob || !blob.type.startsWith('image/')) {
return null
}
@@ -159,7 +160,7 @@ async function uploadBinaryImage(
const stringToSign = `PUT\n\n${blob.type}\n${ossDate}\nx-oss-date:${ossDate}\nx-oss-security-token:${token.upload_token.access_token}\nx-oss-user-agent:${ossUserAgent}\n/zhihu-pics/v2-${imageHash}`
const signature = hmacSha1Base64(token.upload_token.access_key || '', stringToSign)
const uploadResponse = await fetch(
const uploadResponse = await adapterFetch(
`https://zhihu-pics-upload.zhimg.com/${token.upload_file.object_key}`,
{
method: 'PUT',
@@ -173,6 +174,7 @@ async function uploadBinaryImage(
},
body: blob,
},
{ signal: context.signal, timeoutMs: 30_000 },
)
if (!uploadResponse.ok) {
throw new Error(`zhihu_oss_upload_failed_${uploadResponse.status}`)
+17 -6
View File
@@ -168,6 +168,7 @@ async function fetchAccount(context: PublishAdapterContext): Promise<ZolAccount
credentials: 'include',
headers: await zolHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const user = response?.data
@@ -217,6 +218,7 @@ async function createDraftId(context: PublishAdapterContext, form: FormData): Pr
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolDraftResponse => ({
errcode: -1,
@@ -254,6 +256,7 @@ async function uploadCoverVariant(
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolImageUploadResponse => ({
errcode: -1,
@@ -281,7 +284,7 @@ async function uploadCover(
context: PublishAdapterContext,
sourceUrl: string,
): Promise<ZolCoverImage[] | null> {
const source = await fetchImageAssetBlob(sourceUrl)
const source = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!source) {
throw new Error('zol_cover_fetch_failed')
}
@@ -327,6 +330,7 @@ async function uploadContentImageByNetworkPic(
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(() => null)
const uploaded = zolUploadedImageUrl(response)
@@ -345,7 +349,7 @@ async function uploadContentImageByFile(
context: PublishAdapterContext,
sourceUrl: string,
): Promise<string | null> {
const image = await fetchImageAssetBlob(sourceUrl)
const image = await fetchImageAssetBlob(sourceUrl, { signal: context.signal })
if (!image) {
throw new Error(
`zol_content_image_upload_failed:${sourceUrl.slice(0, 160)}:source_fetch_failed`,
@@ -367,6 +371,7 @@ async function uploadContentImageByFile(
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolImageUploadResponse => ({
errcode: -1,
@@ -421,10 +426,15 @@ async function applyRecommendSubjects(
url.searchParams.set('keyword', '')
url.searchParams.set('page', '1')
const response = await sessionFetchJson<ZolSubjectListResponse>(context.session, url.toString(), {
credentials: 'include',
headers: await zolHeaders(context),
}).catch(() => null)
const response = await sessionFetchJson<ZolSubjectListResponse>(
context.session,
url.toString(),
{
credentials: 'include',
headers: await zolHeaders(context),
},
{ signal: context.signal },
).catch(() => null)
const subjects = (response?.data?.list ?? [])
.filter((item) => item.subjectId != null && item.subjectName?.trim())
@@ -598,6 +608,7 @@ export const zolAdapter: PublishAdapter = {
headers: await zolHeaders(context),
body: form,
},
{ signal: context.signal },
).catch(
(error): ZolPublishResponse => ({
errcode: -1,