Files
geo/apps/desktop-client/src/main/adapters/juejin.ts
T
root 290da7cd50 feat(publish): add seven publish adapters for new media platforms
Wire sohuhao, wangyihao, juejin, smzdm, weixin-gzh, zol, and dongchedi
into the publish runtime: each ships its own publish protocol and
auth-failure classifier (login/token/challenge codes) plus registry
entries in selectPublishAdapter and the auth adapter map. Smzdm bind
detection now triple-falls-back across page-context fetch, session
fetch, cookie fetch, and a final cookie-derived account so the
nickname-less guest profile still resolves to a stable platform UID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 00:57:37 +08:00

985 lines
29 KiB
TypeScript

import { createHash, createHmac, randomUUID } from "node:crypto";
import type { JsonValue } from "@geo/shared-types";
import { resolveDesktopApiURL } from "../transport/api-client";
import {
fetchImageBlob,
normalizeArticleHtml,
sessionCookieHeader,
sessionFetchJson,
} from "./common";
import type { PublishAdapter, PublishAdapterContext } from "./base";
type JuejinPublishType = "publish" | "draft";
type JuejinProfileResponse = {
data?: {
bui_user?: {
user_id?: string;
screen_name?: string;
avatar_large?: string;
avatar_url?: string;
};
};
};
type JuejinProfileUser = NonNullable<NonNullable<JuejinProfileResponse["data"]>["bui_user"]>;
type JuejinDraftCreateResponse = {
err_no?: number;
err_msg?: string;
data?: {
id?: string | number;
};
};
type JuejinArticleListResponse = {
err_no?: number;
err_msg?: string;
data?: unknown;
};
type JuejinImageXTokenResponse = {
err_no?: number;
err_msg?: string;
data?: {
token?: {
AccessKeyId?: string;
SecretAccessKey?: string;
SessionToken?: string;
ExpiredTime?: string;
};
};
};
type JuejinImageXToken = {
accessKeyId: string;
secretAccessKey: string;
sessionToken: string;
};
type JuejinImageXApplyUploadResponse = {
Result?: {
UploadAddress?: {
StoreInfos?: Array<{
StoreUri?: string;
Auth?: string;
}>;
UploadHosts?: string[];
SessionKey?: string;
};
};
};
type JuejinImageXCommitUploadResponse = {
Result?: {
Results?: Array<{
Uri?: string;
UriStatus?: number;
}>;
};
};
type JuejinImageURLResponse = {
err_no?: number;
err_msg?: string;
data?: {
main_url?: string;
backup_url?: string;
};
};
type JuejinEditorResult = {
success: boolean;
message?: string;
url?: string;
};
type JuejinPublishedArticle = {
id: string;
url: string;
};
const JUEJIN_ORIGIN = "https://juejin.cn";
const JUEJIN_API_ORIGIN = "https://api.juejin.cn";
const JUEJIN_IMAGEX_ORIGIN = "https://imagex.bytedanceapi.com";
const JUEJIN_IMAGEX_AID = "2608";
const JUEJIN_IMAGEX_SERVICE_ID = "73owjymdk6";
const JUEJIN_DEFAULT_CATEGORY_ID = "6809637772874219534";
const JUEJIN_DEFAULT_TAG_IDS = ["6809641083107016712", "6809640681271721997"];
const JUEJIN_IMAGE_SKIP_PATTERNS = [
"juejin.cn",
"p1-juejin",
"p3-juejin",
"p6-juejin",
"p9-juejin",
"byteimg.com",
];
function stringId(value: unknown): string {
if (typeof value === "number") {
return Number.isFinite(value) ? String(value) : "";
}
if (typeof value !== "string") {
return "";
}
return value.trim();
}
function juejinPostURL(articleId: string): string {
return `${JUEJIN_ORIGIN}/post/${encodeURIComponent(articleId)}`;
}
function articleFromJuejinURL(value?: string | null): JuejinPublishedArticle | null {
const match = /\/post\/(\d+)/.exec(value || "");
if (!match?.[1]) {
return null;
}
return {
id: match[1],
url: juejinPostURL(match[1]),
};
}
function normalizePublishType(payload: Record<string, unknown>): JuejinPublishType {
return payload.publish_type === "draft" || payload.publishType === "draft" ? "draft" : "publish";
}
function normalizeStringArray(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}
return value.map((item) => stringId(item)).filter(Boolean);
}
function juejinHeaders(
context: PublishAdapterContext,
extra: Record<string, string> = {},
): Promise<Record<string, string>> {
return sessionCookieHeader(context.session, "juejin.cn")
.catch(() => "")
.then((cookie) => ({
accept: "application/json, text/plain, */*",
origin: JUEJIN_ORIGIN,
referer: `${JUEJIN_ORIGIN}/`,
...(cookie ? { cookie } : {}),
...extra,
}));
}
async function fetchProfile(context: PublishAdapterContext): Promise<JuejinProfileUser | null> {
const response = await sessionFetchJson<JuejinProfileResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/user_api/v1/user/get_profile`,
{
method: "POST",
credentials: "include",
headers: await juejinHeaders(context, {
"content-type": "application/json",
}),
body: "{}",
},
).catch(() => null);
return response?.data?.bui_user ?? null;
}
async function fetchCsrfToken(context: PublishAdapterContext): Promise<string | null> {
const headers = await juejinHeaders(context, {
"x-secsdk-csrf-request": "1",
"x-secsdk-csrf-version": "1.2.10",
});
const response = await context.session.fetch(`${JUEJIN_API_ORIGIN}/user_api/v1/sys/token`, {
method: "HEAD",
credentials: "include",
headers,
}).catch(() => null);
const token = response?.headers.get("x-ware-csrf-token") ?? "";
const parts = token.split(",");
return parts.length >= 2 && parts[1] ? parts[1] : null;
}
function extractMarkdownImageSources(markdown: string): string[] {
const sources = new Set<string>();
for (const match of markdown.matchAll(/!\[[^\]]*]\((?:<([^>\s]+)>|([^\s)]+))(?:\s+["'][^"']*["'])?\)/g)) {
const source = (match[1] || match[2] || "").trim();
if (source) {
sources.add(source);
}
}
for (const match of markdown.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
const source = match[2]?.trim();
if (source) {
sources.add(source);
}
}
return [...sources];
}
function shouldSkipImageSource(source: string): boolean {
return JUEJIN_IMAGE_SKIP_PATTERNS.some((pattern) => source.includes(pattern));
}
async function uploadMarkdownImages(
markdown: string,
uploader: (sourceUrl: string) => Promise<string | null>,
): Promise<string> {
const sources = extractMarkdownImageSources(markdown);
if (!sources.length) {
return markdown;
}
const uploaded = new Map<string, string>();
for (const source of sources) {
if (shouldSkipImageSource(source)) {
continue;
}
const target = await uploader(source);
if (target) {
uploaded.set(source, target);
}
}
let next = markdown;
for (const [from, to] of uploaded.entries()) {
next = next.split(from).join(to);
}
return next;
}
function stripHtml(value: string): string {
return value
.replace(/<br\s*\/?>/gi, "\n")
.replace(/<\/(?:p|div|section|article|h[1-6]|li|tr)>/gi, "\n")
.replace(/<[^>]+>/g, "")
.replace(/&nbsp;/g, " ")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, "\"")
.replace(/&#39;/g, "'")
.replace(/[ \t]+\n/g, "\n")
.replace(/\n{3,}/g, "\n\n")
.trim();
}
function htmlTableToMarkdown(tableHtml: string): string {
const rows: string[][] = [];
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
const cells: string[] = [];
for (const cellMatch of (rowMatch[1] ?? "").matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/gi)) {
cells.push(stripHtml(cellMatch[1] ?? "").replace(/\|/g, "\\|"));
}
if (cells.length > 0) {
rows.push(cells);
}
}
if (!rows.length) {
return "";
}
const width = Math.max(...rows.map((row) => row.length));
const normalizeRow = (row: string[]) => {
const cells = Array.from({ length: width }, (_item, index) => row[index] ?? "");
return `| ${cells.join(" | ")} |`;
};
return [
normalizeRow(rows[0] ?? []),
normalizeRow(Array.from({ length: width }, () => "---")),
...rows.slice(1).map(normalizeRow),
].join("\n");
}
function htmlToMarkdown(html: string): string {
let next = html.trim();
next = next.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => `\n\n${htmlTableToMarkdown(tableHtml)}\n\n`);
next = next.replace(/<img\b[^>]*alt=(['"])(.*?)\1[^>]*src=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, alt, _q2, src) =>
`\n![${stripHtml(alt)}](${String(src).trim()})\n`,
);
next = next.replace(/<img\b[^>]*src=(['"])(.*?)\1[^>]*alt=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, src, _q2, alt) =>
`\n![${stripHtml(alt)}](${String(src).trim()})\n`,
);
next = next.replace(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi, (_match, _q, src) => `\n![](${String(src).trim()})\n`);
next = next.replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, (_match, content) => `\n# ${stripHtml(content)}\n`);
next = next.replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, (_match, content) => `\n## ${stripHtml(content)}\n`);
next = next.replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, (_match, content) => `\n### ${stripHtml(content)}\n`);
next = next.replace(/<li\b[^>]*>([\s\S]*?)<\/li>/gi, (_match, content) => `\n- ${stripHtml(content)}`);
next = next.replace(/<p\b[^>]*>([\s\S]*?)<\/p>/gi, (_match, content) => `\n${stripHtml(content)}\n`);
return stripHtml(next);
}
function articleMarkdown(context: PublishAdapterContext): string {
const markdown = context.article.markdown_content?.trim();
if (markdown) {
return markdown;
}
const html = context.article.html_content?.trim();
if (html) {
return htmlToMarkdown(normalizeArticleHtml(context.article));
}
return "";
}
function buildAssetURLCandidates(sourceUrl: string): string[] {
const trimmed = sourceUrl.trim();
if (!trimmed) {
return [];
}
if (/^(data|blob):/i.test(trimmed)) {
return [trimmed];
}
const candidates: string[] = [];
const pushCandidate = (value: string) => {
if (!candidates.includes(value)) {
candidates.push(value);
}
};
try {
const parsed = new URL(trimmed, resolveDesktopApiURL("/"));
if (parsed.pathname.startsWith("/api/")) {
pushCandidate(new URL(resolveDesktopApiURL(`${parsed.pathname}${parsed.search}`)).toString());
}
pushCandidate(parsed.toString());
} catch {
pushCandidate(trimmed);
}
return candidates;
}
async function fetchAssetBlob(sourceUrl: string): Promise<Blob | null> {
for (const candidate of buildAssetURLCandidates(sourceUrl)) {
const blob = await fetchImageBlob(candidate);
if (blob) {
return blob;
}
}
return null;
}
function sha256Hex(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
function hmacSha256(key: Buffer | string, value: string): Buffer {
return createHmac("sha256", key).update(value).digest();
}
function formatAmzDate(date: Date): string {
return date.toISOString().replace(/[:-]|\.\d{3}/g, "");
}
function formatDateStamp(date: Date): string {
return date.toISOString().slice(0, 10).replace(/-/g, "");
}
function signAWS4(input: {
method: string;
url: string;
accessKeyId: string;
secretAccessKey: string;
securityToken?: string;
region?: string;
service?: string;
headers?: Record<string, string>;
body?: string;
}): Record<string, string> {
const region = input.region ?? "cn-north-1";
const service = input.service ?? "imagex";
const body = input.body ?? "";
const url = new URL(input.url);
const now = new Date();
const amzDate = formatAmzDate(now);
const dateStamp = formatDateStamp(now);
const query = [...url.searchParams.entries()]
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
.join("&");
const signedHeaders: Record<string, string> = {
"x-amz-date": amzDate,
...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}),
...(input.headers ?? {}),
};
const signedHeaderNames = Object.keys(signedHeaders)
.map((key) => key.toLowerCase())
.sort()
.join(";");
const canonicalHeaders = Object.entries(signedHeaders)
.map(([key, value]) => `${key.toLowerCase()}:${value.trim()}`)
.sort()
.join("\n") + "\n";
const canonicalRequest = [
input.method.toUpperCase(),
url.pathname || "/",
query,
canonicalHeaders,
signedHeaderNames,
sha256Hex(body),
].join("\n");
const credentialScope = `${dateStamp}/${region}/${service}/aws4_request`;
const stringToSign = [
"AWS4-HMAC-SHA256",
amzDate,
credentialScope,
sha256Hex(canonicalRequest),
].join("\n");
const kDate = hmacSha256(`AWS4${input.secretAccessKey}`, dateStamp);
const kRegion = hmacSha256(kDate, region);
const kService = hmacSha256(kRegion, service);
const kSigning = hmacSha256(kService, "aws4_request");
const signature = createHmac("sha256", kSigning).update(stringToSign).digest("hex");
const authorization =
`AWS4-HMAC-SHA256 Credential=${input.accessKeyId}/${credentialScope}, SignedHeaders=${signedHeaderNames}, Signature=${signature}`;
return {
authorization,
"x-amz-date": amzDate,
...(input.securityToken ? { "x-amz-security-token": input.securityToken } : {}),
};
}
function crc32(data: Uint8Array): string {
let crc = 0xFFFFFFFF;
const table = crc32Table();
for (const byte of data) {
crc = (crc >>> 8) ^ table[(crc ^ byte) & 0xFF];
}
return ((crc ^ 0xFFFFFFFF) >>> 0).toString(16).padStart(8, "0");
}
let cachedCrc32Table: Uint32Array | null = null;
function crc32Table(): Uint32Array {
if (cachedCrc32Table) {
return cachedCrc32Table;
}
const table = new Uint32Array(256);
for (let index = 0; index < 256; index += 1) {
let value = index;
for (let bit = 0; bit < 8; bit += 1) {
value = (value & 1) ? (0xEDB88320 ^ (value >>> 1)) : (value >>> 1);
}
table[index] = value;
}
cachedCrc32Table = table;
return table;
}
async function mainFetchJson<T>(input: string, init?: RequestInit): Promise<T> {
const response = await fetch(input, init);
const text = await response.text();
if (!response.ok) {
throw new Error(text || `request_failed_${response.status}`);
}
if (!text) {
return {} as T;
}
return JSON.parse(text) as T;
}
function randomJuejinUUID(): string {
return randomUUID().replace(/-/g, "").slice(0, 16) + Date.now().toString();
}
async function fetchImageXToken(context: PublishAdapterContext, uuid: string): Promise<JuejinImageXToken> {
const response = await sessionFetchJson<JuejinImageXTokenResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/imagex/v2/gen_token?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&client=web`,
{
method: "GET",
credentials: "include",
headers: await juejinHeaders(context),
},
);
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || "juejin_image_token_failed");
}
const token = response.data?.token;
if (!token?.AccessKeyId || !token.SecretAccessKey) {
throw new Error("juejin_image_token_failed");
}
return {
accessKeyId: token.AccessKeyId,
secretAccessKey: token.SecretAccessKey,
sessionToken: token.SessionToken ?? "",
};
}
async function applyImageUpload(token: JuejinImageXToken) {
const url = `${JUEJIN_IMAGEX_ORIGIN}/?Action=ApplyImageUpload&Version=2018-08-01&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`;
const signedHeaders = signAWS4({
method: "GET",
url,
accessKeyId: token.accessKeyId,
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
});
const response = await mainFetchJson<JuejinImageXApplyUploadResponse>(url, {
method: "GET",
headers: signedHeaders,
});
const uploadAddress = response.Result?.UploadAddress;
const storeInfo = uploadAddress?.StoreInfos?.[0];
const uploadHost = uploadAddress?.UploadHosts?.[0];
if (!uploadAddress?.SessionKey || !storeInfo?.StoreUri || !storeInfo.Auth || !uploadHost) {
throw new Error("juejin_image_apply_upload_failed");
}
return {
sessionKey: uploadAddress.SessionKey,
storeUri: storeInfo.StoreUri,
auth: storeInfo.Auth,
uploadHost,
};
}
async function uploadToTOS(upload: Awaited<ReturnType<typeof applyImageUpload>>, blob: Blob): 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)),
},
body: blob,
});
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> {
const url =
`${JUEJIN_IMAGEX_ORIGIN}/?Action=CommitImageUpload&Version=2018-08-01&SessionKey=${encodeURIComponent(sessionKey)}&ServiceId=${JUEJIN_IMAGEX_SERVICE_ID}`;
const signedHeaders = signAWS4({
method: "POST",
url,
accessKeyId: token.accessKeyId,
secretAccessKey: token.secretAccessKey,
securityToken: token.sessionToken,
});
const response = await mainFetchJson<JuejinImageXCommitUploadResponse>(url, {
method: "POST",
headers: {
...signedHeaders,
"content-length": "0",
},
});
if (!response.Result) {
throw new Error("juejin_image_commit_upload_failed");
}
}
async function resolveImageURL(context: PublishAdapterContext, uuid: string, storeUri: string): Promise<string> {
const response = await sessionFetchJson<JuejinImageURLResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/imagex/v2/get_img_url?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&uri=${encodeURIComponent(storeUri)}&img_type=private`,
{
method: "GET",
credentials: "include",
headers: await juejinHeaders(context),
},
);
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || "juejin_image_url_failed");
}
const imageUrl = response.data?.main_url || response.data?.backup_url;
if (!imageUrl) {
throw new Error("juejin_image_url_failed");
}
return imageUrl;
}
async function uploadImage(context: PublishAdapterContext, uuid: string, sourceUrl: string): Promise<string | null> {
const blob = await fetchAssetBlob(sourceUrl);
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);
return await resolveImageURL(context, uuid, upload.storeUri);
}
async function createDraft(
context: PublishAdapterContext,
payload: Record<string, unknown>,
markdown: string,
uuid: string,
): Promise<string> {
const csrfToken = await fetchCsrfToken(context).catch(() => null);
const categoryId = stringId(payload.category_id ?? payload.categoryId) || JUEJIN_DEFAULT_CATEGORY_ID;
const tagIds = normalizeStringArray(payload.tag_ids ?? payload.tagIds);
const coverUrl = context.article.cover_asset_url?.trim()
? await uploadImage(context, uuid, context.article.cover_asset_url).catch(() => null)
: null;
const response = await sessionFetchJson<JuejinDraftCreateResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/content_api/v1/article_draft/create`,
{
method: "POST",
credentials: "include",
headers: await juejinHeaders(context, {
"content-type": "application/json",
...(csrfToken ? { "x-secsdk-csrf-token": csrfToken } : {}),
}),
body: JSON.stringify({
category_id: categoryId,
tag_ids: tagIds.length ? tagIds : JUEJIN_DEFAULT_TAG_IDS,
link_url: "",
cover_image: coverUrl ?? "",
title: context.article.title?.trim() || "未命名文章",
brief_content: "",
edit_type: 10,
html_content: "deprecated",
mark_content: markdown,
theme_ids: [],
pics: [],
}),
},
);
if (response.err_no && response.err_no !== 0) {
throw new Error(response.err_msg || "juejin_create_draft_failed");
}
const draftId = stringId(response.data?.id);
if (!draftId) {
throw new Error(response.err_msg || "juejin_create_draft_failed");
}
return draftId;
}
function normalizeCompactText(value: string | null | undefined): string {
return (value || "").trim().replace(/\s+/g, "");
}
async function clickJuejinButtonByText(
page: NonNullable<PublishAdapterContext["playwright"]>["page"],
selectors: string[],
texts: string[],
timeoutMs: number,
): Promise<boolean> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
for (const selector of selectors) {
const locator = page.locator(selector);
const count = await locator.count().catch(() => 0);
for (let index = 0; index < count; index += 1) {
const candidate = locator.nth(index);
const text = await candidate.innerText({ timeout: 500 })
.catch(async () => await candidate.textContent({ timeout: 500 }).catch(() => ""));
const compactText = normalizeCompactText(text);
if (!texts.some((target) => compactText.includes(normalizeCompactText(target)))) {
continue;
}
await candidate.click({ timeout: 5_000 });
return true;
}
}
await page.waitForTimeout(150);
}
return false;
}
async function publishDraft(context: PublishAdapterContext, draftId: string): Promise<JuejinEditorResult> {
const page = context.playwright?.page;
if (!page) {
return {
success: false,
message: "juejin_playwright_page_missing",
};
}
const draftURL = `${JUEJIN_ORIGIN}/editor/drafts/${encodeURIComponent(draftId)}`;
await page.goto(draftURL, {
waitUntil: "domcontentloaded",
timeout: 45_000,
});
await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => undefined);
const firstClicked = await clickJuejinButtonByText(
page,
[".publish-popup .xitu-btn", ".publish-popup button", "button", ".xitu-btn", "[role='button']"],
["发布"],
20_000,
);
if (!firstClicked) {
return {
success: false,
message: "juejin_publish_button_missing",
url: page.url(),
};
}
await page.waitForTimeout(2_000);
const previousURL = page.url();
const confirmed = await clickJuejinButtonByText(
page,
[".btn-container button", ".byte-modal button", ".semi-modal button", "button", "[role='button']"],
["确定并发布", "确认发布", "确定"],
12_000,
);
if (!confirmed) {
return {
success: false,
message: "juejin_confirm_button_missing",
url: page.url(),
};
}
await Promise.race([
page.waitForURL((url) => url.toString() !== previousURL, { timeout: 6_000 }).catch(() => undefined),
page.waitForLoadState("domcontentloaded", { timeout: 6_000 }).catch(() => undefined),
]);
await page.waitForTimeout(1_500);
return {
success: true,
url: page.url(),
};
}
function findArticleListItems(data: unknown): Array<Record<string, unknown>> {
if (Array.isArray(data)) {
return data.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
}
if (data && typeof data === "object") {
const record = data as Record<string, unknown>;
for (const key of ["list", "article_list", "articles"]) {
const value = record[key];
if (Array.isArray(value)) {
return value.filter((item): item is Record<string, unknown> => Boolean(item) && typeof item === "object");
}
}
}
return [];
}
function extractArticleFromListResponse(response: JuejinArticleListResponse | null, draftId: string): JuejinPublishedArticle | null {
const items = findArticleListItems(response?.data);
for (const item of items) {
const articleInfo = item.article_info && typeof item.article_info === "object"
? item.article_info as Record<string, unknown>
: {};
const matchedDraftId = stringId(articleInfo.draft_id ?? item.draft_id);
if (matchedDraftId !== draftId) {
continue;
}
const articleURL = articleFromJuejinURL(
stringId(item.article_url ?? item.url ?? articleInfo.article_url ?? articleInfo.url),
);
const articleId = stringId(item.article_id) || articleURL?.id || stringId(articleInfo.article_id ?? item.id);
if (!articleId) {
return null;
}
return {
id: articleId,
url: articleURL?.url ?? juejinPostURL(articleId),
};
}
return null;
}
async function findPublishedArticle(
context: PublishAdapterContext,
draftId: string,
uuid: string,
): Promise<JuejinPublishedArticle | null> {
for (let attempt = 0; attempt < 6; attempt += 1) {
if (attempt > 0) {
await new Promise((resolve) => {
setTimeout(resolve, 1_500);
});
}
const response = await sessionFetchJson<JuejinArticleListResponse>(
context.session,
`${JUEJIN_API_ORIGIN}/content_api/v1/article/list_by_user?aid=${JUEJIN_IMAGEX_AID}&uuid=${encodeURIComponent(uuid)}&spider=0`,
{
method: "POST",
credentials: "include",
headers: await juejinHeaders(context, {
"content-type": "application/json",
}),
body: JSON.stringify({
audit_status: null,
keyword: "",
page_size: 10,
page_no: 1,
}),
},
).catch(() => null);
const article = extractArticleFromListResponse(response, draftId);
if (article) {
return article;
}
}
return null;
}
function buildResultPayload(
publishType: JuejinPublishType,
draftId: string,
mediaName: string,
externalManageUrl: string,
externalArticleUrl: string | null,
articleId = draftId,
): Record<string, JsonValue> {
return {
platform: "juejin",
media_name: mediaName,
external_article_id: articleId,
draft_id: draftId,
external_manage_url: externalManageUrl,
external_article_url: externalArticleUrl,
publish_type: publishType,
};
}
function buildFailureResult(error: unknown): ReturnType<PublishAdapter["publish"]> extends Promise<infer T> ? T : never {
const message = error instanceof Error ? error.message : String(error);
if (message === "juejin_not_logged_in") {
return {
status: "failed",
summary: "稀土掘金登录态失效,无法执行发布。",
error: {
code: "juejin_not_logged_in",
message,
},
};
}
if (message === "article_content_empty") {
return {
status: "failed",
summary: "文章内容为空,无法推送到稀土掘金。",
error: {
code: "article_content_empty",
message,
},
};
}
if (message === "juejin_create_draft_failed" || message.includes("创建草稿失败")) {
return {
status: "failed",
summary: "稀土掘金草稿创建失败。",
error: {
code: "juejin_create_draft_failed",
message,
},
};
}
if (message === "juejin_playwright_page_missing" || message.includes("button_missing")) {
return {
status: "failed",
summary: "稀土掘金发布确认按钮未就绪,请打开掘金编辑器确认账号状态后重试。",
error: {
code: "juejin_editor_unavailable",
message,
},
};
}
if (message === "juejin_publish_result_missing") {
return {
status: "failed",
summary: "稀土掘金已执行发布,但未在文章列表中查到新文章,请手动检查掘金创作中心。",
error: {
code: "juejin_publish_result_missing",
message,
},
};
}
return {
status: "failed",
summary: "稀土掘金发布失败。",
error: {
code: "juejin_publish_failed",
message,
},
};
}
export const juejinAdapter: PublishAdapter = {
platform: "juejin",
executionMode: "playwright",
async publish(context, payload) {
try {
context.reportProgress("juejin.detect_login");
const user = await fetchProfile(context);
if (!user?.user_id || !user.screen_name) {
throw new Error("juejin_not_logged_in");
}
const uuid = randomJuejinUUID();
let markdown = articleMarkdown(context);
if (!markdown) {
throw new Error("article_content_empty");
}
context.reportProgress("juejin.upload_content_images");
markdown = await uploadMarkdownImages(markdown, async (sourceUrl) =>
await uploadImage(context, uuid, sourceUrl).catch(() => null),
);
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);
if (publishType === "draft") {
return {
status: "succeeded",
payload: buildResultPayload("draft", draftId, user.screen_name, draftUrl, draftUrl),
summary: "稀土掘金草稿保存成功。",
};
}
context.reportProgress("juejin.submit_editor");
const editorResult = await publishDraft(context, draftId);
if (!editorResult.success) {
throw new Error(editorResult.message || "juejin_publish_failed");
}
context.reportProgress("juejin.resolve_article_url");
const article = articleFromJuejinURL(editorResult.url) ?? await findPublishedArticle(context, draftId, uuid);
if (!article) {
throw new Error("juejin_publish_result_missing");
}
return {
status: "succeeded",
payload: buildResultPayload("publish", draftId, user.screen_name, draftUrl, article.url, article.id),
summary: "稀土掘金发布成功。",
};
} catch (error) {
return buildFailureResult(error);
}
},
};