chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,4 +11,3 @@
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,164 +3,164 @@ import axios, {
|
||||
type AxiosInstance,
|
||||
type AxiosRequestConfig,
|
||||
type InternalAxiosRequestConfig,
|
||||
} from "axios";
|
||||
} from 'axios'
|
||||
|
||||
import type { ApiEnvelope, ApiErrorEnvelope, AuthTokens } from "@geo/shared-types";
|
||||
import type { ApiEnvelope, ApiErrorEnvelope, AuthTokens } from '@geo/shared-types'
|
||||
|
||||
export interface AuthBindings {
|
||||
getAccessToken: () => string | null;
|
||||
getRefreshToken: () => string | null;
|
||||
setTokens: (tokens: AuthTokens) => void;
|
||||
clearTokens: () => void;
|
||||
refresh: (refreshToken: string) => Promise<AuthTokens>;
|
||||
getAccessToken: () => string | null
|
||||
getRefreshToken: () => string | null
|
||||
setTokens: (tokens: AuthTokens) => void
|
||||
clearTokens: () => void
|
||||
refresh: (refreshToken: string) => Promise<AuthTokens>
|
||||
}
|
||||
|
||||
export interface CreateApiClientOptions {
|
||||
baseURL?: string;
|
||||
timeoutMs?: number;
|
||||
auth?: AuthBindings;
|
||||
baseURL?: string
|
||||
timeoutMs?: number
|
||||
auth?: AuthBindings
|
||||
}
|
||||
|
||||
export class ApiClientError extends Error {
|
||||
status?: number;
|
||||
code: number;
|
||||
detail?: string;
|
||||
requestId?: string;
|
||||
status?: number
|
||||
code: number
|
||||
detail?: string
|
||||
requestId?: string
|
||||
|
||||
constructor(input: {
|
||||
message: string;
|
||||
code?: number;
|
||||
status?: number;
|
||||
detail?: string;
|
||||
requestId?: string;
|
||||
message: string
|
||||
code?: number
|
||||
status?: number
|
||||
detail?: string
|
||||
requestId?: string
|
||||
}) {
|
||||
super(input.message);
|
||||
this.name = "ApiClientError";
|
||||
this.code = input.code ?? 50000;
|
||||
this.status = input.status;
|
||||
this.detail = input.detail;
|
||||
this.requestId = input.requestId;
|
||||
super(input.message)
|
||||
this.name = 'ApiClientError'
|
||||
this.code = input.code ?? 50000
|
||||
this.status = input.status
|
||||
this.detail = input.detail
|
||||
this.requestId = input.requestId
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiClient {
|
||||
raw: AxiosInstance;
|
||||
get: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>;
|
||||
post: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>;
|
||||
put: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>;
|
||||
remove: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>;
|
||||
raw: AxiosInstance
|
||||
get: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>
|
||||
post: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>
|
||||
put: <T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) => Promise<T>
|
||||
remove: <T>(url: string, config?: AxiosRequestConfig) => Promise<T>
|
||||
}
|
||||
|
||||
function normalizeError(error: unknown): ApiClientError {
|
||||
if (error instanceof ApiClientError) {
|
||||
return error;
|
||||
return error
|
||||
}
|
||||
|
||||
if (axios.isAxiosError(error)) {
|
||||
const payload = error.response?.data as Partial<ApiErrorEnvelope> | undefined;
|
||||
const payload = error.response?.data as Partial<ApiErrorEnvelope> | undefined
|
||||
return new ApiClientError({
|
||||
message: payload?.message ?? error.message ?? "network_error",
|
||||
message: payload?.message ?? error.message ?? 'network_error',
|
||||
code: payload?.code ?? 50000,
|
||||
status: error.response?.status,
|
||||
detail: payload?.detail,
|
||||
requestId: payload?.request_id,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return new ApiClientError({ message: error.message });
|
||||
return new ApiClientError({ message: error.message })
|
||||
}
|
||||
|
||||
return new ApiClientError({ message: "unknown_error" });
|
||||
return new ApiClientError({ message: 'unknown_error' })
|
||||
}
|
||||
|
||||
function unwrapEnvelope<T>(response: { data: ApiEnvelope<T> }): T {
|
||||
return response.data.data;
|
||||
return response.data.data
|
||||
}
|
||||
|
||||
export function createApiClient(options: CreateApiClientOptions = {}): ApiClient {
|
||||
const client = axios.create({
|
||||
baseURL: options.baseURL ?? "",
|
||||
baseURL: options.baseURL ?? '',
|
||||
timeout: options.timeoutMs ?? 15000,
|
||||
withCredentials: false,
|
||||
});
|
||||
})
|
||||
|
||||
const auth = options.auth;
|
||||
let refreshPromise: Promise<AuthTokens> | null = null;
|
||||
const auth = options.auth
|
||||
let refreshPromise: Promise<AuthTokens> | null = null
|
||||
|
||||
client.interceptors.request.use((config) => {
|
||||
if (!auth) {
|
||||
return config;
|
||||
return config
|
||||
}
|
||||
|
||||
const accessToken = auth.getAccessToken();
|
||||
const accessToken = auth.getAccessToken()
|
||||
if (accessToken) {
|
||||
config.headers = config.headers ?? {};
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${accessToken}`;
|
||||
config.headers = config.headers ?? {}
|
||||
;(config.headers as Record<string, string>).Authorization = `Bearer ${accessToken}`
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
return config
|
||||
})
|
||||
|
||||
client.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error: AxiosError<ApiErrorEnvelope>) => {
|
||||
if (auth && error.response?.status === 401 && error.config) {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||
_retry?: boolean;
|
||||
};
|
||||
const refreshToken = auth.getRefreshToken();
|
||||
_retry?: boolean
|
||||
}
|
||||
const refreshToken = auth.getRefreshToken()
|
||||
|
||||
if (refreshToken && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
originalRequest._retry = true
|
||||
|
||||
try {
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = auth
|
||||
.refresh(refreshToken)
|
||||
.then((tokens) => {
|
||||
auth.setTokens(tokens);
|
||||
return tokens;
|
||||
auth.setTokens(tokens)
|
||||
return tokens
|
||||
})
|
||||
.finally(() => {
|
||||
refreshPromise = null;
|
||||
});
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
const nextTokens = await refreshPromise;
|
||||
originalRequest.headers = originalRequest.headers ?? {};
|
||||
(originalRequest.headers as Record<string, string>).Authorization =
|
||||
`Bearer ${nextTokens.accessToken}`;
|
||||
return client.request(originalRequest);
|
||||
const nextTokens = await refreshPromise
|
||||
originalRequest.headers = originalRequest.headers ?? {}
|
||||
;(originalRequest.headers as Record<string, string>).Authorization =
|
||||
`Bearer ${nextTokens.accessToken}`
|
||||
return client.request(originalRequest)
|
||||
} catch (refreshError) {
|
||||
const normalizedRefreshError = normalizeError(refreshError);
|
||||
const normalizedRefreshError = normalizeError(refreshError)
|
||||
if (normalizedRefreshError.status === 401) {
|
||||
auth.clearTokens();
|
||||
auth.clearTokens()
|
||||
}
|
||||
throw normalizedRefreshError;
|
||||
throw normalizedRefreshError
|
||||
}
|
||||
}
|
||||
|
||||
auth.clearTokens();
|
||||
auth.clearTokens()
|
||||
}
|
||||
|
||||
throw normalizeError(error);
|
||||
throw normalizeError(error)
|
||||
},
|
||||
);
|
||||
)
|
||||
|
||||
return {
|
||||
raw: client,
|
||||
async get<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return unwrapEnvelope(await client.get<ApiEnvelope<T>>(url, config));
|
||||
return unwrapEnvelope(await client.get<ApiEnvelope<T>>(url, config))
|
||||
},
|
||||
async post<T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) {
|
||||
return unwrapEnvelope(await client.post<ApiEnvelope<T>>(url, body, config));
|
||||
return unwrapEnvelope(await client.post<ApiEnvelope<T>>(url, body, config))
|
||||
},
|
||||
async put<T, B = unknown>(url: string, body?: B, config?: AxiosRequestConfig) {
|
||||
return unwrapEnvelope(await client.put<ApiEnvelope<T>>(url, body, config));
|
||||
return unwrapEnvelope(await client.put<ApiEnvelope<T>>(url, body, config))
|
||||
},
|
||||
async remove<T>(url: string, config?: AxiosRequestConfig) {
|
||||
return unwrapEnvelope(await client.delete<ApiEnvelope<T>>(url, config));
|
||||
return unwrapEnvelope(await client.delete<ApiEnvelope<T>>(url, config))
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,274 +1,279 @@
|
||||
const BAIJIAHAO_TABLE_STYLE = [
|
||||
"width: 100%",
|
||||
"border-collapse: collapse",
|
||||
"border-spacing: 0",
|
||||
"margin: 12px 0",
|
||||
"font-size: 15px",
|
||||
"line-height: 1.6",
|
||||
"color: #1f2937",
|
||||
].join("; ");
|
||||
'width: 100%',
|
||||
'border-collapse: collapse',
|
||||
'border-spacing: 0',
|
||||
'margin: 12px 0',
|
||||
'font-size: 15px',
|
||||
'line-height: 1.6',
|
||||
'color: #1f2937',
|
||||
].join('; ')
|
||||
|
||||
const BAIJIAHAO_TABLE_CELL_STYLE = [
|
||||
"border: 1px solid #d9d9d9",
|
||||
"padding: 8px 10px",
|
||||
"vertical-align: middle",
|
||||
"word-break: break-word",
|
||||
"min-width: 64px",
|
||||
].join("; ");
|
||||
'border: 1px solid #d9d9d9',
|
||||
'padding: 8px 10px',
|
||||
'vertical-align: middle',
|
||||
'word-break: break-word',
|
||||
'min-width: 64px',
|
||||
].join('; ')
|
||||
|
||||
const BAIJIAHAO_TABLE_HEADER_CELL_STYLE = [
|
||||
BAIJIAHAO_TABLE_CELL_STYLE,
|
||||
"background-color: #f5f7fa",
|
||||
"font-weight: 600",
|
||||
].join("; ");
|
||||
'background-color: #f5f7fa',
|
||||
'font-weight: 600',
|
||||
].join('; ')
|
||||
|
||||
const TABLE_PARAGRAPH_CELL_SEPARATOR = " ";
|
||||
const TABLE_PARAGRAPH_CELL_SEPARATOR = ' '
|
||||
|
||||
const BAIJIAHAO_TABLE_ALLOWED_ATTRS = ["colspan", "rowspan", "align", "valign", "width"];
|
||||
const BAIJIAHAO_TABLE_ALLOWED_ATTRS = ['colspan', 'rowspan', 'align', 'valign', 'width']
|
||||
|
||||
type ParsedParagraphTableRow = string[];
|
||||
type ParsedParagraphTableRow = string[]
|
||||
|
||||
function hasUnescapedPipe(value: string): boolean {
|
||||
let escaped = false;
|
||||
let escaped = false
|
||||
for (const char of value) {
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
continue;
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
continue;
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
continue
|
||||
}
|
||||
if (char === "|") {
|
||||
return true;
|
||||
if (char === '|') {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
function splitMarkdownTableCells(value: string): string[] {
|
||||
const cells: string[] = [];
|
||||
let cell = "";
|
||||
let escaped = false;
|
||||
const trimmed = value.trim();
|
||||
const source = trimmed.startsWith("|") ? trimmed.slice(1) : trimmed;
|
||||
const content = source.endsWith("|") && !source.endsWith("\\|") ? source.slice(0, -1) : source;
|
||||
const cells: string[] = []
|
||||
let cell = ''
|
||||
let escaped = false
|
||||
const trimmed = value.trim()
|
||||
const source = trimmed.startsWith('|') ? trimmed.slice(1) : trimmed
|
||||
const content = source.endsWith('|') && !source.endsWith('\\|') ? source.slice(0, -1) : source
|
||||
|
||||
for (const char of content) {
|
||||
if (escaped) {
|
||||
cell += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
cell += char
|
||||
escaped = false
|
||||
continue
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
cell += char;
|
||||
continue;
|
||||
if (char === '\\') {
|
||||
escaped = true
|
||||
cell += char
|
||||
continue
|
||||
}
|
||||
if (char === "|") {
|
||||
cells.push(cell.trim());
|
||||
cell = "";
|
||||
continue;
|
||||
if (char === '|') {
|
||||
cells.push(cell.trim())
|
||||
cell = ''
|
||||
continue
|
||||
}
|
||||
cell += char;
|
||||
cell += char
|
||||
}
|
||||
|
||||
cells.push(cell.trim());
|
||||
return cells;
|
||||
cells.push(cell.trim())
|
||||
return cells
|
||||
}
|
||||
|
||||
function isMarkdownTableRow(value: string): boolean {
|
||||
const trimmed = value.trim();
|
||||
return trimmed !== "" && hasUnescapedPipe(trimmed) && splitMarkdownTableCells(trimmed).length >= 2;
|
||||
const trimmed = value.trim()
|
||||
return trimmed !== '' && hasUnescapedPipe(trimmed) && splitMarkdownTableCells(trimmed).length >= 2
|
||||
}
|
||||
|
||||
function isMarkdownTableSeparator(value: string): boolean {
|
||||
const cells = splitMarkdownTableCells(value).filter(Boolean);
|
||||
return cells.length >= 2 && cells.every((cell) => /^:?-{2,}:?$/.test(cell.trim()));
|
||||
const cells = splitMarkdownTableCells(value).filter(Boolean)
|
||||
return cells.length >= 2 && cells.every((cell) => /^:?-{2,}:?$/.test(cell.trim()))
|
||||
}
|
||||
|
||||
function isMarkdownTableStart(lines: string[], index: number): boolean {
|
||||
return isMarkdownTableRow(lines[index] ?? "") && isMarkdownTableSeparator(lines[index + 1] ?? "");
|
||||
return isMarkdownTableRow(lines[index] ?? '') && isMarkdownTableSeparator(lines[index + 1] ?? '')
|
||||
}
|
||||
|
||||
function isFenceBoundary(value: string): boolean {
|
||||
return /^\s*(```|~~~)/.test(value);
|
||||
return /^\s*(```|~~~)/.test(value)
|
||||
}
|
||||
|
||||
export function prepareBaijiahaoMarkdown(markdown: string): string {
|
||||
const source = markdown.replace(/\r\n?/g, "\n").trim();
|
||||
const source = markdown.replace(/\r\n?/g, '\n').trim()
|
||||
if (!source) {
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
const lines = source.split("\n");
|
||||
const output: string[] = [];
|
||||
let inCodeFence = false;
|
||||
const lines = source.split('\n')
|
||||
const output: string[] = []
|
||||
let inCodeFence = false
|
||||
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index] ?? "";
|
||||
const line = lines[index] ?? ''
|
||||
if (isFenceBoundary(line)) {
|
||||
inCodeFence = !inCodeFence;
|
||||
output.push(line);
|
||||
continue;
|
||||
inCodeFence = !inCodeFence
|
||||
output.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
if (!inCodeFence && isMarkdownTableStart(lines, index)) {
|
||||
while (output.length > 0 && output[output.length - 1]?.trim() === "") {
|
||||
output.pop();
|
||||
while (output.length > 0 && output[output.length - 1]?.trim() === '') {
|
||||
output.pop()
|
||||
}
|
||||
if (/^\s*#{1,6}\s*$/.test(output[output.length - 1] ?? "")) {
|
||||
output.pop();
|
||||
if (/^\s*#{1,6}\s*$/.test(output[output.length - 1] ?? '')) {
|
||||
output.pop()
|
||||
}
|
||||
if (output.length > 0 && output[output.length - 1]?.trim() !== "") {
|
||||
output.push("");
|
||||
if (output.length > 0 && output[output.length - 1]?.trim() !== '') {
|
||||
output.push('')
|
||||
}
|
||||
|
||||
output.push(lines[index] ?? "");
|
||||
output.push(lines[index + 1] ?? "");
|
||||
index += 2;
|
||||
output.push(lines[index] ?? '')
|
||||
output.push(lines[index + 1] ?? '')
|
||||
index += 2
|
||||
|
||||
while (index < lines.length && isMarkdownTableRow(lines[index] ?? "")) {
|
||||
output.push(lines[index] ?? "");
|
||||
index += 1;
|
||||
while (index < lines.length && isMarkdownTableRow(lines[index] ?? '')) {
|
||||
output.push(lines[index] ?? '')
|
||||
index += 1
|
||||
}
|
||||
|
||||
if (index < lines.length && lines[index]?.trim() !== "") {
|
||||
output.push("");
|
||||
if (index < lines.length && lines[index]?.trim() !== '') {
|
||||
output.push('')
|
||||
}
|
||||
index -= 1;
|
||||
continue;
|
||||
index -= 1
|
||||
continue
|
||||
}
|
||||
|
||||
output.push(line);
|
||||
output.push(line)
|
||||
}
|
||||
|
||||
return output.join("\n").trim();
|
||||
return output.join('\n').trim()
|
||||
}
|
||||
|
||||
function escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
}
|
||||
|
||||
function extractAttribute(sourceTag: string, attributeName: string): string | null {
|
||||
const matcher = new RegExp(`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
|
||||
const match = matcher.exec(sourceTag);
|
||||
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
const matcher = new RegExp(
|
||||
`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`,
|
||||
'i',
|
||||
)
|
||||
const match = matcher.exec(sourceTag)
|
||||
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? ''
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function allowedTableAttributes(sourceTag: string): string {
|
||||
return BAIJIAHAO_TABLE_ALLOWED_ATTRS
|
||||
.map((attributeName) => {
|
||||
const value = extractAttribute(sourceTag, attributeName);
|
||||
return value ? `${attributeName}="${escapeAttribute(value)}"` : "";
|
||||
})
|
||||
return BAIJIAHAO_TABLE_ALLOWED_ATTRS.map((attributeName) => {
|
||||
const value = extractAttribute(sourceTag, attributeName)
|
||||
return value ? `${attributeName}="${escapeAttribute(value)}"` : ''
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function normalizeCellContent(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : "<br/>";
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : '<br/>'
|
||||
}
|
||||
|
||||
function normalizeTableCell(sourceTag: string, content: string, header: boolean): string {
|
||||
const attrs = allowedTableAttributes(sourceTag);
|
||||
const attrPrefix = attrs ? `${attrs} ` : "";
|
||||
const style = header ? BAIJIAHAO_TABLE_HEADER_CELL_STYLE : BAIJIAHAO_TABLE_CELL_STYLE;
|
||||
const normalizedContent = normalizeCellContent(content);
|
||||
const cellContent = header ? `<strong>${normalizedContent}</strong>` : normalizedContent;
|
||||
return `<td ${attrPrefix}style="${style}">${cellContent}</td>`;
|
||||
const attrs = allowedTableAttributes(sourceTag)
|
||||
const attrPrefix = attrs ? `${attrs} ` : ''
|
||||
const style = header ? BAIJIAHAO_TABLE_HEADER_CELL_STYLE : BAIJIAHAO_TABLE_CELL_STYLE
|
||||
const normalizedContent = normalizeCellContent(content)
|
||||
const cellContent = header ? `<strong>${normalizedContent}</strong>` : normalizedContent
|
||||
return `<td ${attrPrefix}style="${style}">${cellContent}</td>`
|
||||
}
|
||||
|
||||
function normalizeSingleTable(tableHtml: string): string {
|
||||
let body = tableHtml
|
||||
.replace(/^\s*<table\b[^>]*>/i, "")
|
||||
.replace(/<\/table>\s*$/i, "")
|
||||
.replace(/<caption\b[^>]*>[\s\S]*?<\/caption>/gi, "")
|
||||
.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, "")
|
||||
.replace(/<col\b[^>]*\/?>/gi, "")
|
||||
.replace(/<\/?(?:thead|tbody|tfoot)\b[^>]*>/gi, "");
|
||||
.replace(/^\s*<table\b[^>]*>/i, '')
|
||||
.replace(/<\/table>\s*$/i, '')
|
||||
.replace(/<caption\b[^>]*>[\s\S]*?<\/caption>/gi, '')
|
||||
.replace(/<colgroup\b[^>]*>[\s\S]*?<\/colgroup>/gi, '')
|
||||
.replace(/<col\b[^>]*\/?>/gi, '')
|
||||
.replace(/<\/?(?:thead|tbody|tfoot)\b[^>]*>/gi, '')
|
||||
|
||||
body = body.replace(/<th\b([^>]*)>([\s\S]*?)<\/th>/gi, (_match, attrs: string, content: string) =>
|
||||
normalizeTableCell(`<th${attrs}>`, content, true),
|
||||
);
|
||||
)
|
||||
body = body.replace(/<td\b([^>]*)>([\s\S]*?)<\/td>/gi, (_match, attrs: string, content: string) =>
|
||||
normalizeTableCell(`<td${attrs}>`, content, false),
|
||||
);
|
||||
)
|
||||
|
||||
let rowIndex = 0;
|
||||
let rowIndex = 0
|
||||
body = body.replace(/<tr\b[^>]*>/gi, () => {
|
||||
rowIndex += 1;
|
||||
return rowIndex === 1 ? `<tr class="firstRow">` : "<tr>";
|
||||
});
|
||||
rowIndex += 1
|
||||
return rowIndex === 1 ? `<tr class="firstRow">` : '<tr>'
|
||||
})
|
||||
|
||||
return `<table data-sort="sortDisabled" border="1" cellpadding="0" cellspacing="0" style="${BAIJIAHAO_TABLE_STYLE}"><tbody>${body.trim()}</tbody></table>`;
|
||||
return `<table data-sort="sortDisabled" border="1" cellpadding="0" cellspacing="0" style="${BAIJIAHAO_TABLE_STYLE}"><tbody>${body.trim()}</tbody></table>`
|
||||
}
|
||||
|
||||
export function normalizeBaijiahaoTables(html: string): string {
|
||||
if (!/<table\b/i.test(html)) {
|
||||
return html;
|
||||
return html
|
||||
}
|
||||
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) => normalizeSingleTable(tableHtml));
|
||||
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) =>
|
||||
normalizeSingleTable(tableHtml),
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeParagraphCellContent(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.replace(/<br\s*\/?>/gi, " ")
|
||||
.replace(/<\/?(?:p|div|section|article|header|footer|ul|ol|li|blockquote|h[1-6])\b[^>]*>/gi, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
.replace(/<br\s*\/?>/gi, ' ')
|
||||
.replace(
|
||||
/<\/?(?:p|div|section|article|header|footer|ul|ol|li|blockquote|h[1-6])\b[^>]*>/gi,
|
||||
' ',
|
||||
)
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
}
|
||||
|
||||
function parseParagraphTableRows(tableHtml: string): ParsedParagraphTableRow[] {
|
||||
const rows: ParsedParagraphTableRow[] = [];
|
||||
const rows: ParsedParagraphTableRow[] = []
|
||||
for (const rowMatch of tableHtml.matchAll(/<tr\b[^>]*>([\s\S]*?)<\/tr>/gi)) {
|
||||
const cells: ParsedParagraphTableRow = [];
|
||||
const rowContent = rowMatch[1] ?? "";
|
||||
const cells: ParsedParagraphTableRow = []
|
||||
const rowContent = rowMatch[1] ?? ''
|
||||
for (const cellMatch of rowContent.matchAll(/<(?:th|td)\b[^>]*>([\s\S]*?)<\/(?:th|td)>/gi)) {
|
||||
const content = normalizeParagraphCellContent(cellMatch[1] ?? "");
|
||||
const content = normalizeParagraphCellContent(cellMatch[1] ?? '')
|
||||
if (content) {
|
||||
cells.push(content);
|
||||
cells.push(content)
|
||||
}
|
||||
}
|
||||
if (cells.length > 0) {
|
||||
rows.push(cells);
|
||||
rows.push(cells)
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
return rows
|
||||
}
|
||||
|
||||
function renderSingleTableAsParagraphRows(tableHtml: string): string {
|
||||
const rows = parseParagraphTableRows(tableHtml);
|
||||
const rows = parseParagraphTableRows(tableHtml)
|
||||
if (!rows.length) {
|
||||
return "";
|
||||
return ''
|
||||
}
|
||||
|
||||
return rows
|
||||
.map((row) => `<p>${row.join(TABLE_PARAGRAPH_CELL_SEPARATOR)}</p>`)
|
||||
.join("");
|
||||
return rows.map((row) => `<p>${row.join(TABLE_PARAGRAPH_CELL_SEPARATOR)}</p>`).join('')
|
||||
}
|
||||
|
||||
export function renderTablesAsParagraphRows(html: string): string {
|
||||
if (!/<table\b/i.test(html)) {
|
||||
return html;
|
||||
return html
|
||||
}
|
||||
return html.replace(/<table\b[^>]*>[\s\S]*?<\/table>/gi, (tableHtml) =>
|
||||
renderSingleTableAsParagraphRows(tableHtml),
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
export function renderBaijiahaoTablesAsBlocks(html: string): string {
|
||||
return renderTablesAsParagraphRows(html);
|
||||
return renderTablesAsParagraphRows(html)
|
||||
}
|
||||
|
||||
export function prepareBaijiahaoArticleHtml(html: string): string {
|
||||
return renderTablesAsParagraphRows(html).trim();
|
||||
return renderTablesAsParagraphRows(html).trim()
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export * from "./baijiahao";
|
||||
export * from "./jianshu";
|
||||
export * from "./qiehao";
|
||||
export * from "./toutiao";
|
||||
export * from './baijiahao'
|
||||
export * from './jianshu'
|
||||
export * from './qiehao'
|
||||
export * from './toutiao'
|
||||
|
||||
@@ -1,193 +1,193 @@
|
||||
type JianshuCurrentUserResponse = {
|
||||
id?: number;
|
||||
nickname?: string;
|
||||
avatar?: string;
|
||||
preferred_note_type?: "plain" | "markdown";
|
||||
};
|
||||
id?: number
|
||||
nickname?: string
|
||||
avatar?: string
|
||||
preferred_note_type?: 'plain' | 'markdown'
|
||||
}
|
||||
|
||||
type JianshuNotebookSummary = {
|
||||
id?: number;
|
||||
name?: string;
|
||||
};
|
||||
id?: number
|
||||
name?: string
|
||||
}
|
||||
|
||||
type JianshuNoteCreateResponse = {
|
||||
id?: number;
|
||||
slug?: string;
|
||||
};
|
||||
id?: number
|
||||
slug?: string
|
||||
}
|
||||
|
||||
type JianshuTokenResponse = {
|
||||
token?: string;
|
||||
key?: string;
|
||||
};
|
||||
token?: string
|
||||
key?: string
|
||||
}
|
||||
|
||||
type JianshuQiniuResponse = {
|
||||
url?: string;
|
||||
};
|
||||
url?: string
|
||||
}
|
||||
|
||||
type JianshuPublicizeResponse = {
|
||||
id?: number;
|
||||
slug?: string;
|
||||
error?: Array<{ message?: string }>;
|
||||
};
|
||||
id?: number
|
||||
slug?: string
|
||||
error?: Array<{ message?: string }>
|
||||
}
|
||||
|
||||
export type JianshuNoteType = "plain" | "markdown";
|
||||
export type JianshuNoteType = 'plain' | 'markdown'
|
||||
|
||||
export interface JianshuMediaSnapshot {
|
||||
platformUid: string | null;
|
||||
screenName: string | null;
|
||||
avatarUrl: string | null;
|
||||
preferredNoteType: JianshuNoteType;
|
||||
platformUid: string | null
|
||||
screenName: string | null
|
||||
avatarUrl: string | null
|
||||
preferredNoteType: JianshuNoteType
|
||||
}
|
||||
|
||||
export interface JianshuPublishArticleInput {
|
||||
title: string;
|
||||
html: string;
|
||||
markdown: string | null;
|
||||
coverAssetUrl?: string | null;
|
||||
title: string
|
||||
html: string
|
||||
markdown: string | null
|
||||
coverAssetUrl?: string | null
|
||||
}
|
||||
|
||||
export interface JianshuPublishTransport {
|
||||
fetchJson<T>(input: string, init?: RequestInit): Promise<T>;
|
||||
fetchImageBlob(sourceUrl: string): Promise<Blob | null>;
|
||||
fetchJson<T>(input: string, init?: RequestInit): Promise<T>
|
||||
fetchImageBlob(sourceUrl: string): Promise<Blob | null>
|
||||
reportProgress?(
|
||||
stage:
|
||||
| "media_info"
|
||||
| "fetch_notebook"
|
||||
| "create_note"
|
||||
| "upload_content_images"
|
||||
| "save_content"
|
||||
| "upload_cover"
|
||||
| "publish",
|
||||
): void;
|
||||
| 'media_info'
|
||||
| 'fetch_notebook'
|
||||
| 'create_note'
|
||||
| 'upload_content_images'
|
||||
| 'save_content'
|
||||
| 'upload_cover'
|
||||
| 'publish',
|
||||
): void
|
||||
}
|
||||
|
||||
export type JianshuPublishResult =
|
||||
| {
|
||||
success: true;
|
||||
status: "success";
|
||||
articleId: string;
|
||||
mediaName: string;
|
||||
externalManageUrl: string;
|
||||
externalArticleUrl: string | null;
|
||||
message: string;
|
||||
success: true
|
||||
status: 'success'
|
||||
articleId: string
|
||||
mediaName: string
|
||||
externalManageUrl: string
|
||||
externalArticleUrl: string | null
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
status: "failed";
|
||||
success: false
|
||||
status: 'failed'
|
||||
code:
|
||||
| "jianshu_not_logged_in"
|
||||
| "article_content_empty"
|
||||
| "jianshu_no_notebook"
|
||||
| "jianshu_create_note_failed"
|
||||
| "jianshu_publish_failed";
|
||||
message: string;
|
||||
};
|
||||
| 'jianshu_not_logged_in'
|
||||
| 'article_content_empty'
|
||||
| 'jianshu_no_notebook'
|
||||
| 'jianshu_create_note_failed'
|
||||
| 'jianshu_publish_failed'
|
||||
message: string
|
||||
}
|
||||
|
||||
const JIANSHU_ORIGIN = "https://www.jianshu.com";
|
||||
const QINIU_UPLOAD_ENDPOINT = "https://upload.qiniup.com";
|
||||
const JIANSHU_ORIGIN = 'https://www.jianshu.com'
|
||||
const QINIU_UPLOAD_ENDPOINT = 'https://upload.qiniup.com'
|
||||
|
||||
function randomFilenameStem(): string {
|
||||
const ts = Date.now().toString(36);
|
||||
const rand = Math.random().toString(36).slice(2, 10);
|
||||
return `${ts}-${rand}`;
|
||||
const ts = Date.now().toString(36)
|
||||
const rand = Math.random().toString(36).slice(2, 10)
|
||||
return `${ts}-${rand}`
|
||||
}
|
||||
|
||||
function jianshuApiHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return {
|
||||
Accept: "application/json",
|
||||
Accept: 'application/json',
|
||||
Referer: `${JIANSHU_ORIGIN}/`,
|
||||
Origin: JIANSHU_ORIGIN,
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function jianshuJsonHeaders(extra: Record<string, string> = {}): Record<string, string> {
|
||||
return jianshuApiHeaders({
|
||||
"Content-Type": "application/json",
|
||||
'Content-Type': 'application/json',
|
||||
...extra,
|
||||
});
|
||||
})
|
||||
}
|
||||
|
||||
function parseJianshuErrorMessage(error: unknown, fallback: string): string {
|
||||
if (error instanceof Error && error.message) {
|
||||
try {
|
||||
const parsed = JSON.parse(error.message) as {
|
||||
error?: Array<{ message?: string }>;
|
||||
message?: string;
|
||||
};
|
||||
const apiMessage = parsed?.error?.[0]?.message ?? parsed?.message;
|
||||
error?: Array<{ message?: string }>
|
||||
message?: string
|
||||
}
|
||||
const apiMessage = parsed?.error?.[0]?.message ?? parsed?.message
|
||||
if (apiMessage) {
|
||||
return apiMessage;
|
||||
return apiMessage
|
||||
}
|
||||
} catch {
|
||||
// not JSON — fall through to raw message
|
||||
}
|
||||
return error.message;
|
||||
return error.message
|
||||
}
|
||||
return fallback;
|
||||
return fallback
|
||||
}
|
||||
|
||||
function extractHtmlAttribute(sourceTag: string, attributeName: string): string | null {
|
||||
const matcher = new RegExp(`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`, "i");
|
||||
const match = matcher.exec(sourceTag);
|
||||
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? "";
|
||||
const trimmed = value.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
const matcher = new RegExp(
|
||||
`\\s${attributeName}\\s*=\\s*(?:"([^"]*)"|'([^']*)'|([^\\s"'>]+))`,
|
||||
'i',
|
||||
)
|
||||
const match = matcher.exec(sourceTag)
|
||||
const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? ''
|
||||
const trimmed = value.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function escapeMarkdownAlt(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/\]/g, "\\]");
|
||||
return value.replace(/\\/g, '\\\\').replace(/\]/g, '\\]')
|
||||
}
|
||||
|
||||
function markdownImageFromHtmlTag(imgTag: string): string {
|
||||
const src = extractHtmlAttribute(imgTag, "src");
|
||||
const src = extractHtmlAttribute(imgTag, 'src')
|
||||
if (!src) {
|
||||
return imgTag;
|
||||
return imgTag
|
||||
}
|
||||
|
||||
const alt = extractHtmlAttribute(imgTag, "alt") ?? "";
|
||||
return ``;
|
||||
const alt = extractHtmlAttribute(imgTag, 'alt') ?? ''
|
||||
return ``
|
||||
}
|
||||
|
||||
export function renderJianshuHtmlImagesAsMarkdown(content: string): string {
|
||||
if (!/<img\b/i.test(content)) {
|
||||
return content;
|
||||
return content
|
||||
}
|
||||
|
||||
let next = content.replace(
|
||||
/<p\b[^>]*>\s*(<img\b[^>]*\/?>)\s*<\/p>/gi,
|
||||
(_match, imgTag: string) => `\n\n${markdownImageFromHtmlTag(imgTag)}\n\n`,
|
||||
);
|
||||
)
|
||||
|
||||
next = next.replace(
|
||||
/<img\b[^>]*\/?>/gi,
|
||||
(imgTag) => `\n\n${markdownImageFromHtmlTag(imgTag)}\n\n`,
|
||||
);
|
||||
)
|
||||
|
||||
return next.replace(/\n{3,}/g, "\n\n").trim();
|
||||
return next.replace(/\n{3,}/g, '\n\n').trim()
|
||||
}
|
||||
|
||||
export async function fetchJianshuMediaSnapshot(
|
||||
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
||||
): Promise<JianshuMediaSnapshot | null> {
|
||||
const me = await fetchJson<JianshuCurrentUserResponse>(
|
||||
`${JIANSHU_ORIGIN}/author/current_user`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
const me = await fetchJson<JianshuCurrentUserResponse>(`${JIANSHU_ORIGIN}/author/current_user`, {
|
||||
method: 'GET',
|
||||
headers: jianshuApiHeaders(),
|
||||
}).catch(() => null)
|
||||
|
||||
if (!me?.id) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
platformUid: String(me.id),
|
||||
screenName: me.nickname ?? null,
|
||||
avatarUrl: me.avatar ?? null,
|
||||
preferredNoteType: me.preferred_note_type === "markdown" ? "markdown" : "plain",
|
||||
};
|
||||
preferredNoteType: me.preferred_note_type === 'markdown' ? 'markdown' : 'plain',
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchPrimaryNotebookId(
|
||||
@@ -196,13 +196,13 @@ async function fetchPrimaryNotebookId(
|
||||
const notebooks = await fetchJson<JianshuNotebookSummary[]>(
|
||||
`${JIANSHU_ORIGIN}/author/notebooks`,
|
||||
{
|
||||
method: "GET",
|
||||
method: 'GET',
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
).catch(() => null)
|
||||
|
||||
const first = notebooks?.find((item) => typeof item?.id === "number");
|
||||
return first?.id ?? null;
|
||||
const first = notebooks?.find((item) => typeof item?.id === 'number')
|
||||
return first?.id ?? null
|
||||
}
|
||||
|
||||
async function createNote(
|
||||
@@ -210,102 +210,100 @@ async function createNote(
|
||||
notebookId: number,
|
||||
title: string,
|
||||
): Promise<JianshuNoteCreateResponse | null> {
|
||||
return await fetchJson<JianshuNoteCreateResponse>(
|
||||
`${JIANSHU_ORIGIN}/author/notes`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jianshuJsonHeaders(),
|
||||
body: JSON.stringify({
|
||||
notebook_id: notebookId,
|
||||
title,
|
||||
at_bottom: true,
|
||||
}),
|
||||
},
|
||||
).catch(() => null);
|
||||
return await fetchJson<JianshuNoteCreateResponse>(`${JIANSHU_ORIGIN}/author/notes`, {
|
||||
method: 'POST',
|
||||
headers: jianshuJsonHeaders(),
|
||||
body: JSON.stringify({
|
||||
notebook_id: notebookId,
|
||||
title,
|
||||
at_bottom: true,
|
||||
}),
|
||||
}).catch(() => null)
|
||||
}
|
||||
|
||||
async function uploadImageToQiniu(
|
||||
transport: JianshuPublishTransport,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await transport.fetchImageBlob(sourceUrl);
|
||||
if (!blob || !blob.type.startsWith("image/")) {
|
||||
return null;
|
||||
const blob = await transport.fetchImageBlob(sourceUrl)
|
||||
if (!blob || !blob.type.startsWith('image/')) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filename = `${randomFilenameStem()}.png`;
|
||||
const token = await transport.fetchJson<JianshuTokenResponse>(
|
||||
`${JIANSHU_ORIGIN}/upload_images/token.json?filename=${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: "GET",
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
const filename = `${randomFilenameStem()}.png`
|
||||
const token = await transport
|
||||
.fetchJson<JianshuTokenResponse>(
|
||||
`${JIANSHU_ORIGIN}/upload_images/token.json?filename=${encodeURIComponent(filename)}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
if (!token?.token || !token.key) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("token", token.token);
|
||||
form.append("key", token.key);
|
||||
form.append("file", blob, filename);
|
||||
form.append("x:protocol", "https");
|
||||
const form = new FormData()
|
||||
form.append('token', token.token)
|
||||
form.append('key', token.key)
|
||||
form.append('file', blob, filename)
|
||||
form.append('x:protocol', 'https')
|
||||
|
||||
const uploaded = await transport.fetchJson<JianshuQiniuResponse>(
|
||||
QINIU_UPLOAD_ENDPOINT,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Accept: "application/json" },
|
||||
const uploaded = await transport
|
||||
.fetchJson<JianshuQiniuResponse>(QINIU_UPLOAD_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { Accept: 'application/json' },
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
return uploaded?.url ?? null;
|
||||
return uploaded?.url ?? null
|
||||
}
|
||||
|
||||
function collectImageSources(content: string): string[] {
|
||||
const sources = new Set<string>();
|
||||
const sources = new Set<string>()
|
||||
for (const match of content.matchAll(/<img\b[^>]*src=(['"])(.*?)\1[^>]*>/gi)) {
|
||||
const src = match[2]?.trim();
|
||||
const src = match[2]?.trim()
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
sources.add(src)
|
||||
}
|
||||
}
|
||||
for (const match of content.matchAll(/!\[[^\]]*\]\(([^)\s]+)(?:\s+"[^"]*")?\)/g)) {
|
||||
const src = match[1]?.trim();
|
||||
const src = match[1]?.trim()
|
||||
if (src) {
|
||||
sources.add(src);
|
||||
sources.add(src)
|
||||
}
|
||||
}
|
||||
return [...sources];
|
||||
return [...sources]
|
||||
}
|
||||
|
||||
async function processContentImages(
|
||||
transport: JianshuPublishTransport,
|
||||
content: string,
|
||||
): Promise<string> {
|
||||
const sources = collectImageSources(content);
|
||||
const sources = collectImageSources(content)
|
||||
if (!sources.length) {
|
||||
return content;
|
||||
return content
|
||||
}
|
||||
|
||||
const replacements = new Map<string, string>();
|
||||
const replacements = new Map<string, string>()
|
||||
for (const source of sources) {
|
||||
if (/jianshu\.io|sfx\.jianshu/i.test(source)) {
|
||||
continue;
|
||||
continue
|
||||
}
|
||||
const uploaded = await uploadImageToQiniu(transport, source);
|
||||
const uploaded = await uploadImageToQiniu(transport, source)
|
||||
if (uploaded) {
|
||||
replacements.set(source, uploaded);
|
||||
replacements.set(source, uploaded)
|
||||
}
|
||||
}
|
||||
|
||||
let next = content;
|
||||
let next = content
|
||||
for (const [from, to] of replacements.entries()) {
|
||||
next = next.split(from).join(to);
|
||||
next = next.split(from).join(to)
|
||||
}
|
||||
return renderJianshuHtmlImagesAsMarkdown(next);
|
||||
return renderJianshuHtmlImagesAsMarkdown(next)
|
||||
}
|
||||
|
||||
async function attachCoverImage(
|
||||
@@ -313,145 +311,143 @@ async function attachCoverImage(
|
||||
noteId: number,
|
||||
coverAssetUrl: string,
|
||||
): Promise<void> {
|
||||
const coverUrl = await uploadImageToQiniu(transport, coverAssetUrl);
|
||||
const coverUrl = await uploadImageToQiniu(transport, coverAssetUrl)
|
||||
if (!coverUrl) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
await transport.fetchJson<unknown>(
|
||||
`${JIANSHU_ORIGIN}/author/notes/${noteId}/update_cover_images`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: jianshuJsonHeaders({ "Content-Type": "application/json; charset=UTF-8" }),
|
||||
await transport
|
||||
.fetchJson<unknown>(`${JIANSHU_ORIGIN}/author/notes/${noteId}/update_cover_images`, {
|
||||
method: 'POST',
|
||||
headers: jianshuJsonHeaders({ 'Content-Type': 'application/json; charset=UTF-8' }),
|
||||
body: JSON.stringify({
|
||||
flow_type: 14,
|
||||
abbr: "",
|
||||
abbr: '',
|
||||
cover_image_urls: [coverUrl],
|
||||
}),
|
||||
},
|
||||
).catch(() => null);
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
async function softDestroyNote(
|
||||
transport: JianshuPublishTransport,
|
||||
noteId: number,
|
||||
): Promise<void> {
|
||||
await transport.fetchJson<unknown>(
|
||||
`${JIANSHU_ORIGIN}/author/notes/${noteId}/soft_destroy`,
|
||||
{
|
||||
method: "POST",
|
||||
async function softDestroyNote(transport: JianshuPublishTransport, noteId: number): Promise<void> {
|
||||
await transport
|
||||
.fetchJson<unknown>(`${JIANSHU_ORIGIN}/author/notes/${noteId}/soft_destroy`, {
|
||||
method: 'POST',
|
||||
headers: jianshuApiHeaders(),
|
||||
},
|
||||
).catch(() => null);
|
||||
})
|
||||
.catch(() => null)
|
||||
}
|
||||
|
||||
function buildExternalManageUrl(notebookId: number, noteId: number): string {
|
||||
return `${JIANSHU_ORIGIN}/writer#/notebooks/${notebookId}/notes/${noteId}`;
|
||||
return `${JIANSHU_ORIGIN}/writer#/notebooks/${notebookId}/notes/${noteId}`
|
||||
}
|
||||
|
||||
function buildExternalArticleUrl(slug: string | null | undefined): string | null {
|
||||
return slug ? `${JIANSHU_ORIGIN}/p/${slug}` : null;
|
||||
return slug ? `${JIANSHU_ORIGIN}/p/${slug}` : null
|
||||
}
|
||||
|
||||
function sanitizeJianshuHtml(html: string): string {
|
||||
let next = html;
|
||||
let next = html
|
||||
|
||||
next = next.replace(/<(script|style|iframe|noscript|link|meta)\b[^>]*>[\s\S]*?<\/\1>/gi, "");
|
||||
next = next.replace(/<(script|style|iframe|noscript|link|meta)\b[^>]*\/?>/gi, "");
|
||||
next = next.replace(/<(script|style|iframe|noscript|link|meta)\b[^>]*>[\s\S]*?<\/\1>/gi, '')
|
||||
next = next.replace(/<(script|style|iframe|noscript|link|meta)\b[^>]*\/?>/gi, '')
|
||||
|
||||
next = next.replace(/<section\b/gi, "<div").replace(/<\/section>/gi, "</div>");
|
||||
next = next.replace(/<section\b/gi, '<div').replace(/<\/section>/gi, '</div>')
|
||||
|
||||
next = next.replace(/<figure[^>]*>\s*(<img\b[^>]*>)\s*(?:<figcaption[^>]*>[\s\S]*?<\/figcaption>)?\s*<\/figure>/gi, "$1");
|
||||
next = next.replace(
|
||||
/<figure[^>]*>\s*(<img\b[^>]*>)\s*(?:<figcaption[^>]*>[\s\S]*?<\/figcaption>)?\s*<\/figure>/gi,
|
||||
'$1',
|
||||
)
|
||||
|
||||
next = next.replace(/\son[a-z]+="[^"]*"/gi, "");
|
||||
next = next.replace(/\sstyle="[^"]*"/gi, "");
|
||||
next = next.replace(/\sclass="[^"]*"/gi, "");
|
||||
next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, "");
|
||||
next = next.replace(/\son[a-z]+="[^"]*"/gi, '')
|
||||
next = next.replace(/\sstyle="[^"]*"/gi, '')
|
||||
next = next.replace(/\sclass="[^"]*"/gi, '')
|
||||
next = next.replace(/\sdata-[a-z-]+="[^"]*"/gi, '')
|
||||
|
||||
next = next.replace(/<table\b([^>]*)>([\s\S]*?)<\/table>/gi, (_match, attrs, inner) => {
|
||||
let body = inner;
|
||||
let body = inner
|
||||
if (!/<thead\b/i.test(body) && !/<tbody\b/i.test(body)) {
|
||||
body = `<tbody>${body}</tbody>`;
|
||||
body = `<tbody>${body}</tbody>`
|
||||
}
|
||||
return `<table${attrs}>${body}</table>`;
|
||||
});
|
||||
return `<table${attrs}>${body}</table>`
|
||||
})
|
||||
|
||||
return next.trim();
|
||||
return next.trim()
|
||||
}
|
||||
|
||||
function pickContent(
|
||||
input: JianshuPublishArticleInput,
|
||||
noteType: JianshuNoteType,
|
||||
): { content: string; usedMarkdown: boolean } {
|
||||
if (noteType === "markdown") {
|
||||
const md = input.markdown?.trim();
|
||||
if (noteType === 'markdown') {
|
||||
const md = input.markdown?.trim()
|
||||
if (md) {
|
||||
return { content: md, usedMarkdown: true };
|
||||
return { content: md, usedMarkdown: true }
|
||||
}
|
||||
}
|
||||
return {
|
||||
content: sanitizeJianshuHtml(input.html.trim()),
|
||||
usedMarkdown: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export async function publishJianshuArticle(
|
||||
input: JianshuPublishArticleInput,
|
||||
transport: JianshuPublishTransport,
|
||||
): Promise<JianshuPublishResult> {
|
||||
transport.reportProgress?.("media_info");
|
||||
const media = await fetchJianshuMediaSnapshot(transport.fetchJson);
|
||||
transport.reportProgress?.('media_info')
|
||||
const media = await fetchJianshuMediaSnapshot(transport.fetchJson)
|
||||
if (!media?.platformUid) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_not_logged_in",
|
||||
message: "未检测到简书登录态",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'jianshu_not_logged_in',
|
||||
message: '未检测到简书登录态',
|
||||
}
|
||||
}
|
||||
|
||||
const { content } = pickContent(input, media.preferredNoteType);
|
||||
const { content } = pickContent(input, media.preferredNoteType)
|
||||
if (!content) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "article_content_empty",
|
||||
message: "html_content/markdown_content is empty",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'article_content_empty',
|
||||
message: 'html_content/markdown_content is empty',
|
||||
}
|
||||
}
|
||||
|
||||
transport.reportProgress?.("fetch_notebook");
|
||||
const notebookId = await fetchPrimaryNotebookId(transport.fetchJson);
|
||||
transport.reportProgress?.('fetch_notebook')
|
||||
const notebookId = await fetchPrimaryNotebookId(transport.fetchJson)
|
||||
if (!notebookId) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_no_notebook",
|
||||
message: "没有找到文集,请先在简书创建一个文集",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'jianshu_no_notebook',
|
||||
message: '没有找到文集,请先在简书创建一个文集',
|
||||
}
|
||||
}
|
||||
|
||||
transport.reportProgress?.("create_note");
|
||||
const note = await createNote(transport.fetchJson, notebookId, input.title);
|
||||
transport.reportProgress?.('create_note')
|
||||
const note = await createNote(transport.fetchJson, notebookId, input.title)
|
||||
if (!note?.id) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_create_note_failed",
|
||||
message: "创建文章失败",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'jianshu_create_note_failed',
|
||||
message: '创建文章失败',
|
||||
}
|
||||
}
|
||||
|
||||
const noteId = note.id;
|
||||
let createdSlug = note.slug ?? null;
|
||||
let publishOk = false;
|
||||
const noteId = note.id
|
||||
let createdSlug = note.slug ?? null
|
||||
let publishOk = false
|
||||
|
||||
try {
|
||||
transport.reportProgress?.("upload_content_images");
|
||||
const processedContent = await processContentImages(transport, content);
|
||||
transport.reportProgress?.('upload_content_images')
|
||||
const processedContent = await processContentImages(transport, content)
|
||||
|
||||
transport.reportProgress?.("save_content");
|
||||
transport.reportProgress?.('save_content')
|
||||
await transport.fetchJson<unknown>(`${JIANSHU_ORIGIN}/author/notes/${noteId}`, {
|
||||
method: "PUT",
|
||||
method: 'PUT',
|
||||
headers: jianshuJsonHeaders(),
|
||||
body: JSON.stringify({
|
||||
id: noteId,
|
||||
@@ -459,56 +455,57 @@ export async function publishJianshuArticle(
|
||||
title: input.title,
|
||||
content: processedContent,
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
if (input.coverAssetUrl?.trim()) {
|
||||
transport.reportProgress?.("upload_cover");
|
||||
await attachCoverImage(transport, noteId, input.coverAssetUrl.trim());
|
||||
transport.reportProgress?.('upload_cover')
|
||||
await attachCoverImage(transport, noteId, input.coverAssetUrl.trim())
|
||||
}
|
||||
|
||||
transport.reportProgress?.("publish");
|
||||
const publicize = await transport.fetchJson<JianshuPublicizeResponse>(
|
||||
`${JIANSHU_ORIGIN}/author/notes/${noteId}/publicize`,
|
||||
{
|
||||
method: "POST",
|
||||
transport.reportProgress?.('publish')
|
||||
const publicize = await transport
|
||||
.fetchJson<JianshuPublicizeResponse>(`${JIANSHU_ORIGIN}/author/notes/${noteId}/publicize`, {
|
||||
method: 'POST',
|
||||
headers: jianshuJsonHeaders(),
|
||||
},
|
||||
).catch((error: unknown): JianshuPublicizeResponse => ({
|
||||
error: [{ message: parseJianshuErrorMessage(error, "jianshu_publish_failed") }],
|
||||
}));
|
||||
})
|
||||
.catch(
|
||||
(error: unknown): JianshuPublicizeResponse => ({
|
||||
error: [{ message: parseJianshuErrorMessage(error, 'jianshu_publish_failed') }],
|
||||
}),
|
||||
)
|
||||
|
||||
if (publicize.error?.length) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_publish_failed",
|
||||
message: publicize.error[0]?.message || "发布失败,请稍后重试",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'jianshu_publish_failed',
|
||||
message: publicize.error[0]?.message || '发布失败,请稍后重试',
|
||||
}
|
||||
}
|
||||
|
||||
publishOk = true;
|
||||
publishOk = true
|
||||
if (publicize.slug) {
|
||||
createdSlug = publicize.slug;
|
||||
createdSlug = publicize.slug
|
||||
}
|
||||
} catch (error) {
|
||||
if (!publishOk) {
|
||||
await softDestroyNote(transport, noteId);
|
||||
await softDestroyNote(transport, noteId)
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "jianshu_publish_failed",
|
||||
message: error instanceof Error ? error.message : "jianshu_publish_failed",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'jianshu_publish_failed',
|
||||
message: error instanceof Error ? error.message : 'jianshu_publish_failed',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: "success",
|
||||
status: 'success',
|
||||
articleId: String(noteId),
|
||||
mediaName: media.screenName ?? "",
|
||||
mediaName: media.screenName ?? '',
|
||||
externalManageUrl: buildExternalManageUrl(notebookId, noteId),
|
||||
externalArticleUrl: buildExternalArticleUrl(createdSlug),
|
||||
message: "简书发布成功。",
|
||||
};
|
||||
message: '简书发布成功。',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { renderTablesAsParagraphRows } from "./baijiahao";
|
||||
import { renderTablesAsParagraphRows } from './baijiahao'
|
||||
|
||||
export function prepareQiehaoArticleHtml(html: string): string {
|
||||
return renderTablesAsParagraphRows(html).trim();
|
||||
return renderTablesAsParagraphRows(html).trim()
|
||||
}
|
||||
|
||||
@@ -1,101 +1,101 @@
|
||||
type ToutiaoMediaInfoResponse = {
|
||||
data?: {
|
||||
user?: {
|
||||
id_str?: string;
|
||||
screen_name?: string;
|
||||
https_avatar_url?: string;
|
||||
};
|
||||
id_str?: string
|
||||
screen_name?: string
|
||||
https_avatar_url?: string
|
||||
}
|
||||
media?: {
|
||||
has_third_party_ad_permission?: boolean;
|
||||
has_toutiao_ad_permission?: boolean;
|
||||
};
|
||||
};
|
||||
code?: number;
|
||||
message?: string;
|
||||
};
|
||||
has_third_party_ad_permission?: boolean
|
||||
has_toutiao_ad_permission?: boolean
|
||||
}
|
||||
}
|
||||
code?: number
|
||||
message?: string
|
||||
}
|
||||
|
||||
type ToutiaoUploadPictureResponse = {
|
||||
url?: string;
|
||||
web_uri?: string;
|
||||
origin_web_uri?: string;
|
||||
rigin_web_uri?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
};
|
||||
url?: string
|
||||
web_uri?: string
|
||||
origin_web_uri?: string
|
||||
rigin_web_uri?: string
|
||||
width?: number
|
||||
height?: number
|
||||
}
|
||||
|
||||
type ToutiaoSpiceImageResponse = {
|
||||
data?: {
|
||||
image_url?: string;
|
||||
image_uri?: string;
|
||||
image_width?: number;
|
||||
image_height?: number;
|
||||
};
|
||||
};
|
||||
image_url?: string
|
||||
image_uri?: string
|
||||
image_width?: number
|
||||
image_height?: number
|
||||
}
|
||||
}
|
||||
|
||||
type ToutiaoPublishResponse = {
|
||||
code?: number;
|
||||
message?: string;
|
||||
code?: number
|
||||
message?: string
|
||||
data?: {
|
||||
pgc_id?: string | number;
|
||||
};
|
||||
};
|
||||
pgc_id?: string | number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ToutiaoMediaSnapshot {
|
||||
platformUid: string | null;
|
||||
screenName: string | null;
|
||||
avatarUrl: string | null;
|
||||
hasAdPermission: boolean;
|
||||
platformUid: string | null
|
||||
screenName: string | null
|
||||
avatarUrl: string | null
|
||||
hasAdPermission: boolean
|
||||
}
|
||||
|
||||
export interface ToutiaoPublishArticleInput {
|
||||
title: string;
|
||||
html: string;
|
||||
coverAssetUrl?: string | null;
|
||||
publishType: "publish" | "draft";
|
||||
title: string
|
||||
html: string
|
||||
coverAssetUrl?: string | null
|
||||
publishType: 'publish' | 'draft'
|
||||
}
|
||||
|
||||
export interface ToutiaoPublishTransport {
|
||||
fetchJson<T>(input: string, init?: RequestInit): Promise<T>;
|
||||
fetchImageBlob(sourceUrl: string): Promise<Blob | null>;
|
||||
fetchJson<T>(input: string, init?: RequestInit): Promise<T>
|
||||
fetchImageBlob(sourceUrl: string): Promise<Blob | null>
|
||||
uploadHtmlImages(
|
||||
html: string,
|
||||
uploader: (sourceUrl: string) => Promise<string | null>,
|
||||
): Promise<{ html: string }>;
|
||||
reportProgress?(stage: "media_info" | "upload_content_images" | "upload_cover" | "submit"): void;
|
||||
): Promise<{ html: string }>
|
||||
reportProgress?(stage: 'media_info' | 'upload_content_images' | 'upload_cover' | 'submit'): void
|
||||
}
|
||||
|
||||
export type ToutiaoPublishResult =
|
||||
| {
|
||||
success: true;
|
||||
status: "success" | "pending_review";
|
||||
articleId: string;
|
||||
mediaName: string;
|
||||
externalManageUrl: string;
|
||||
externalArticleUrl: string | null;
|
||||
message: string;
|
||||
success: true
|
||||
status: 'success' | 'pending_review'
|
||||
articleId: string
|
||||
mediaName: string
|
||||
externalManageUrl: string
|
||||
externalArticleUrl: string | null
|
||||
message: string
|
||||
}
|
||||
| {
|
||||
success: false;
|
||||
status: "failed";
|
||||
code: "toutiaohao_not_logged_in" | "article_content_empty" | "toutiaohao_publish_failed";
|
||||
message: string;
|
||||
};
|
||||
success: false
|
||||
status: 'failed'
|
||||
code: 'toutiaohao_not_logged_in' | 'article_content_empty' | 'toutiaohao_publish_failed'
|
||||
message: string
|
||||
}
|
||||
|
||||
export async function fetchToutiaoMediaSnapshot(
|
||||
fetchJson: <T>(input: string, init?: RequestInit) => Promise<T>,
|
||||
): Promise<ToutiaoMediaSnapshot | null> {
|
||||
const response = await fetchJson<ToutiaoMediaInfoResponse>(
|
||||
"https://mp.toutiao.com/mp/agw/media/get_media_info",
|
||||
'https://mp.toutiao.com/mp/agw/media/get_media_info',
|
||||
{
|
||||
headers: {
|
||||
accept: "application/json, text/plain, */*",
|
||||
accept: 'application/json, text/plain, */*',
|
||||
},
|
||||
},
|
||||
).catch(() => null);
|
||||
).catch(() => null)
|
||||
|
||||
const user = response?.data?.user;
|
||||
const user = response?.data?.user
|
||||
if (!user?.id_str || !user.screen_name) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -103,218 +103,227 @@ export async function fetchToutiaoMediaSnapshot(
|
||||
screenName: user.screen_name,
|
||||
avatarUrl: user.https_avatar_url ?? null,
|
||||
hasAdPermission: Boolean(
|
||||
response?.data?.media?.has_third_party_ad_permission
|
||||
|| response?.data?.media?.has_toutiao_ad_permission,
|
||||
response?.data?.media?.has_third_party_ad_permission ||
|
||||
response?.data?.media?.has_toutiao_ad_permission,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadCover(
|
||||
transport: ToutiaoPublishTransport,
|
||||
sourceUrl: string,
|
||||
publishType: "publish" | "draft",
|
||||
publishType: 'publish' | 'draft',
|
||||
) {
|
||||
const blob = await transport.fetchImageBlob(sourceUrl);
|
||||
const blob = await transport.fetchImageBlob(sourceUrl)
|
||||
if (!blob) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
if (publishType === "publish") {
|
||||
const form = new FormData();
|
||||
form.append("upfile", blob, "cover.png");
|
||||
const uploaded = await transport.fetchJson<ToutiaoUploadPictureResponse>(
|
||||
"https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
if (publishType === 'publish') {
|
||||
const form = new FormData()
|
||||
form.append('upfile', blob, 'cover.png')
|
||||
const uploaded = await transport
|
||||
.fetchJson<ToutiaoUploadPictureResponse>(
|
||||
'https://mp.toutiao.com/mp/agw/article_material/photo/upload_picture',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
if (!uploaded?.url || !uploaded.web_uri) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: 0,
|
||||
url: uploaded.url,
|
||||
uri: uploaded.web_uri,
|
||||
origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? "",
|
||||
origin_uri: uploaded.origin_web_uri ?? uploaded.rigin_web_uri ?? '',
|
||||
thumb_width: uploaded.width ?? 0,
|
||||
thumb_height: uploaded.height ?? 0,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const firstForm = new FormData();
|
||||
firstForm.append("image", blob, "cover.png");
|
||||
const first = await transport.fetchJson<ToutiaoSpiceImageResponse>(
|
||||
"https://mp.toutiao.com/spice/image?device_platform=web",
|
||||
{
|
||||
method: "POST",
|
||||
body: firstForm,
|
||||
},
|
||||
).catch(() => null);
|
||||
const firstForm = new FormData()
|
||||
firstForm.append('image', blob, 'cover.png')
|
||||
const first = await transport
|
||||
.fetchJson<ToutiaoSpiceImageResponse>(
|
||||
'https://mp.toutiao.com/spice/image?device_platform=web',
|
||||
{
|
||||
method: 'POST',
|
||||
body: firstForm,
|
||||
},
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
const imageUrl = first?.data?.image_url;
|
||||
const imageUrl = first?.data?.image_url
|
||||
if (!imageUrl) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const secondForm = new FormData();
|
||||
secondForm.append("imageUrl", imageUrl);
|
||||
const final = await transport.fetchJson<ToutiaoSpiceImageResponse>(
|
||||
"https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1",
|
||||
{
|
||||
method: "POST",
|
||||
body: secondForm,
|
||||
},
|
||||
).catch(() => null);
|
||||
const secondForm = new FormData()
|
||||
secondForm.append('imageUrl', imageUrl)
|
||||
const final = await transport
|
||||
.fetchJson<ToutiaoSpiceImageResponse>(
|
||||
'https://mp.toutiao.com/spice/image?device_platform=web&need_cover_url=1',
|
||||
{
|
||||
method: 'POST',
|
||||
body: secondForm,
|
||||
},
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
if (!final?.data?.image_url || !first.data?.image_uri) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
id: "",
|
||||
id: '',
|
||||
url: final.data.image_url,
|
||||
uri: first.data.image_uri,
|
||||
ic_uri: "",
|
||||
ic_uri: '',
|
||||
thumb_width: first.data.image_width ?? 0,
|
||||
thumb_height: first.data.image_height ?? 0,
|
||||
extra: {
|
||||
from_content_uri: "",
|
||||
from_content: "0",
|
||||
from_content_uri: '',
|
||||
from_content: '0',
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadContentImage(
|
||||
transport: ToutiaoPublishTransport,
|
||||
sourceUrl: string,
|
||||
): Promise<string | null> {
|
||||
const blob = await transport.fetchImageBlob(sourceUrl);
|
||||
const blob = await transport.fetchImageBlob(sourceUrl)
|
||||
if (!blob) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const form = new FormData();
|
||||
form.append("image", blob, "image.png");
|
||||
const uploaded = await transport.fetchJson<ToutiaoSpiceImageResponse>(
|
||||
"https://mp.toutiao.com/spice/image?device_platform=web",
|
||||
{
|
||||
method: "POST",
|
||||
body: form,
|
||||
},
|
||||
).catch(() => null);
|
||||
const form = new FormData()
|
||||
form.append('image', blob, 'image.png')
|
||||
const uploaded = await transport
|
||||
.fetchJson<ToutiaoSpiceImageResponse>(
|
||||
'https://mp.toutiao.com/spice/image?device_platform=web',
|
||||
{
|
||||
method: 'POST',
|
||||
body: form,
|
||||
},
|
||||
)
|
||||
.catch(() => null)
|
||||
|
||||
return uploaded?.data?.image_url ?? null;
|
||||
return uploaded?.data?.image_url ?? null
|
||||
}
|
||||
|
||||
export async function publishToutiaoArticle(
|
||||
input: ToutiaoPublishArticleInput,
|
||||
transport: ToutiaoPublishTransport,
|
||||
): Promise<ToutiaoPublishResult> {
|
||||
transport.reportProgress?.("media_info");
|
||||
const media = await fetchToutiaoMediaSnapshot(transport.fetchJson);
|
||||
transport.reportProgress?.('media_info')
|
||||
const media = await fetchToutiaoMediaSnapshot(transport.fetchJson)
|
||||
if (!media?.platformUid || !media.screenName) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "toutiaohao_not_logged_in",
|
||||
message: "未检测到头条号登录态",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'toutiaohao_not_logged_in',
|
||||
message: '未检测到头条号登录态',
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedHtml = input.html.trim();
|
||||
const normalizedHtml = input.html.trim()
|
||||
if (!normalizedHtml) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "article_content_empty",
|
||||
message: "html_content is empty",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'article_content_empty',
|
||||
message: 'html_content is empty',
|
||||
}
|
||||
}
|
||||
|
||||
transport.reportProgress?.("upload_content_images");
|
||||
const processed = await transport.uploadHtmlImages(
|
||||
normalizedHtml,
|
||||
async (sourceUrl) => uploadContentImage(transport, sourceUrl),
|
||||
);
|
||||
transport.reportProgress?.('upload_content_images')
|
||||
const processed = await transport.uploadHtmlImages(normalizedHtml, async (sourceUrl) =>
|
||||
uploadContentImage(transport, sourceUrl),
|
||||
)
|
||||
|
||||
transport.reportProgress?.("upload_cover");
|
||||
transport.reportProgress?.('upload_cover')
|
||||
const cover = input.coverAssetUrl?.trim()
|
||||
? await uploadCover(transport, input.coverAssetUrl.trim(), input.publishType)
|
||||
: null;
|
||||
: null
|
||||
|
||||
const body = new URLSearchParams({
|
||||
content: processed.html,
|
||||
title: input.title,
|
||||
mp_editor_stat: JSON.stringify({ code_block: 1 }),
|
||||
is_refute_rumor: "0",
|
||||
save: input.publishType === "publish" ? "1" : "0",
|
||||
timer_status: "0",
|
||||
is_refute_rumor: '0',
|
||||
save: input.publishType === 'publish' ? '1' : '0',
|
||||
timer_status: '0',
|
||||
draft_form_data: JSON.stringify({ coverType: 1 }),
|
||||
article_ad_type: media.hasAdPermission ? "3" : "2",
|
||||
article_ad_type: media.hasAdPermission ? '3' : '2',
|
||||
pgc_feed_covers: JSON.stringify(cover ? [cover] : []),
|
||||
is_fans_article: "0",
|
||||
govern_forward: "0",
|
||||
praise: "0",
|
||||
disable_praise: "0",
|
||||
tree_plan_article: "0",
|
||||
activity_tag: "0",
|
||||
trends_writing_tag: "0",
|
||||
claim_exclusive: "0",
|
||||
is_fans_article: '0',
|
||||
govern_forward: '0',
|
||||
praise: '0',
|
||||
disable_praise: '0',
|
||||
tree_plan_article: '0',
|
||||
activity_tag: '0',
|
||||
trends_writing_tag: '0',
|
||||
claim_exclusive: '0',
|
||||
info_source: JSON.stringify({
|
||||
source_type: 5,
|
||||
source_author_uid: "",
|
||||
time_format: "",
|
||||
source_author_uid: '',
|
||||
time_format: '',
|
||||
position: {},
|
||||
}),
|
||||
});
|
||||
})
|
||||
|
||||
transport.reportProgress?.("submit");
|
||||
const response: ToutiaoPublishResponse = await transport.fetchJson<ToutiaoPublishResponse>(
|
||||
"https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
transport.reportProgress?.('submit')
|
||||
const response: ToutiaoPublishResponse = await transport
|
||||
.fetchJson<ToutiaoPublishResponse>(
|
||||
'https://mp.toutiao.com/mp/agw/article/publish?source=mp&type=article&aid=1231',
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body,
|
||||
},
|
||||
body,
|
||||
},
|
||||
).catch((error) => ({
|
||||
code: -1,
|
||||
message: error instanceof Error ? error.message : "toutiaohao_publish_failed",
|
||||
}));
|
||||
)
|
||||
.catch((error) => ({
|
||||
code: -1,
|
||||
message: error instanceof Error ? error.message : 'toutiaohao_publish_failed',
|
||||
}))
|
||||
|
||||
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : "";
|
||||
const articleId = response.data?.pgc_id != null ? String(response.data.pgc_id) : ''
|
||||
if (response.code !== 0 || !articleId) {
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
code: "toutiaohao_publish_failed",
|
||||
message: response.message || "toutiaohao_publish_failed",
|
||||
};
|
||||
status: 'failed',
|
||||
code: 'toutiaohao_publish_failed',
|
||||
message: response.message || 'toutiaohao_publish_failed',
|
||||
}
|
||||
}
|
||||
|
||||
if (input.publishType === "draft") {
|
||||
if (input.publishType === 'draft') {
|
||||
return {
|
||||
success: true,
|
||||
status: "pending_review",
|
||||
status: 'pending_review',
|
||||
articleId,
|
||||
mediaName: media.screenName,
|
||||
externalManageUrl: "https://mp.toutiao.com/profile_v4/graphic/publish",
|
||||
externalManageUrl: 'https://mp.toutiao.com/profile_v4/graphic/publish',
|
||||
externalArticleUrl: null,
|
||||
message: "头条号草稿保存成功。",
|
||||
};
|
||||
message: '头条号草稿保存成功。',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
status: "success",
|
||||
status: 'success',
|
||||
articleId,
|
||||
mediaName: media.screenName,
|
||||
externalManageUrl: "https://mp.toutiao.com/profile_v4/index",
|
||||
externalManageUrl: 'https://mp.toutiao.com/profile_v4/index',
|
||||
externalArticleUrl: `https://www.toutiao.com/article/${articleId}/`,
|
||||
message: "头条号发布成功。",
|
||||
};
|
||||
message: '头条号发布成功。',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,3 @@
|
||||
".": "./src/index.ts"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export * from "./ai-platforms.ts";
|
||||
export * from './ai-platforms.ts'
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
export interface AIPlatformCatalogItem {
|
||||
id: string;
|
||||
label: string;
|
||||
shortName: string;
|
||||
accent: string;
|
||||
description: string;
|
||||
loginUrl: string;
|
||||
consoleUrl: string;
|
||||
id: string
|
||||
label: string
|
||||
shortName: string
|
||||
accent: string
|
||||
description: string
|
||||
loginUrl: string
|
||||
consoleUrl: string
|
||||
}
|
||||
|
||||
export const aiPlatformCatalog: readonly AIPlatformCatalogItem[] = [
|
||||
{
|
||||
id: "yuanbao",
|
||||
label: "混元 / 元宝",
|
||||
shortName: "元",
|
||||
accent: "#19bf6b",
|
||||
description: "腾讯系 AI 平台账号绑定与静默监测",
|
||||
loginUrl: "https://yuanbao.tencent.com/",
|
||||
consoleUrl: "https://yuanbao.tencent.com/",
|
||||
id: 'yuanbao',
|
||||
label: '混元 / 元宝',
|
||||
shortName: '元',
|
||||
accent: '#19bf6b',
|
||||
description: '腾讯系 AI 平台账号绑定与静默监测',
|
||||
loginUrl: 'https://yuanbao.tencent.com/',
|
||||
consoleUrl: 'https://yuanbao.tencent.com/',
|
||||
},
|
||||
{
|
||||
id: "kimi",
|
||||
label: "Kimi",
|
||||
shortName: "K",
|
||||
accent: "#111827",
|
||||
description: "Moonshot AI 账号绑定与静默监测",
|
||||
loginUrl: "https://www.kimi.com/",
|
||||
consoleUrl: "https://www.kimi.com/",
|
||||
id: 'kimi',
|
||||
label: 'Kimi',
|
||||
shortName: 'K',
|
||||
accent: '#111827',
|
||||
description: 'Moonshot AI 账号绑定与静默监测',
|
||||
loginUrl: 'https://www.kimi.com/',
|
||||
consoleUrl: 'https://www.kimi.com/',
|
||||
},
|
||||
{
|
||||
id: "wenxin",
|
||||
label: "文心一言",
|
||||
shortName: "文",
|
||||
accent: "#20c6d7",
|
||||
description: "百度文心账号绑定与静默监测",
|
||||
loginUrl: "https://yiyan.baidu.com/",
|
||||
consoleUrl: "https://yiyan.baidu.com/",
|
||||
id: 'wenxin',
|
||||
label: '文心一言',
|
||||
shortName: '文',
|
||||
accent: '#20c6d7',
|
||||
description: '百度文心账号绑定与静默监测',
|
||||
loginUrl: 'https://yiyan.baidu.com/',
|
||||
consoleUrl: 'https://yiyan.baidu.com/',
|
||||
},
|
||||
{
|
||||
id: "deepseek",
|
||||
label: "DeepSeek",
|
||||
shortName: "D",
|
||||
accent: "#4468ff",
|
||||
description: "DeepSeek 账号绑定与静默监测",
|
||||
loginUrl: "https://chat.deepseek.com/",
|
||||
consoleUrl: "https://chat.deepseek.com/",
|
||||
id: 'deepseek',
|
||||
label: 'DeepSeek',
|
||||
shortName: 'D',
|
||||
accent: '#4468ff',
|
||||
description: 'DeepSeek 账号绑定与静默监测',
|
||||
loginUrl: 'https://chat.deepseek.com/',
|
||||
consoleUrl: 'https://chat.deepseek.com/',
|
||||
},
|
||||
{
|
||||
id: "doubao",
|
||||
label: "豆包",
|
||||
shortName: "豆",
|
||||
accent: "#ff9348",
|
||||
description: "字节豆包账号绑定与静默监测",
|
||||
loginUrl: "https://www.doubao.com/",
|
||||
consoleUrl: "https://www.doubao.com/",
|
||||
id: 'doubao',
|
||||
label: '豆包',
|
||||
shortName: '豆',
|
||||
accent: '#ff9348',
|
||||
description: '字节豆包账号绑定与静默监测',
|
||||
loginUrl: 'https://www.doubao.com/',
|
||||
consoleUrl: 'https://www.doubao.com/',
|
||||
},
|
||||
{
|
||||
id: "qwen",
|
||||
label: "通义千问",
|
||||
shortName: "通",
|
||||
accent: "#276ef1",
|
||||
description: "阿里千问账号绑定与静默监测",
|
||||
loginUrl: "https://www.qianwen.com/",
|
||||
consoleUrl: "https://www.qianwen.com/",
|
||||
id: 'qwen',
|
||||
label: '通义千问',
|
||||
shortName: '通',
|
||||
accent: '#276ef1',
|
||||
description: '阿里千问账号绑定与静默监测',
|
||||
loginUrl: 'https://www.qianwen.com/',
|
||||
consoleUrl: 'https://www.qianwen.com/',
|
||||
},
|
||||
] as const;
|
||||
] as const
|
||||
|
||||
const aiPlatformMap = new Map(aiPlatformCatalog.map((item) => [item.id, item]));
|
||||
const aiPlatformMap = new Map(aiPlatformCatalog.map((item) => [item.id, item]))
|
||||
|
||||
export function getAIPlatformCatalogItem(platformId: string): AIPlatformCatalogItem | null {
|
||||
return aiPlatformMap.get(platformId) ?? null;
|
||||
return aiPlatformMap.get(platformId) ?? null
|
||||
}
|
||||
|
||||
export function isAIPlatformId(platformId: string): boolean {
|
||||
return aiPlatformMap.has(platformId);
|
||||
return aiPlatformMap.has(platformId)
|
||||
}
|
||||
|
||||
+1178
-1177
File diff suppressed because it is too large
Load Diff
@@ -15,4 +15,3 @@
|
||||
"jsx": "preserve"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export {}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export {};
|
||||
export {}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export * from "./tokens/index";
|
||||
export * from "./composables/index";
|
||||
export * from "./components/index";
|
||||
export * from './components/index'
|
||||
export * from './composables/index'
|
||||
export * from './tokens/index'
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
:root {
|
||||
--geo-font-sans: "IBM Plex Sans", "Aptos", "Segoe UI Variable", "Segoe UI", sans-serif;
|
||||
--geo-font-mono: "JetBrains Mono", "SFMono-Regular", "Cascadia Code", "Consolas", monospace;
|
||||
--geo-font-sans: 'IBM Plex Sans', 'Aptos', 'Segoe UI Variable', 'Segoe UI', sans-serif;
|
||||
--geo-font-mono: 'JetBrains Mono', 'SFMono-Regular', 'Cascadia Code', 'Consolas', monospace;
|
||||
--geo-color-bg-primary: #eef3f8;
|
||||
--geo-color-bg-secondary: #e4ebf3;
|
||||
--geo-color-bg-tertiary: #d6e1ec;
|
||||
@@ -30,7 +30,7 @@
|
||||
--geo-shadow-edge: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
html[data-theme='dark'] {
|
||||
--geo-color-bg-primary: #0a1015;
|
||||
--geo-color-bg-secondary: #101820;
|
||||
--geo-color-bg-tertiary: #16212c;
|
||||
@@ -67,7 +67,7 @@ html {
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
html[data-theme="dark"] {
|
||||
html[data-theme='dark'] {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
export const themeTokens = {
|
||||
colorBgPrimary: "var(--geo-color-bg-primary)",
|
||||
colorBgSecondary: "var(--geo-color-bg-secondary)",
|
||||
colorTextPrimary: "var(--geo-color-text-primary)",
|
||||
colorTextSecondary: "var(--geo-color-text-secondary)",
|
||||
colorBorder: "var(--geo-color-border)",
|
||||
colorBrand: "var(--geo-color-brand)",
|
||||
colorSuccess: "var(--geo-color-success)",
|
||||
colorWarn: "var(--geo-color-warn)",
|
||||
colorDanger: "var(--geo-color-danger)",
|
||||
} as const;
|
||||
colorBgPrimary: 'var(--geo-color-bg-primary)',
|
||||
colorBgSecondary: 'var(--geo-color-bg-secondary)',
|
||||
colorTextPrimary: 'var(--geo-color-text-primary)',
|
||||
colorTextSecondary: 'var(--geo-color-text-secondary)',
|
||||
colorBorder: 'var(--geo-color-border)',
|
||||
colorBrand: 'var(--geo-color-brand)',
|
||||
colorSuccess: 'var(--geo-color-success)',
|
||||
colorWarn: 'var(--geo-color-warn)',
|
||||
colorDanger: 'var(--geo-color-danger)',
|
||||
} as const
|
||||
|
||||
export type ThemeTokenKey = keyof typeof themeTokens;
|
||||
export type ThemeTokenKey = keyof typeof themeTokens
|
||||
|
||||
Reference in New Issue
Block a user