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,
|
||||
|
||||
Reference in New Issue
Block a user