Files
geo/apps/desktop-client/src/main/adapters/zhihu.ts
T

409 lines
11 KiB
TypeScript
Raw Normal View History

import { Buffer } from 'node:buffer'
import { createHash, createHmac, randomUUID } from 'node:crypto'
2026-04-20 11:45:08 +08:00
import type { JsonValue } from '@geo/shared-types'
import { marked } from 'marked'
2026-04-20 11:45:08 +08:00
import type { PublishAdapter } from './base'
2026-04-20 11:45:08 +08:00
import {
extractImageSources,
fetchImageBlob,
normalizeRemoteUrl,
sessionCookieValue,
sessionFetchJson,
} from './common'
2026-04-20 11:45:08 +08:00
type ZhihuMeResponse = {
uid?: string
id?: string
name?: string
avatar_url?: string
}
2026-04-20 11:45:08 +08:00
type ZhihuImageTokenResponse = {
upload_file?: {
state?: number
object_key?: string
}
2026-04-20 11:45:08 +08:00
upload_token?: {
access_id?: string
access_key?: string
access_token?: string
}
}
2026-04-20 11:45:08 +08:00
type ZhihuDraftCreateResponse = {
id?: string
}
2026-04-20 11:45:08 +08:00
type ZhihuPublishResponse = {
code?: number
}
2026-04-20 11:45:08 +08:00
function buildHeaders(headers?: HeadersInit): Headers {
const next = new Headers(headers)
if (!next.has('x-requested-with')) {
next.set('x-requested-with', 'fetch')
2026-04-20 11:45:08 +08:00
}
return next
2026-04-20 11:45:08 +08:00
}
async function zhihuFetch<T>(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
2026-04-20 11:45:08 +08:00
input: string,
init?: RequestInit,
): Promise<T> {
return await sessionFetchJson<T>(context.session, input, {
...init,
headers: buildHeaders(init?.headers),
})
2026-04-20 11:45:08 +08:00
}
function markdownToHtml(markdown: string): string {
const source = markdown.trim()
2026-04-20 11:45:08 +08:00
if (!source) {
return ''
2026-04-20 11:45:08 +08:00
}
return marked.parse(source, {
async: false,
gfm: true,
breaks: false,
}) as string
2026-04-20 11:45:08 +08:00
}
function baseContent(
article: Parameters<NonNullable<PublishAdapter['publish']>>[0]['article'],
): string {
const html = article.html_content?.trim()
2026-04-20 11:45:08 +08:00
if (html) {
return html
2026-04-20 11:45:08 +08:00
}
return markdownToHtml(article.markdown_content ?? '')
2026-04-20 11:45:08 +08:00
}
function wrapStandaloneImages(html: string): string {
return html.replace(/<img([^>]+src="[^"]+"[^>]*)>/gi, '<figure><img$1></figure>')
2026-04-20 11:45:08 +08:00
}
function convertTablesToZhihuFormat(html: string): string {
let next = html
next = next.replace(/<\/thead>\s*<tbody>/gi, '')
next = next.replace(/<thead>/gi, '<tbody>')
next = next.replace(/<\/thead>/gi, '</tbody>')
2026-04-20 11:45:08 +08:00
next = next.replace(
/<table\b/gi,
'<table data-draft-node="block" data-draft-type="table" data-draft-padding="8"',
)
return next
2026-04-20 11:45:08 +08:00
}
function transformContent(html: string): string {
let next = html.trim()
next = next.replace(/<section\b/gi, '<div').replace(/<\/section>/gi, '</div>')
next = next.replace(/<(script|style)[^>]*>[\s\S]*?<\/\1>/gi, '')
next = next.replace(/\sstyle="[^"]*"/gi, '')
next = next.replace(/\sdata-(?!draft)[a-z-]+="[^"]*"/gi, '')
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, '$1')
next = next.replace(/<pre><code class="language-([^"]+)">/gi, '<pre lang="$1"><code>')
next = wrapStandaloneImages(next)
next = convertTablesToZhihuFormat(next)
return next.trim()
2026-04-20 11:45:08 +08:00
}
function md5Hex(buffer: ArrayBuffer): string {
return createHash('md5').update(Buffer.from(buffer)).digest('hex')
2026-04-20 11:45:08 +08:00
}
function hmacSha1Base64(key: string, message: string): string {
return createHmac('sha1', key).update(message).digest('base64')
2026-04-20 11:45:08 +08:00
}
function randomTraceId(): string {
return `${Date.now()},${randomUUID()}`
2026-04-20 11:45:08 +08:00
}
function buildPictureUrl(imageHash: string, mimeType: string): string {
const extension = mimeType.split('/')[1] || 'png'
return `https://picx.zhimg.com/v2-${imageHash}.${extension}`
2026-04-20 11:45:08 +08:00
}
async function uploadBinaryImage(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
2026-04-20 11:45:08 +08:00
sourceUrl: string,
): Promise<string | null> {
const blob = await fetchImageBlob(sourceUrl)
if (!blob || !blob.type.startsWith('image/')) {
return null
2026-04-20 11:45:08 +08:00
}
const buffer = await blob.arrayBuffer()
const imageHash = md5Hex(buffer)
2026-04-20 11:45:08 +08:00
const token = await zhihuFetch<ZhihuImageTokenResponse>(context, 'https://api.zhihu.com/images', {
method: 'POST',
2026-04-20 11:45:08 +08:00
headers: {
'Content-Type': 'application/json',
2026-04-20 11:45:08 +08:00
},
body: JSON.stringify({
image_hash: imageHash,
source: 'article',
2026-04-20 11:45:08 +08:00
}),
})
2026-04-20 11:45:08 +08:00
if (token.upload_file?.state === 2 && token.upload_file.object_key && token.upload_token) {
const ossDate = new Date().toUTCString()
const ossUserAgent = 'aliyun-sdk-js/6.8.0 Chrome 139.0.0.0 on OS X 10.15.7 64-bit'
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(
`https://zhihu-pics-upload.zhimg.com/${token.upload_file.object_key}`,
{
method: 'PUT',
headers: {
'content-type': blob.type,
'Accept-Language': 'zh-CN,zh;q=0.8,zh-TW;q=0.7,zh-HK;q=0.5,en-US;q=0.3,en;q=0.2',
'x-oss-date': ossDate,
'x-oss-user-agent': ossUserAgent,
'x-oss-security-token': token.upload_token.access_token || '',
authorization: `OSS ${token.upload_token.access_id}:${signature}`,
},
body: blob,
2026-04-20 11:45:08 +08:00
},
)
2026-04-20 11:45:08 +08:00
if (!uploadResponse.ok) {
throw new Error(`zhihu_oss_upload_failed_${uploadResponse.status}`)
2026-04-20 11:45:08 +08:00
}
}
return buildPictureUrl(imageHash, blob.type)
2026-04-20 11:45:08 +08:00
}
async function processContentImages(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
2026-04-20 11:45:08 +08:00
html: string,
): Promise<string> {
const sources = extractImageSources(html)
2026-04-20 11:45:08 +08:00
if (!sources.length) {
return html
2026-04-20 11:45:08 +08:00
}
let next = html
const uploaded = new Map<string, string>()
2026-04-20 11:45:08 +08:00
for (const source of sources) {
if (/zhimg\.com/i.test(source)) {
continue
2026-04-20 11:45:08 +08:00
}
const target = await uploadBinaryImage(context, source)
2026-04-20 11:45:08 +08:00
if (target) {
uploaded.set(source, target)
2026-04-20 11:45:08 +08:00
}
}
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to)
2026-04-20 11:45:08 +08:00
}
return next
2026-04-20 11:45:08 +08:00
}
async function detectZhihu(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
2026-04-20 11:45:08 +08:00
): Promise<ZhihuMeResponse | null> {
const me = await zhihuFetch<ZhihuMeResponse>(context, 'https://www.zhihu.com/api/v4/me', {
method: 'GET',
}).catch(() => null)
2026-04-20 11:45:08 +08:00
const uid = me?.uid || me?.id ? String(me?.uid || me?.id) : ''
2026-04-20 11:45:08 +08:00
if (!uid || !me?.name) {
return null
2026-04-20 11:45:08 +08:00
}
return {
...me,
uid,
avatar_url: normalizeRemoteUrl(me.avatar_url) ?? undefined,
}
2026-04-20 11:45:08 +08:00
}
async function createDraft(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
2026-04-20 11:45:08 +08:00
titleImage: string | null,
): Promise<string> {
const title = context.article.title?.trim() || '未命名文章'
const transformedContent = transformContent(baseContent(context.article))
2026-04-20 11:45:08 +08:00
if (!transformedContent) {
throw new Error('article_content_empty')
2026-04-20 11:45:08 +08:00
}
context.reportProgress('zhihu.upload_content_images')
const content = await processContentImages(context, transformedContent)
2026-04-20 11:45:08 +08:00
context.reportProgress('zhihu.create_draft')
const created = await zhihuFetch<ZhihuDraftCreateResponse>(
context,
'https://zhuanlan.zhihu.com/api/articles/drafts',
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
delta_time: 0,
can_reward: false,
title,
content,
...(titleImage ? { titleImage } : {}),
}),
2026-04-20 11:45:08 +08:00
},
)
2026-04-20 11:45:08 +08:00
if (!created.id) {
throw new Error('zhihu_create_draft_failed')
2026-04-20 11:45:08 +08:00
}
return created.id
2026-04-20 11:45:08 +08:00
}
async function publishDraft(
context: Parameters<NonNullable<PublishAdapter['publish']>>[0],
2026-04-20 11:45:08 +08:00
draftId: string,
): Promise<boolean> {
const xsrfToken = await sessionCookieValue(context.session, 'zhihu.com', '_xsrf')
context.reportProgress('zhihu.publish_draft')
const result = await zhihuFetch<ZhihuPublishResponse>(
context,
'https://www.zhihu.com/api/v4/content/publish',
{
method: 'POST',
headers: {
'content-type': 'application/json',
'x-xsrftoken': xsrfToken,
2026-04-20 11:45:08 +08:00
},
body: JSON.stringify({
action: 'article',
data: {
publish: {
traceId: randomTraceId(),
},
draft: {
disabled: 1,
id: draftId,
isPublished: false,
},
commentsPermission: {
comment_permission: 'anyone',
},
creationStatement: {
disclaimer_type: '',
disclaimer_status: '',
},
contentsTables: {
table_of_contents_enabled: false,
},
commercialReportInfo: {
isReport: 0,
},
appreciate: {
can_reward: false,
tagline: '',
},
hybridInfo: {},
},
}),
},
)
2026-04-20 11:45:08 +08:00
return result.code === 0
2026-04-20 11:45:08 +08:00
}
function buildResultPayload(
articleId: string,
mediaName: string,
externalManageUrl: string,
externalArticleUrl: string | null,
): Record<string, JsonValue> {
return {
platform: 'zhihu',
2026-04-20 11:45:08 +08:00
media_name: mediaName,
external_article_id: articleId,
external_manage_url: externalManageUrl,
external_article_url: externalArticleUrl,
publish_type: 'publish',
}
2026-04-20 11:45:08 +08:00
}
function buildFailureResult(
error: unknown,
): ReturnType<PublishAdapter['publish']> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error)
2026-04-20 11:45:08 +08:00
if (message === 'article_content_empty') {
2026-04-20 11:45:08 +08:00
return {
status: 'failed',
summary: '文章内容为空,无法推送到知乎。',
2026-04-20 11:45:08 +08:00
error: {
code: 'article_content_empty',
2026-04-20 11:45:08 +08:00
message,
},
}
2026-04-20 11:45:08 +08:00
}
if (message === 'zhihu_not_logged_in') {
2026-04-20 11:45:08 +08:00
return {
status: 'failed',
summary: '知乎登录态失效,无法执行发布。',
2026-04-20 11:45:08 +08:00
error: {
code: 'zhihu_not_logged_in',
2026-04-20 11:45:08 +08:00
message,
},
}
2026-04-20 11:45:08 +08:00
}
return {
status: 'failed',
summary: '知乎发布失败。',
2026-04-20 11:45:08 +08:00
error: {
code: 'zhihu_publish_failed',
2026-04-20 11:45:08 +08:00
message,
},
}
2026-04-20 11:45:08 +08:00
}
export const zhihuAdapter: PublishAdapter = {
platform: 'zhihu',
executionMode: 'session',
2026-04-20 11:45:08 +08:00
async publish(context) {
context.reportProgress('zhihu.detect_login')
const me = await detectZhihu(context)
2026-04-20 11:45:08 +08:00
if (!me?.uid || !me.name) {
return buildFailureResult(new Error('zhihu_not_logged_in'))
2026-04-20 11:45:08 +08:00
}
try {
context.reportProgress('zhihu.upload_cover')
const coverUrl = context.article.cover_asset_url?.trim() || null
const titleImage = coverUrl ? await uploadBinaryImage(context, coverUrl) : null
const draftId = await createDraft(context, titleImage)
2026-04-20 11:45:08 +08:00
const published = await publishDraft(context, draftId)
2026-04-20 11:45:08 +08:00
if (!published) {
return buildFailureResult(new Error('zhihu_publish_failed'))
2026-04-20 11:45:08 +08:00
}
const publishedUrl = `https://zhuanlan.zhihu.com/p/${draftId}`
2026-04-20 11:45:08 +08:00
return {
status: 'succeeded',
2026-04-20 11:45:08 +08:00
payload: buildResultPayload(draftId, me.name, `${publishedUrl}/edit`, publishedUrl),
summary: '知乎发布成功。',
}
2026-04-20 11:45:08 +08:00
} catch (error) {
return buildFailureResult(error)
2026-04-20 11:45:08 +08:00
}
},
}