/** * 文件:adapter.ts * 功能:将 Markdown / 原始文本内容适配为小红书平台要求的数据结构。 * * 核心点: * - 标题截断与合法性(最长 20 中文字符) * - 正文长度控制(默认 1000 字符内) * - 话题 / 标签提取(基于 #话题 或自定义规则) * - 表情/风格增强(示例性实现,可扩展主题风格) * - 去除不支持/冗余的 Markdown 结构(脚注/复杂嵌套等) * * 适配策略:偏“软处理”——尽量不抛错,最大化生成可用内容; * 若遇格式无法解析的块,可进入降级模式(直接纯文本保留)。 * * 后续可扩展: * - 图片占位替换(与 image.ts 协同,支持序号引用) * - 自动摘要生成 / AI 优化标题 * - 支持多语言文案风格转换 */ import { XiaohongshuAdapter, XiaohongshuPost, XIAOHONGSHU_CONSTANTS } from './types'; /** * XiaohongshuContentAdapter * * 说明(中文注释): * 负责将Obsidian的Markdown内容转换为适合小红书平台的格式。 * * 主要功能: * - 处理标题长度限制(最多20字符) * - 转换Markdown格式为小红书支持的纯文本格式 * - 提取和处理标签(从Obsidian的#标签格式转换) * - 处理图片引用和链接 * - 内容长度控制(最多1000字符) * * 设计原则: * - 保持内容的可读性和完整性 * - 符合小红书平台的内容规范 * - 提供灵活的自定义选项 * - 错误处理和验证 */ export class XiaohongshuContentAdapter implements XiaohongshuAdapter { /** * 转换标题 * 处理标题长度限制,保留核心信息 */ adaptTitle(title: string): string { // 移除Markdown格式标记 let adaptedTitle = title.replace(/^#+\s*/, ''); // 移除标题标记 adaptedTitle = adaptedTitle.replace(/\*\*(.*?)\*\*/g, '$1'); // 移除粗体标记 adaptedTitle = adaptedTitle.replace(/\*(.*?)\*/g, '$1'); // 移除斜体标记 adaptedTitle = adaptedTitle.replace(/`(.*?)`/g, '$1'); // 移除代码标记 // 长度限制处理 const maxLength = XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_TITLE_LENGTH; if (adaptedTitle.length > maxLength) { // 智能截断:优先保留前面的内容,如果有标点符号就在标点处截断 const truncated = adaptedTitle.substring(0, maxLength - 1); const lastPunctuation = Math.max( truncated.lastIndexOf('。'), truncated.lastIndexOf('!'), truncated.lastIndexOf('?'), truncated.lastIndexOf(','), truncated.lastIndexOf(',') ); if (lastPunctuation > maxLength * 0.7) { // 如果标点位置合理,在标点处截断 adaptedTitle = truncated.substring(0, lastPunctuation + 1); } else { // 否则直接截断并添加省略号 adaptedTitle = truncated + '…'; } } return adaptedTitle.trim(); } /** * 转换正文内容 * 将Markdown格式转换为小红书适用的纯文本格式 */ adaptContent(content: string): string { let adaptedContent = content; // 移除YAML frontmatter adaptedContent = adaptedContent.replace(/^---\s*[\s\S]*?---\s*/m, ''); // 处理标题:转换为带emoji的形式 adaptedContent = adaptedContent.replace(/^### (.*$)/gim, '🔸 $1'); adaptedContent = adaptedContent.replace(/^## (.*$)/gim, '📌 $1'); adaptedContent = adaptedContent.replace(/^# (.*$)/gim, '🎯 $1'); // 处理强调文本 adaptedContent = adaptedContent.replace(/\*\*(.*?)\*\*/g, '✨ $1 ✨'); // 粗体 adaptedContent = adaptedContent.replace(/\*(.*?)\*/g, '$1'); // 斜体(小红书不支持,移除标记) // 处理代码块:转换为引用格式 adaptedContent = adaptedContent.replace(/```[\s\S]*?```/g, (match) => { const codeContent = match.replace(/```\w*\n?/g, '').replace(/```$/, ''); return `💻 代码片段:\n${codeContent.split('\n').map(line => `  ${line}`).join('\n')}`; }); // 处理行内代码 adaptedContent = adaptedContent.replace(/`([^`]+)`/g, '「$1」'); // 处理引用块 adaptedContent = adaptedContent.replace(/^> (.*$)/gim, '💭 $1'); // 处理无序列表 adaptedContent = adaptedContent.replace(/^[*+-] (.*$)/gim, '• $1'); // 处理有序列表 adaptedContent = adaptedContent.replace(/^\d+\. (.*$)/gim, (match, content) => `🔢 ${content}`); // 处理链接:小红书不支持外链,转换为纯文本提示 adaptedContent = adaptedContent.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '$1 🔗'); // 处理图片引用标记(图片会单独处理) adaptedContent = adaptedContent.replace(/!\[.*?\]\(.*?\)/g, '[图片]'); // 清理多余的空行 adaptedContent = adaptedContent.replace(/\n{3,}/g, '\n\n'); // 长度控制 const maxLength = XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_CONTENT_LENGTH; if (adaptedContent.length > maxLength) { // 智能截断:尽量在段落边界截断 const truncated = adaptedContent.substring(0, maxLength - 10); const lastParagraph = truncated.lastIndexOf('\n\n'); const lastSentence = Math.max( truncated.lastIndexOf('。'), truncated.lastIndexOf('!'), truncated.lastIndexOf('?') ); if (lastParagraph > maxLength * 0.8) { adaptedContent = truncated.substring(0, lastParagraph) + '\n\n...'; } else if (lastSentence > maxLength * 0.8) { adaptedContent = truncated.substring(0, lastSentence + 1) + '\n...'; } else { adaptedContent = truncated + '...'; } } return adaptedContent.trim(); } /** * 提取标签 * 从Markdown内容中提取Obsidian标签并转换为小红书格式 */ extractTags(content: string): string[] { const tags: string[] = []; // 提取Obsidian风格的标签 (#标签) const obsidianTags = content.match(/#[\w\u4e00-\u9fa5]+/g); if (obsidianTags) { obsidianTags.forEach(tag => { const cleanTag = tag.substring(1); // 移除#号 if (cleanTag.length <= 10 && !tags.includes(cleanTag)) { tags.push(cleanTag); } }); } // 从YAML frontmatter中提取tags const frontmatterMatch = content.match(/^---\s*\n([\s\S]*?)\n---/); if (frontmatterMatch) { const frontmatter = frontmatterMatch[1]; const tagsMatch = frontmatter.match(/tags:\s*\[(.*?)\]/); if (tagsMatch) { const yamlTags = tagsMatch[1].split(',').map(t => t.trim().replace(/['"]/g, '')); yamlTags.forEach(tag => { if (tag.length <= 10 && !tags.includes(tag)) { tags.push(tag); } }); } } // 限制标签数量 return tags.slice(0, XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_TAGS); } /** * 处理图片引用 * 将Markdown中的图片引用替换为小红书的图片标识 */ processImages(content: string, imageUrls: Map): string { let processedContent = content; // 处理图片引用 processedContent = processedContent.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, src) => { // 查找对应的小红书图片URL const xiaohongshuUrl = imageUrls.get(src); if (xiaohongshuUrl) { return `[图片: ${alt || '图片'}]`; } else { return `[图片: ${alt || '图片'}]`; } }); return processedContent; } /** * 验证内容是否符合小红书要求 */ validatePost(post: XiaohongshuPost): { valid: boolean; errors: string[] } { const errors: string[] = []; // 验证标题 if (!post.title || post.title.trim().length === 0) { errors.push('标题不能为空'); } else if (post.title.length > XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_TITLE_LENGTH) { errors.push(`标题长度不能超过${XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_TITLE_LENGTH}个字符`); } // 验证内容 if (!post.content || post.content.trim().length === 0) { errors.push('内容不能为空'); } else if (post.content.length > XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_CONTENT_LENGTH) { errors.push(`内容长度不能超过${XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_CONTENT_LENGTH}个字符`); } // 验证图片 if (post.images && post.images.length > XIAOHONGSHU_CONSTANTS.IMAGE_LIMITS.MAX_COUNT) { errors.push(`图片数量不能超过${XIAOHONGSHU_CONSTANTS.IMAGE_LIMITS.MAX_COUNT}张`); } // 验证标签 if (post.tags && post.tags.length > XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_TAGS) { errors.push(`标签数量不能超过${XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_TAGS}个`); } // 检查敏感词(基础检查) const sensitiveWords = ['广告', '推广', '代购', '微商']; const fullContent = (post.title + ' ' + post.content).toLowerCase(); sensitiveWords.forEach(word => { if (fullContent.includes(word)) { errors.push(`内容中包含可能违规的词汇: ${word}`); } }); return { valid: errors.length === 0, errors }; } /** * 生成适合小红书的标题 * 基于内容自动生成吸引人的标题 */ generateTitle(content: string): string { // 提取第一个标题作为基础 const headingMatch = content.match(/^#+\s+(.+)$/m); if (headingMatch) { return this.adaptTitle(headingMatch[1]); } // 如果没有标题,从内容中提取关键词 const firstParagraph = content.split('\n\n')[0]; const cleanParagraph = firstParagraph.replace(/[#*`>\-\[\]()]/g, '').trim(); if (cleanParagraph.length > 0) { return this.adaptTitle(cleanParagraph); } return '分享一些想法'; } /** * 添加小红书风格的emoji和格式 */ addXiaohongshuStyle(content: string): string { // 在段落间添加适当的emoji分隔 let styledContent = content; // 在开头添加吸引注意的emoji const startEmojis = ['✨', '🌟', '💡', '🎉', '🔥']; const randomEmoji = startEmojis[Math.floor(Math.random() * startEmojis.length)]; styledContent = `${randomEmoji} ${styledContent}`; // 在结尾添加互动性文字 const endingPhrases = [ '\n\n❤️ 觉得有用请点赞支持~', '\n\n💬 有什么想法欢迎评论交流', '\n\n🔄 觉得不错就转发分享吧', '\n\n⭐ 记得收藏起来哦' ]; const randomEnding = endingPhrases[Math.floor(Math.random() * endingPhrases.length)]; styledContent += randomEnding; return styledContent; } /** * 完整的内容适配流程 * 一站式处理从Markdown到小红书格式的转换 */ adaptMarkdownToXiaohongshu(markdownContent: string, options?: { addStyle?: boolean; generateTitle?: boolean; maxLength?: number; }): XiaohongshuPost { const opts = { addStyle: true, generateTitle: false, maxLength: XIAOHONGSHU_CONSTANTS.CONTENT_LIMITS.MAX_CONTENT_LENGTH, ...options }; // 提取标题 let title = ''; const titleMatch = markdownContent.match(/^#\s+(.+)$/m); if (titleMatch) { title = this.adaptTitle(titleMatch[1]); } else if (opts.generateTitle) { title = this.generateTitle(markdownContent); } // 适配内容 let content = this.adaptContent(markdownContent); if (opts.addStyle) { content = this.addXiaohongshuStyle(content); } // 提取标签 const tags = this.extractTags(markdownContent); // 提取图片(这里只是提取引用,实际处理在渲染器中) const imageMatches = markdownContent.match(/!\[([^\]]*)\]\(([^)]+)\)/g); const images: string[] = []; if (imageMatches) { imageMatches.forEach(match => { const srcMatch = match.match(/\(([^)]+)\)/); if (srcMatch) { images.push(srcMatch[1]); } }); } return { title: title || '无题', content, tags, images }; } }