文件预览

beehiiv-api.ts

查看 youmind-beehiiv-article 技能包中的文件内容。

文件内容

toolkit/src/beehiiv-api.ts

/**
 * Beehiiv API client via YouMind OpenAPI.
 */

import { loadYouMindConfig, YOUMIND_CONFIG_ERROR_HINT } from './config.js';

export interface BeehiivConfig {
  apiKey: string;
  baseUrl: string;
}

export interface BeehiivPostRecipientsChannel {
  tierIds?: string[];
  includeSegmentIds?: string[];
  excludeSegmentIds?: string[];
}

export interface BeehiivPostRecipients {
  web?: BeehiivPostRecipientsChannel;
  email?: BeehiivPostRecipientsChannel;
}

export interface BeehiivPostEmailSettings {
  fromAddress?: string;
  customLiveUrl?: string;
  displayTitleInEmail?: boolean;
  displayBylineInEmail?: boolean;
  displaySubtitleInEmail?: boolean;
  emailHeaderEngagementButtons?: string;
  emailHeaderSocialShare?: string;
  emailPreviewText?: string;
  emailSubjectLine?: string;
}

export interface BeehiivPostWebSettings {
  displayThumbnailOnWeb?: boolean;
  hideFromFeed?: boolean;
  paywallBreakPriceId?: string;
  paywallId?: string;
  slug?: string;
}

export interface BeehiivPostSeoSettings {
  defaultDescription?: string;
  defaultTitle?: string;
  ogDescription?: string;
  ogTitle?: string;
  twitterDescription?: string;
  twitterTitle?: string;
}

export interface BeehiivPost {
  id: string;
  title: string;
  subtitle?: string;
  status: string;
  subjectLine?: string;
  previewText?: string;
  slug?: string;
  thumbnailUrl?: string;
  webUrl?: string;
  audience?: string;
  platform?: string;
  contentTags?: string[];
  hiddenFromFeed?: boolean;
  enforceGatedContent?: boolean;
  emailCapturePopup?: boolean;
  authors?: string[];
  created?: number;
  publishDate?: number;
  displayedDate?: number;
  metaDefaultDescription?: string;
  metaDefaultTitle?: string;
  newsletterListId?: string;
  content?: Record<string, unknown>;
  stats?: Record<string, unknown>;
}

export interface BeehiivPostTemplate {
  id: string;
  name: string;
}

export interface BeehiivPostListResponse {
  posts: BeehiivPost[];
  limit: number;
  page: number;
  totalResults: number;
  totalPages: number;
}

export interface BeehiivPostTemplateListResponse {
  templates: BeehiivPostTemplate[];
  limit: number;
  page: number;
  totalResults: number;
  totalPages: number;
}

export interface BeehiivConnectionResult {
  ok: boolean;
  message: string;
  workspaceId?: string;
  workspaceName?: string;
  publicationId?: string;
  publicationName?: string;
}

export interface CreateBeehiivPostOptions {
  title: string;
  bodyContent?: string;
  blocks?: Record<string, unknown>[];
  subtitle?: string;
  postTemplateId?: string;
  status?: 'draft' | 'confirmed';
  scheduledAt?: string;
  customLinkTrackingEnabled?: boolean;
  emailCaptureTypeOverride?: 'none' | 'gated' | 'popup';
  overrideScheduledAt?: string;
  socialShare?: 'comments_and_likes_only' | 'with_comments_and_likes' | 'top' | 'none';
  contentTags?: string[];
  thumbnailImageUrl?: string;
  recipients?: BeehiivPostRecipients;
  emailSettings?: BeehiivPostEmailSettings;
  webSettings?: BeehiivPostWebSettings;
  seoSettings?: BeehiivPostSeoSettings;
  headers?: Record<string, string>;
  customFields?: Record<string, string>;
  newsletterListId?: string;
}

export interface UpdateBeehiivPostOptions {
  bodyContent?: string;
  blocks?: Record<string, unknown>[];
  title?: string;
  subtitle?: string;
  scheduledAt?: string;
  customLinkTrackingEnabled?: boolean;
  emailCaptureTypeOverride?: 'none' | 'gated' | 'popup';
  overrideScheduledAt?: string;
  socialShare?: 'comments_and_likes_only' | 'with_comments_and_likes' | 'top' | 'none';
  contentTags?: string[];
  thumbnailImageUrl?: string;
  emailSettings?: BeehiivPostEmailSettings;
  webSettings?: BeehiivPostWebSettings;
  seoSettings?: BeehiivPostSeoSettings;
}

export interface ListBeehiivPostsOptions {
  page?: number;
  limit?: number;
  status?: 'draft' | 'confirmed' | 'archived' | 'all';
  audience?: 'free' | 'premium' | 'all';
  platform?: 'web' | 'email' | 'both' | 'all';
  contentTags?: string[];
  slugs?: string[];
  authors?: string[];
  premiumTiers?: string[];
  expand?: string[];
  orderBy?: 'created' | 'publish_date' | 'displayed_date' | 'publishDate' | 'displayedDate';
  direction?: 'asc' | 'desc';
  hiddenFromFeed?: 'all' | 'true' | 'false';
}

export interface ListBeehiivPostTemplatesOptions {
  page?: number;
  limit?: number;
  order?: 'asc' | 'desc';
  orderBy?: string;
}

interface OpenApiErrorDetail {
  connectUrl?: string;
  upgradeUrl?: string;
  hint?: string;
  upstreamMessage?: string;
  retryAfter?: string | null;
}

interface OpenApiErrorResponse {
  message?: string;
  code?: string;
  detail?: OpenApiErrorDetail;
}

export function loadBeehiivConfig(): BeehiivConfig {
  const { apiKey, baseUrl } = loadYouMindConfig();
  return { apiKey, baseUrl };
}

async function postJson<T = unknown>(
  endpoint: string,
  body: Record<string, unknown> = {},
  config?: BeehiivConfig,
): Promise<T> {
  const cfg = config ?? loadBeehiivConfig();
  if (!cfg.apiKey) {
    throw new Error(`YouMind API key not configured. ${YOUMIND_CONFIG_ERROR_HINT}`);
  }

  const response = await fetch(`${cfg.baseUrl}${endpoint}`, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'x-api-key': cfg.apiKey,
      'x-use-camel-case': 'true',
    },
    body: JSON.stringify(body),
    signal: AbortSignal.timeout(30_000),
  });

  if (!response.ok) {
    const text = await response.text().catch(() => '');
    const parsed = parseOpenApiError(text);
    throw new Error(
      `YouMind Beehiiv API ${endpoint} failed (${response.status}): ${formatOpenApiError(parsed, text)}`,
    );
  }

  return response.json() as Promise<T>;
}

function parseOpenApiError(text: string): OpenApiErrorResponse | null {
  try {
    return JSON.parse(text) as OpenApiErrorResponse;
  } catch {
    return null;
  }
}

function formatOpenApiError(parsed: OpenApiErrorResponse | null, rawText: string): string {
  if (!parsed) {
    return rawText.slice(0, 300);
  }

  const parts = [parsed.message, parsed.code, parsed.detail?.hint].filter(
    (value): value is string => typeof value === 'string' && value.length > 0,
  );

  if (parsed.detail?.connectUrl) {
    parts.push(`Connect beehiiv: ${parsed.detail.connectUrl}`);
  }
  if (parsed.detail?.upgradeUrl) {
    parts.push(`Upgrade plan: ${parsed.detail.upgradeUrl}`);
  }
  if (parsed.detail?.upstreamMessage) {
    parts.push(`beehiiv said: ${parsed.detail.upstreamMessage}`);
  }
  if (parsed.detail?.retryAfter) {
    parts.push(`Retry-After: ${parsed.detail.retryAfter}`);
  }

  return parts.join(' | ') || rawText.slice(0, 300);
}

function isPlainObject(value: unknown): value is Record<string, unknown> {
  return typeof value === 'object' && value !== null && !Array.isArray(value);
}

function normalizeStringArray(value: unknown): string[] | undefined {
  if (!Array.isArray(value)) {
    return undefined;
  }
  const items = value.filter((item): item is string => typeof item === 'string');
  return items.length ? items : undefined;
}

function normalizePost(post: Record<string, unknown>): BeehiivPost {
  return {
    id: String(post.id ?? ''),
    title: String(post.title ?? ''),
    subtitle: (post.subtitle as string | undefined) ?? undefined,
    status: String(post.status ?? ''),
    subjectLine:
      (post.subjectLine as string | undefined) ??
      (post.subject_line as string | undefined),
    previewText:
      (post.previewText as string | undefined) ??
      (post.preview_text as string | undefined),
    slug: (post.slug as string | undefined) ?? undefined,
    thumbnailUrl:
      (post.thumbnailUrl as string | undefined) ??
      (post.thumbnail_image_url as string | undefined) ??
      (post.thumbnail_url as string | undefined),
    webUrl: (post.webUrl as string | undefined) ?? (post.web_url as string | undefined),
    audience: (post.audience as string | undefined) ?? undefined,
    platform: (post.platform as string | undefined) ?? undefined,
    contentTags: normalizeStringArray(post.contentTags) ?? normalizeStringArray(post.content_tags),
    hiddenFromFeed:
      typeof post.hiddenFromFeed === 'boolean'
        ? post.hiddenFromFeed
        : typeof post.hidden_from_feed === 'boolean'
          ? post.hidden_from_feed
          : undefined,
    enforceGatedContent:
      typeof post.enforceGatedContent === 'boolean'
        ? post.enforceGatedContent
        : typeof post.enforce_gated_content === 'boolean'
          ? post.enforce_gated_content
          : undefined,
    emailCapturePopup:
      typeof post.emailCapturePopup === 'boolean'
        ? post.emailCapturePopup
        : typeof post.email_capture_popup === 'boolean'
          ? post.email_capture_popup
          : undefined,
    authors: normalizeStringArray(post.authors),
    created: typeof post.created === 'number' ? post.created : undefined,
    publishDate:
      typeof post.publishDate === 'number'
        ? post.publishDate
        : typeof post.publish_date === 'number'
          ? post.publish_date
          : undefined,
    displayedDate:
      typeof post.displayedDate === 'number'
        ? post.displayedDate
        : typeof post.displayed_date === 'number'
          ? post.displayed_date
          : undefined,
    metaDefaultDescription:
      (post.metaDefaultDescription as string | undefined) ??
      (post.meta_default_description as string | undefined),
    metaDefaultTitle:
      (post.metaDefaultTitle as string | undefined) ??
      (post.meta_default_title as string | undefined),
    newsletterListId:
      (post.newsletterListId as string | undefined) ??
      (post.newsletter_list_id as string | undefined),
    content: isPlainObject(post.content) ? post.content : undefined,
    stats: isPlainObject(post.stats) ? post.stats : undefined,
  };
}

function normalizePostTemplate(template: Record<string, unknown>): BeehiivPostTemplate {
  return {
    id: String(template.id ?? ''),
    name: String(template.name ?? ''),
  };
}

export async function validateConnection(
  config?: BeehiivConfig,
): Promise<BeehiivConnectionResult> {
  return postJson<BeehiivConnectionResult>('/beehiiv/validateConnection', {}, config);
}

export async function createPost(
  config: BeehiivConfig,
  options: CreateBeehiivPostOptions,
): Promise<BeehiivPost> {
  const post = await postJson<Record<string, unknown>>(
    '/beehiiv/createPost',
    { ...options },
    config,
  );
  return normalizePost(post);
}

export async function updatePost(
  config: BeehiivConfig,
  id: string,
  options: UpdateBeehiivPostOptions,
): Promise<BeehiivPost> {
  const post = await postJson<Record<string, unknown>>(
    '/beehiiv/updatePost',
    { id, ...options },
    config,
  );
  return normalizePost(post);
}

export async function getPost(config: BeehiivConfig, id: string): Promise<BeehiivPost> {
  const post = await postJson<Record<string, unknown>>('/beehiiv/getPost', { id }, config);
  return normalizePost(post);
}

export async function deletePost(
  config: BeehiivConfig,
  id: string,
): Promise<{ ok: boolean; id: string }> {
  return postJson<{ ok: boolean; id: string }>('/beehiiv/deletePost', { id }, config);
}

export async function listPosts(
  config: BeehiivConfig,
  options: ListBeehiivPostsOptions = {},
): Promise<BeehiivPostListResponse> {
  const normalizedOrderBy =
    options.orderBy === 'publishDate'
      ? 'publish_date'
      : options.orderBy === 'displayedDate'
        ? 'displayed_date'
        : options.orderBy;

  const response = await postJson<BeehiivPostListResponse>(
    '/beehiiv/listPosts',
    {
      ...(options.page !== undefined ? { page: options.page } : {}),
      ...(options.limit !== undefined ? { limit: options.limit } : {}),
      ...(options.status ? { status: options.status } : {}),
      ...(options.audience ? { audience: options.audience } : {}),
      ...(options.platform ? { platform: options.platform } : {}),
      ...(options.contentTags?.length ? { contentTags: options.contentTags } : {}),
      ...(options.slugs?.length ? { slugs: options.slugs } : {}),
      ...(options.authors?.length ? { authors: options.authors } : {}),
      ...(options.premiumTiers?.length ? { premiumTiers: options.premiumTiers } : {}),
      ...(options.expand?.length ? { expand: options.expand } : {}),
      ...(normalizedOrderBy ? { orderBy: normalizedOrderBy } : {}),
      ...(options.direction ? { direction: options.direction } : {}),
      ...(options.hiddenFromFeed ? { hiddenFromFeed: options.hiddenFromFeed } : {}),
    },
    config,
  );

  return {
    ...response,
    posts: response.posts.map((post) =>
      normalizePost(post as unknown as Record<string, unknown>),
    ),
  };
}

export async function listPostTemplates(
  config: BeehiivConfig,
  options: ListBeehiivPostTemplatesOptions = {},
): Promise<BeehiivPostTemplateListResponse> {
  const response = await postJson<BeehiivPostTemplateListResponse>(
    '/beehiiv/listPostTemplates',
    {
      ...(options.page !== undefined ? { page: options.page } : {}),
      ...(options.limit !== undefined ? { limit: options.limit } : {}),
      ...(options.order ? { order: options.order } : {}),
      ...(options.orderBy ? { orderBy: options.orderBy } : {}),
    },
    config,
  );

  return {
    ...response,
    templates: response.templates.map((template) =>
      normalizePostTemplate(template as unknown as Record<string, unknown>),
    ),
  };
}