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
JavaScript
"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;