UNPKG

n8n-nodes-wechat-publish

Version:

N8N nodes for WeChat Official Account publishing - Create drafts and publish articles to WeChat Official Account platform

618 lines (617 loc) 24.2 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.WeChatApiClient = void 0; const axios_1 = __importDefault(require("axios")); const form_data_1 = __importDefault(require("form-data")); /** * WeChat API Client for managing drafts and publishing articles */ class WeChatApiClient { constructor(config) { this.accessToken = null; this.tokenExpiryTime = 0; this.config = config; this.httpClient = axios_1.default.create({ baseURL: config.baseUrl, timeout: config.timeout, }); // Add request interceptor for automatic token refresh this.httpClient.interceptors.request.use(async (config) => { await this.ensureValidToken(); if (this.accessToken && config.url?.includes('access_token=')) { config.url = config.url.replace(/access_token=[^&]*/, `access_token=${this.accessToken}`); } else if (this.accessToken) { const separator = config.url?.includes('?') ? '&' : '?'; config.url += `${separator}access_token=${this.accessToken}`; } return config; }); // Add response interceptor for error handling this.httpClient.interceptors.response.use((response) => response, (error) => { if (error.response?.data?.errcode) { const wechatError = new Error(`WeChat API Error ${error.response.data.errcode}: ${error.response.data.errmsg}`); wechatError.name = 'WeChatApiError'; throw wechatError; } throw error; }); } /** * Ensure access token is valid and refresh if necessary */ async ensureValidToken() { const now = Date.now(); // Refresh token if it's expired or will expire in the next 5 minutes if (!this.accessToken || now >= (this.tokenExpiryTime - 300000)) { await this.refreshAccessToken(); } } /** * Refresh access token from WeChat API */ async refreshAccessToken() { try { const response = await axios_1.default.get(`${this.config.baseUrl}/cgi-bin/token`, { params: { grant_type: 'client_credential', appid: this.config.appId, secret: this.config.appSecret, }, timeout: this.config.timeout, }); if (response.data.access_token) { this.accessToken = response.data.access_token; this.tokenExpiryTime = Date.now() + (response.data.expires_in * 1000); } else { throw new Error('Failed to obtain access token from WeChat API'); } } catch (error) { throw new Error(`Failed to refresh access token: ${error.message}`); } } /** * Create a new draft article */ async createDraft(articles) { const requestData = { articles }; const response = await this.httpClient.post('/cgi-bin/draft/add', requestData); this.validateResponse(response.data); return response.data; } /** * Get an existing draft article */ async getDraft(mediaId) { const response = await this.httpClient.post('/cgi-bin/draft/get', { media_id: mediaId }); this.validateResponse(response.data); return response.data; } /** * Delete an existing draft article */ async deleteDraft(mediaId) { const response = await this.httpClient.post('/cgi-bin/draft/delete', { media_id: mediaId }); this.validateResponse(response.data); return response.data; } /** * Publish a draft article */ async publishDraft(mediaId) { const requestData = { media_id: mediaId }; const response = await this.httpClient.post('/cgi-bin/freepublish/submit', requestData); this.validateResponse(response.data); return response.data; } /** * Get publishing status of an article */ async getPublishStatus(publishId) { const response = await this.httpClient.post('/cgi-bin/freepublish/get', { publish_id: publishId }); this.validateResponse(response.data); return response.data; } /** * Upload media file (image/thumbnail) */ async uploadMedia(fileBuffer, mimeType, mediaType = 'image') { const formData = new form_data_1.default(); // Determine file extension from MIME type const extension = this.getFileExtension(mimeType); const fileName = `media.${extension}`; formData.append('media', fileBuffer, { filename: fileName, contentType: mimeType, }); formData.append('type', mediaType); const response = await this.httpClient.post('/cgi-bin/material/add_material', formData, { headers: { ...formData.getHeaders(), }, }); this.validateResponse(response.data); return response.data; } /** * Get file extension from MIME type */ getFileExtension(mimeType) { const mimeToExt = { 'image/jpeg': 'jpg', 'image/jpg': 'jpg', 'image/png': 'png', 'image/gif': 'gif', 'image/bmp': 'bmp', 'image/webp': 'webp', }; return mimeToExt[mimeType.toLowerCase()] || 'jpg'; } /** * Validate WeChat API response for errors */ validateResponse(data) { if (data.errcode && data.errcode !== 0) { throw new Error(`WeChat API Error ${data.errcode}: ${data.errmsg}`); } } /** * Get current access token (for debugging purposes) */ getAccessToken() { return this.accessToken; } /** * Get material list (images, videos, voices, news) */ async getMaterialList(type, offset = 0, count = 20) { const requestData = { type, offset, count: Math.min(count, 20) // WeChat API limit: max 20 items per request }; const response = await this.httpClient.post('/cgi-bin/material/batchget_material', requestData); this.validateResponse(response.data); return response.data; } /** * Check if access token is valid */ isTokenValid() { return this.accessToken !== null && Date.now() < (this.tokenExpiryTime - 300000); } /** * Batch upload memes from Meme API to WeChat */ async batchUploadMemes(count = 5, subreddit, skipNsfw = true, skipSpoiler = true, minUps = 0) { const result = { success_count: 0, failed_count: 0, duplicated_count: 0, media_ids: [], failed_urls: [], processed_memes: [], }; try { // Step 1: Fetch memes from Meme API const memes = await this.fetchMemesFromApi(count, subreddit); // Step 2: Filter memes based on criteria const filteredMemes = this.filterMemes(memes, skipNsfw, skipSpoiler, minUps); // Step 3: Remove duplicates based on URL const uniqueMemes = this.removeDuplicateMemes(filteredMemes); result.duplicated_count = filteredMemes.length - uniqueMemes.length; // Step 4: Download and upload each meme for (const meme of uniqueMemes) { try { // Download image const imageBuffer = await this.downloadImage(meme.url); // Upload to WeChat const uploadResult = await this.uploadMedia(imageBuffer, 'image/jpeg', 'image'); result.success_count++; result.media_ids.push(uploadResult.media_id); result.processed_memes.push({ url: meme.url, title: meme.title, media_id: uploadResult.media_id, }); } catch (error) { result.failed_count++; result.failed_urls.push(meme.url); result.processed_memes.push({ url: meme.url, title: meme.title, error: error.message, }); } } return result; } catch (error) { throw new Error(`Batch upload memes failed: ${error.message}`); } } /** * Fetch memes from Meme API */ async fetchMemesFromApi(count, subreddit) { const maxApiCount = Math.min(count, 50); // API限制最大50 let apiUrl = `https://meme-api.com/gimme/${maxApiCount}`; if (subreddit) { apiUrl = `https://meme-api.com/gimme/${subreddit}/${maxApiCount}`; } const response = await axios_1.default.get(apiUrl, { timeout: 30000, }); if (response.data.memes) { return response.data.memes; } else if (response.data.url) { // Single meme response return [{ postLink: response.data.postLink || '', subreddit: response.data.subreddit || '', title: response.data.title || '', url: response.data.url, nsfw: response.data.nsfw || false, spoiler: response.data.spoiler || false, author: response.data.author || '', ups: response.data.ups || 0, preview: response.data.preview || [], }]; } throw new Error('No memes found in API response'); } /** * Filter memes based on criteria */ filterMemes(memes, skipNsfw, skipSpoiler, minUps) { return memes.filter(meme => { if (skipNsfw && meme.nsfw) return false; if (skipSpoiler && meme.spoiler) return false; if (meme.ups < minUps) return false; return true; }); } /** * Remove duplicate memes based on URL */ removeDuplicateMemes(memes) { const seenUrls = new Set(); const uniqueMemes = []; for (const meme of memes) { if (!seenUrls.has(meme.url)) { seenUrls.add(meme.url); uniqueMemes.push(meme); } } return uniqueMemes; } /** * Download image from URL */ async downloadImage(url) { const response = await axios_1.default.get(url, { responseType: 'arraybuffer', timeout: 30000, headers: { 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', }, }); return Buffer.from(response.data); } /** * Process content and insert images intelligently */ processContentWithImages(content, config) { if (!config.enabled || config.imageIds.length === 0) { return { processedContent: content, insertedImages: 0, imagePositions: [], }; } // Validate image IDs const validImageIds = config.imageIds.filter(id => id && id.trim().length > 0); if (validImageIds.length === 0) { return { processedContent: content, insertedImages: 0, imagePositions: [], }; } // Update config with valid IDs const updatedConfig = { ...config, imageIds: validImageIds }; switch (config.mode) { case 'auto': return this.insertImagesAutomatically(content, updatedConfig); case 'paragraph': return this.insertImagesByParagraph(content, updatedConfig); case 'manual': // Manual mode expects images to be already in content return { processedContent: content, insertedImages: 0, imagePositions: [], }; default: return { processedContent: content, insertedImages: 0, imagePositions: [], }; } } /** * Create a draft article with automatic image integration from batch upload */ async createDraftWithBatchImages(article, imageConfig) { const processedArticle = { ...article }; let imageIntegrationInfo = null; // If image config is provided, process the content let finalArticle = processedArticle; if (imageConfig && imageConfig.enabled) { const result = this.processContentWithImages(article.content, imageConfig); finalArticle = { ...processedArticle, content: result.processedContent }; imageIntegrationInfo = { images_inserted: result.insertedImages, insertion_positions: result.imagePositions, mode: imageConfig.mode, strategy: imageConfig.strategy, available_images: imageConfig.imageIds.length, success: result.insertedImages > 0 }; } const response = await this.createDraft([finalArticle]); return { ...response, ...(imageIntegrationInfo && { image_integration_info: imageIntegrationInfo }) }; } /** * Automatically insert images at strategic positions */ insertImagesAutomatically(content, config) { const imagePositions = []; // Remove existing HTML tags for analysis const textContent = content.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); const sentences = textContent.split(/[.!?。!?]/).filter(s => s.trim().length > 10); if (sentences.length === 0) { return { processedContent: content, insertedImages: 0, imagePositions: [], }; } // Calculate insertion points const maxImages = Math.min(config.maxImages, config.imageIds.length, Math.ceil(sentences.length / 3)); const insertionPoints = this.calculateInsertionPoints(sentences.length, maxImages); let processedContent = content; let insertedCount = 0; // Insert images at calculated points (reverse order to maintain positions) for (let i = insertionPoints.length - 1; i >= 0 && insertedCount < maxImages; i--) { const imageId = this.selectImageByStrategy(config.imageIds, insertionPoints.length - 1 - i, config.strategy); const insertionPoint = insertionPoints[i]; // Find the position in the original HTML content const sentenceText = sentences[insertionPoint].trim(); const insertionPosition = this.findInsertionPositionInHtml(processedContent, sentenceText); if (insertionPosition > -1) { const imageHtml = this.generateWeChatImageHtml(imageId); processedContent = processedContent.slice(0, insertionPosition) + imageHtml + processedContent.slice(insertionPosition); imagePositions.unshift({ imageId, position: insertionPosition, context: sentenceText.substring(0, 50) + '...', }); insertedCount++; } } return { processedContent, insertedImages: insertedCount, imagePositions, }; } /** * Insert images by paragraph breaks */ insertImagesByParagraph(content, config) { const imagePositions = []; // Split content by paragraph tags and preserve the structure const paragraphPattern = /(<\/p>)/gi; const parts = content.split(paragraphPattern); if (parts.length < 3) { return { processedContent: content, insertedImages: 0, imagePositions: [], }; } // Count actual content paragraphs (skip closing tags) const contentParagraphs = parts.filter((part, index) => index % 2 === 0 && part.trim().length > 0); const maxImages = Math.min(config.maxImages, config.imageIds.length, Math.floor(contentParagraphs.length / 2)); if (maxImages === 0) { return { processedContent: content, insertedImages: 0, imagePositions: [], }; } const insertionInterval = Math.floor(contentParagraphs.length / (maxImages + 1)); let processedContent = content; let insertedCount = 0; // Insert images in reverse order to maintain positions for (let i = maxImages; i >= 1; i--) { const paragraphIndex = i * insertionInterval * 2; // *2 because of closing tags if (paragraphIndex < parts.length - 1) { const imageId = this.selectImageByStrategy(config.imageIds, i - 1, config.strategy); const imageHtml = `</p>${this.generateWeChatImageHtml(imageId)}<p>`; // Find position by reconstructing up to the target paragraph let position = 0; for (let j = 0; j <= paragraphIndex; j++) { position += parts[j].length; } processedContent = processedContent.slice(0, position) + imageHtml + processedContent.slice(position + 4); // +4 to replace </p> imagePositions.unshift({ imageId, position, context: `After paragraph ${Math.floor(paragraphIndex / 2) + 1}`, }); insertedCount++; } } return { processedContent, insertedImages: insertedCount, imagePositions, }; } /** * Calculate optimal insertion points for images */ calculateInsertionPoints(totalSentences, maxImages) { const points = []; const interval = Math.floor(totalSentences / (maxImages + 1)); for (let i = 1; i <= maxImages; i++) { points.push(Math.min(i * interval, totalSentences - 1)); } return points; } /** * Select image based on strategy */ selectImageByStrategy(imageIds, index, strategy) { switch (strategy) { case 'sequential': return imageIds[index % imageIds.length]; case 'random': return imageIds[Math.floor(Math.random() * imageIds.length)]; case 'balanced': // Distribute images evenly const step = imageIds.length / (index + 1); return imageIds[Math.floor(step * index) % imageIds.length]; default: return imageIds[0]; } } /** * Find insertion position in HTML content */ findInsertionPositionInHtml(htmlContent, sentenceText) { // Clean the sentence text for better matching const cleanSentence = sentenceText .replace(/\s+/g, ' ') .replace(/[.*+?^${}()|[\]\\]/g, '\\$&') .trim(); // Try multiple matching strategies const strategies = [ // Strategy 1: Exact match new RegExp(cleanSentence, 'i'), // Strategy 2: Partial match (first 30 characters) new RegExp(cleanSentence.substring(0, Math.min(30, cleanSentence.length)), 'i'), // Strategy 3: Match first and last few words new RegExp(this.getFirstAndLastWords(cleanSentence), 'i') ]; for (const regex of strategies) { const match = htmlContent.match(regex); if (match && match.index !== undefined) { let position = match.index + match[0].length; // Find the next suitable insertion point position = this.findNextInsertionPoint(htmlContent, position); if (position > -1) { return position; } } } // Fallback: insert at paragraph boundaries return this.findParagraphInsertionPoint(htmlContent); } /** * Extract first and last words for partial matching */ getFirstAndLastWords(sentence) { const words = sentence.split(' ').filter(w => w.length > 2); if (words.length < 2) return sentence.substring(0, 20); const firstWord = words[0].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const lastWord = words[words.length - 1].replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); return `${firstWord}.*${lastWord}`; } /** * Find the next suitable insertion point */ findNextInsertionPoint(htmlContent, startPosition) { let position = startPosition; let insideTag = false; while (position < htmlContent.length) { const char = htmlContent[position]; // Track if we're inside an HTML tag if (char === '<') { insideTag = true; } else if (char === '>') { insideTag = false; // Good insertion point after closing tag if (position + 1 < htmlContent.length) { return position + 1; } } else if (!insideTag) { // Look for sentence endings if (char === '.' || char === '!' || char === '?' || char === '。' || char === '!' || char === '?') { // Skip whitespace after punctuation let nextPos = position + 1; while (nextPos < htmlContent.length && /\s/.test(htmlContent[nextPos])) { nextPos++; } return nextPos; } } position++; } return startPosition; } /** * Find insertion point at paragraph boundaries as fallback */ findParagraphInsertionPoint(htmlContent) { // Look for paragraph endings const paragraphEndPattern = /<\/p>/gi; const matches = Array.from(htmlContent.matchAll(paragraphEndPattern)); if (matches.length > 0) { // Insert after the first paragraph const match = matches[0]; return (match.index || 0) + match[0].length; } // Fallback: insert at the middle of content return Math.floor(htmlContent.length / 2); } /** * Generate WeChat compatible image HTML * @param imageUrlOrMediaId - Either a full URL from Get Material List or a media_id * @param imageType - Image type (png, jpg, etc.) */ generateWeChatImageHtml(imageUrlOrMediaId, imageType = 'png') { // Check if it's a full URL (from Get Material List) or just a media_id const isFullUrl = imageUrlOrMediaId.startsWith('https://'); const imageUrl = isFullUrl ? imageUrlOrMediaId : `https://mmbiz.qpic.cn/mmbiz_${imageType}/${imageUrlOrMediaId}/0?wx_fmt=${imageType}`; // Use the actual WeChat image format as shown in the official editor return `<section style="text-align: center" nodeleaf><img src="${imageUrl}" data-src="${imageUrl}" class="rich_pages wxw-img" data-s="300,640" data-type="${imageType}" type="block" contenteditable="false" data-ratio="0.8542274052478134" data-w="1372"></section>`; } } exports.WeChatApiClient = WeChatApiClient;