fix(publish): require submit marker before platform write
Desktop Client Build / Resolve Build Metadata (push) Successful in 20s
Frontend CI / Frontend (push) Successful in 2m57s
Desktop Client Build / Build Desktop Client (push) Successful in 25m11s
Desktop Client Build / Publish Client Artifacts to NAS (push) Successful in 34s
Backend CI / Backend (push) Successful in 50m56s

This commit is contained in:
2026-05-30 22:29:28 +08:00
parent dbd2a1c9f6
commit 8ea4a2ffff
20 changed files with 227 additions and 155 deletions
@@ -343,10 +343,14 @@ async function processContentImages(
html: string,
appId: string,
): Promise<string> {
const processed = await uploadHtmlImages(html, async (sourceUrl) => {
await waitForHumanPace(context.signal)
return await uploadImage(context, sourceUrl, appId, false)
}, { signal: context.signal })
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => {
await waitForHumanPace(context.signal)
return await uploadImage(context, sourceUrl, appId, false)
},
{ signal: context.signal },
)
return processed.html
}
@@ -562,7 +566,7 @@ export const baijiahaoAdapter: PublishAdapter = {
throw new Error('baijiahao_cover_upload_failed')
}
context.reportProgress('baijiahao.submit')
await context.reportProgress('baijiahao.submit')
await waitForHumanPace(context.signal)
const response: BaijiahaoPublishResponse = await sessionFetchJson<BaijiahaoPublishResponse>(
context.session,
@@ -17,7 +17,7 @@ export interface AdapterContext {
} | null
signal: AbortSignal
phase: AdapterTaskPhase
reportProgress(stage: string): void
reportProgress(stage: string): void | Promise<void>
}
export interface PublishAdapterContext extends AdapterContext {
@@ -72,9 +72,7 @@ type BilibiliEditorResult = {
articleId?: string
}
type BilibiliSubmitOutcome =
| { ok: true; articleId: string }
| { ok: false; message: string }
type BilibiliSubmitOutcome = { ok: true; articleId: string } | { ok: false; message: string }
const mixinKeyEncTab = [
46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49, 33, 9, 42, 19, 29, 28,
@@ -319,10 +317,7 @@ async function fillAndSubmitEditor(
await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined)
const fillResult = await page.evaluate(
async ({
title: nextTitle,
html: nextHtml,
}): Promise<BilibiliEditorResult> => {
async ({ title: nextTitle, html: nextHtml }): Promise<BilibiliEditorResult> => {
const sleep = (ms: number) =>
new Promise((resolve) => {
setTimeout(resolve, ms)
@@ -740,8 +735,7 @@ export const bilibiliAdapter: PublishAdapter = {
const coverAssetUrl = context.article.cover_asset_url?.trim() || ''
if (coverAssetUrl) {
const hasExistingCover =
extractImageSources(html)[0] === coverAssetUrl
const hasExistingCover = extractImageSources(html)[0] === coverAssetUrl
if (!hasExistingCover) {
html = `<img src="${coverAssetUrl}" />${html}`
}
@@ -752,7 +746,7 @@ export const bilibiliAdapter: PublishAdapter = {
const title = context.article.title?.trim() || '未命名文章'
const startedAt = Date.now()
context.reportProgress('bilibili.submit_editor')
await context.reportProgress('bilibili.submit_editor')
const editorResult = await fillAndSubmitEditor(context, title, processedHtml)
if (!editorResult.success) {
throw new Error(editorResult.message || 'bilibili_publish_failed')
@@ -291,26 +291,26 @@ async function dongchediPageFetchJson<T>(
if (page && !page.isClosed()) {
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,
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,
},
async ({ fetchInput, fetchInit }) => {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), 30_000)
try {
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 },
)
@@ -337,10 +337,13 @@ async function dongchediPageFetchJson<T>(
}
})()`
const serialized = await raceWithAbort(webContents.executeJavaScript(script, true) as Promise<string>, {
signal: context.signal,
timeoutMs: 35_000,
})
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')
}
@@ -406,29 +409,32 @@ async function fetchDongchediCsrfToken(context: PublishAdapterContext): Promise<
const page = context.playwright?.page
if (page && !page.isClosed()) {
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',
},
})
return response.headers.get('x-ware-csrf-token') || ''
} catch {
return ''
} finally {
clearTimeout(timeout)
}
}, `${DONGCHEDI_ORIGIN}/motor/mp_index/api/user/info?`), {
signal: context.signal,
timeoutMs: 18_000,
})
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',
},
})
return response.headers.get('x-ware-csrf-token') || ''
} catch {
return ''
} finally {
clearTimeout(timeout)
}
}, `${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
}
@@ -1126,7 +1132,7 @@ export const dongchediAdapter: PublishAdapter = {
const contentImageSources = extractImageSources(html)
const content = await processDongchediContentImages(context, html, contentImageSources)
context.reportProgress('dongchedi.save_draft')
await context.reportProgress('dongchedi.save_draft')
const draftBody = buildPublishBody(title, content, verticalCover, feedCover, {
save: 0,
})
@@ -1163,7 +1169,7 @@ export const dongchediAdapter: PublishAdapter = {
)
}
context.reportProgress('dongchedi.submit')
await context.reportProgress('dongchedi.submit')
const publishBody = buildPublishBody(title, content, verticalCover, feedCover, {
save: 1,
pgcId: draftArticleId,
@@ -86,8 +86,8 @@ export const jianshuAdapter: PublishAdapter = {
fetchJson: (input, init) =>
sessionFetchJson(context.session, input, init, { signal: context.signal }),
fetchImageBlob: (sourceUrl) => fetchAssetBlob(sourceUrl, context.signal),
reportProgress(stage) {
context.reportProgress(`jianshu.${stage}`)
async reportProgress(stage) {
await context.reportProgress(`jianshu.${stage}`)
},
},
)
@@ -1060,7 +1060,7 @@ export const juejinAdapter: PublishAdapter = {
context.signal,
)
context.reportProgress('juejin.create_draft')
await context.reportProgress('juejin.create_draft')
const draftId = await createDraft(context, payload, markdown, uuid)
const draftUrl = `${JUEJIN_ORIGIN}/editor/drafts/${encodeURIComponent(draftId)}`
const publishType = normalizePublishType(payload)
@@ -1073,7 +1073,7 @@ export const juejinAdapter: PublishAdapter = {
}
}
context.reportProgress('juejin.submit_editor')
await context.reportProgress('juejin.submit_editor')
const editorResult = await publishDraft(context, draftId)
if (!editorResult.success) {
throw new Error(editorResult.message || 'juejin_publish_failed')
@@ -534,7 +534,7 @@ export const qiehaoAdapter: PublishAdapter = {
? `${QIEHAO_ORIGIN}/marticlepublish/omPublish`
: `${QIEHAO_ORIGIN}/marticlepublish/omSave`
context.reportProgress('qiehao.submit')
await context.reportProgress('qiehao.submit')
const response = await sessionFetchJson<QiehaoPublishResponse>(
context.session,
endpoint,
@@ -13,7 +13,6 @@ import {
normalizeArticleHtml,
raceWithAbort,
sessionCookieHeader,
sessionCookieValue,
sessionFetchJson,
} from './common'
@@ -624,8 +623,7 @@ async function calculateEditorSignature(
return editor.commands.getTextCount()
}, html),
{ signal: context.signal, timeoutMs: 20_000 },
)
.catch(() => null)
).catch(() => null)
const normalizedCount =
typeof textCount === 'number' && Number.isFinite(textCount) ? Math.round(textCount) : null
@@ -874,7 +872,7 @@ export const smzdmAdapter: PublishAdapter = {
throw new Error('smzdm_not_logged_in')
}
context.reportProgress('smzdm.create_article_id')
await context.reportProgress('smzdm.create_article_id')
const articleId = await getArticleId(context)
let html = normalizeArticleHtml(context.article)
@@ -905,7 +903,7 @@ export const smzdmAdapter: PublishAdapter = {
account.uid,
)
context.reportProgress('smzdm.submit')
await context.reportProgress('smzdm.submit')
await waitForHumanPace(context.signal)
const response = await sessionFetchJson<SmzdmSubmitResponse>(
context.session,
@@ -374,7 +374,7 @@ export const sohuhaoAdapter: PublishAdapter = {
)
const publishType = resolvePublishType(payload)
context.reportProgress('sohuhao.submit')
await context.reportProgress('sohuhao.submit')
const articleId = await submitArticle(context, account, processed.html, publishType)
return {
@@ -48,8 +48,8 @@ export const toutiaoAdapter: PublishAdapter = {
fetchImageBlob: (sourceUrl) => fetchToutiaoImageBlob(sourceUrl, context.signal),
uploadHtmlImages: (sourceHtml, uploader) =>
uploadHtmlImages(sourceHtml, uploader, { signal: context.signal }),
reportProgress(stage) {
context.reportProgress(`toutiao.${stage}`)
async reportProgress(stage) {
await context.reportProgress(`toutiao.${stage}`)
},
},
)
@@ -1030,10 +1030,10 @@ export const wangyihaoAdapter: PublishAdapter = {
{ signal: context.signal },
)
context.reportProgress('wangyihao.save_draft')
await context.reportProgress('wangyihao.save_draft')
const draftId = await saveDraft(context, account, detail, processed.html, token)
context.reportProgress('wangyihao.publish_draft')
await context.reportProgress('wangyihao.publish_draft')
const published = await publishDraft(context, draftId)
if (!published) {
throw new Error('wangyihao_publish_click_failed')
@@ -1609,7 +1609,7 @@ async function completeWeixinPublishConfirmation(
return response
}
context.reportProgress('weixin_gzh.disable_mass_notification')
await context.reportProgress('weixin_gzh.disable_mass_notification')
await waitForWeixinDelay(context.signal, 300)
const notification = await disableWeixinMassNotification(page)
if (notification.found && notification.reason === 'switch_missing') {
@@ -1657,7 +1657,7 @@ async function completeWeixinPublishConfirmation(
signal: context.signal,
})
if (continueConfirm.clicked) {
context.reportProgress('weixin_gzh.continue_publish_without_notification')
await context.reportProgress('weixin_gzh.continue_publish_without_notification')
}
const response = await waitForWeixinPublishResponseOrAuthorization(
@@ -2125,8 +2125,9 @@ export const weixinGzhAdapter: PublishAdapter = {
}
context.reportProgress('weixin_gzh.upload_content_images')
const processed = await uploadHtmlImages(html, async (sourceUrl) =>
uploadImage(context, meta, sourceUrl),
const processed = await uploadHtmlImages(
html,
async (sourceUrl) => uploadImage(context, meta, sourceUrl),
{ signal: context.signal },
)
const content = wrapWeixinContent(processed.html)
@@ -2136,7 +2137,7 @@ export const weixinGzhAdapter: PublishAdapter = {
? await uploadCover(context, meta, coverAssetUrl).catch(() => null)
: null
context.reportProgress('weixin_gzh.save_draft')
await context.reportProgress('weixin_gzh.save_draft')
const form = buildOperateForm(context, meta, content, cover)
const response = await sessionFetchJson<WeixinOperateResponse>(
context.session,
@@ -2169,7 +2170,7 @@ export const weixinGzhAdapter: PublishAdapter = {
throw new Error(`weixin_gzh_save_failed:${message}`)
}
context.reportProgress('weixin_gzh.publish')
await context.reportProgress('weixin_gzh.publish')
const articleTitle = context.article.title?.trim() || '未命名文章'
let publishResponse: WeixinOperateResponse | null
try {
+11 -6
View File
@@ -54,10 +54,15 @@ async function zhihuFetch<T>(
input: string,
init?: RequestInit,
): Promise<T> {
return await sessionFetchJson<T>(context.session, input, {
...init,
headers: buildHeaders(init?.headers),
}, { signal: context.signal })
return await sessionFetchJson<T>(
context.session,
input,
{
...init,
headers: buildHeaders(init?.headers),
},
{ signal: context.signal },
)
}
function markdownToHtml(markdown: string): string {
@@ -245,7 +250,7 @@ async function createDraft(
context.reportProgress('zhihu.upload_content_images')
const content = await processContentImages(context, transformedContent)
context.reportProgress('zhihu.create_draft')
await context.reportProgress('zhihu.create_draft')
const created = await zhihuFetch<ZhihuDraftCreateResponse>(
context,
'https://zhuanlan.zhihu.com/api/articles/drafts',
@@ -276,7 +281,7 @@ async function publishDraft(
): Promise<boolean> {
const xsrfToken = await sessionCookieValue(context.session, 'zhihu.com', '_xsrf')
context.reportProgress('zhihu.publish_draft')
await context.reportProgress('zhihu.publish_draft')
const result = await zhihuFetch<ZhihuPublishResponse>(
context,
'https://www.zhihu.com/api/v4/content/publish',
+2 -2
View File
@@ -570,7 +570,7 @@ export const zolAdapter: PublishAdapter = {
const form = new FormData()
appendBaseFormFields(form, title, account.uid)
context.reportProgress('zol.create_draft')
await context.reportProgress('zol.create_draft')
const draftId = await createDraftId(context, form)
const coverAssetUrl = context.article.cover_asset_url?.trim() || ''
@@ -598,7 +598,7 @@ export const zolAdapter: PublishAdapter = {
form.append('taskType', '1')
form.append('taskIds', '[]')
context.reportProgress('zol.submit')
await context.reportProgress('zol.submit')
const response = await sessionFetchJson<ZolPublishResponse>(
context.session,
`${ZOL_API_ORIGIN}/api/v1/creator.content.save.orther`,
@@ -1677,7 +1677,11 @@ function armPublishTaskDeadline(
function resolvePublishTaskTimeoutMs(platform: string): number {
const envKey = `GEO_DESKTOP_PUBLISH_TIMEOUT_${platform.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_MS`
return resolvePositiveEnvInteger(envKey) ?? resolvePositiveEnvInteger('GEO_DESKTOP_PUBLISH_TIMEOUT_MS') ?? defaultPublishTaskTimeoutMs
return (
resolvePositiveEnvInteger(envKey) ??
resolvePositiveEnvInteger('GEO_DESKTOP_PUBLISH_TIMEOUT_MS') ??
defaultPublishTaskTimeoutMs
)
}
function resolvePositiveEnvInteger(key: string): number | null {
@@ -1695,16 +1699,30 @@ function resolvePositiveEnvInteger(key: string): number | null {
function isIrreversiblePublishStage(stage: string): boolean {
const normalized = stage.trim().toLowerCase()
return (
normalized.endsWith('.create_note') ||
normalized.endsWith('.create_article_id') ||
normalized.endsWith('.create_draft') ||
normalized.endsWith('.publish') ||
normalized.endsWith('.publish_draft') ||
normalized.endsWith('.save_content') ||
normalized.endsWith('.submit') ||
normalized.endsWith('.submit_editor') ||
normalized.endsWith('.save_draft') ||
normalized === 'create_note' ||
normalized === 'create_article_id' ||
normalized === 'create_draft' ||
normalized === 'publish' ||
normalized === 'publish_draft' ||
normalized === 'save_content' ||
normalized === 'submit' ||
normalized === 'submit_editor' ||
normalized === 'save_draft'
)
}
// Persist a durable "submit started" marker (server-side, before/at the irreversible POST) and a
// local flag the abort path reads. Fires at most once per execution and never blocks publishing.
function markPublishSubmitStartedIfNeeded(taskId: string, stage: string): void {
// Persist a durable "submit started" marker before the irreversible platform write. The local flag
// is set only after the server confirms the marker, so recovery can trust publish_submit_started_at.
async function markPublishSubmitStartedIfNeeded(taskId: string, stage: string): Promise<void> {
if (!isIrreversiblePublishStage(stage)) {
return
}
@@ -1712,19 +1730,31 @@ function markPublishSubmitStartedIfNeeded(taskId: string, stage: string): void {
if (!active || active.kind !== 'publish' || active.submitStartedAt !== null) {
return
}
active.submitStartedAt = Date.now()
state.activeExecutions.set(taskId, active)
const leaseToken = state.tasks.get(taskId)?.leaseToken
if (!leaseToken) {
return
throw new Error('publish_submit_marker_lease_missing')
}
await markDesktopTaskPublishSubmitStarted(taskId, leaseToken)
active.submitStartedAt = Date.now()
state.activeExecutions.set(taskId, active)
}
function asSubmitUncertainExecution(result: AdapterExecutionResult): AdapterExecutionResult {
if (result.status !== 'failed') {
return result
}
return {
...result,
status: 'unknown',
summary: '发布可能已提交到平台,结果待人工确认,已避免自动重复发布。',
error: {
...(result.error ?? {}),
publish_submit_uncertain: true,
original_status: 'failed',
original_summary: result.summary,
},
}
void markDesktopTaskPublishSubmitStarted(taskId, leaseToken).catch((error) => {
console.warn('[desktop-runtime] mark publish submit started failed', {
taskId,
message: error instanceof Error ? error.message : String(error),
})
})
}
function pumpExecutionLoop(): void {
@@ -2824,11 +2854,7 @@ async function extendActiveLease(taskId: string, leaseToken: string): Promise<vo
existing.summary = `发布任务超过 ${Math.round(idleMs / 1000)} 秒没有新进度,已中止并释放,准备由服务端重排${detail}`
existing.updatedAt = Date.now()
state.tasks.set(taskId, existing)
recordActivity(
'warn',
'发布任务疑似卡住',
`${existing.title} ${existing.summary}`,
)
recordActivity('warn', '发布任务疑似卡住', `${existing.title} ${existing.summary}`)
// Abort the local execution instead of merely letting the lease lapse: this
// releases the slot immediately and, crucially, closes the window where the
// server could re-queue the task while a stalled adapter is still mid-publish
@@ -2914,16 +2940,19 @@ async function executeTaskAdapter(
signal,
phase: 'initial',
article: await loadPublishArticle(task),
reportProgress(stage: string) {
async reportProgress(stage: string) {
noteMonitorExecutionSafePoint(task.id, null)
updateTaskProgress(task.id, `publish adapter progress: ${stage}`)
markPublishSubmitStartedIfNeeded(task.id, stage)
await markPublishSubmitStartedIfNeeded(task.id, stage)
},
},
payload,
)
await maybeReportTaskAuthFailure(task, result, accountIdentity)
return result
const active = state.activeExecutions.get(task.id)
const normalizedResult =
active?.submitStartedAt != null ? asSubmitUncertainExecution(result) : result
await maybeReportTaskAuthFailure(task, normalizedResult, accountIdentity)
return normalizedResult
} finally {
await playwrightLease?.release()
if (viewHandle) {
@@ -3358,7 +3387,11 @@ function notifyLongRunningPublishTasks(): void {
task.summary = `${task.summary || '发布任务仍在执行。'}(已执行 ${formatDurationText(now - active.startedAt)},如网络异常可取消后重试。)`
task.updatedAt = now
state.tasks.set(task.id, task)
recordActivity('warn', '发布等待较久', `${task.title} 已执行 ${formatDurationText(now - active.startedAt)},可能存在网络异常。`)
recordActivity(
'warn',
'发布等待较久',
`${task.title} 已执行 ${formatDurationText(now - active.startedAt)},可能存在网络异常。`,
)
emitRuntimeInvalidated('publish-task-progress', { taskId: task.id })
}
}