290da7cd50
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>
985 lines
29 KiB
TypeScript
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(/ /g, " ")
|
|
.replace(/&/g, "&")
|
|
.replace(/</g, "<")
|
|
.replace(/>/g, ">")
|
|
.replace(/"/g, "\"")
|
|
.replace(/'/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.trim()})\n`,
|
|
);
|
|
next = next.replace(/<img\b[^>]*src=(['"])(.*?)\1[^>]*alt=(['"])(.*?)\3[^>]*>/gi, (_match, _q1, src, _q2, alt) =>
|
|
`\n.trim()})\n`,
|
|
);
|
|
next = next.replace(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi, (_match, _q, src) => `\n.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);
|
|
}
|
|
},
|
|
};
|