mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-23 06:58:12 +02:00
Add Docker support and installation guide for easy server deployment
Includes a Dockerfile for building the application image and an INSTALLATION.md file detailing the setup process, prerequisites, and helpful commands for managing the application on a private server. Replit-Commit-Author: Agent Replit-Commit-Session-Id: ae4037a0-2a6f-4530-9bac-79b543286bda Replit-Commit-Checkpoint-Type: intermediate_checkpoint Replit-Commit-Screenshot-Url: https://storage.googleapis.com/screenshot-production-us-central1/397bca8c-984f-43ff-841a-10897aeb8140/ae4037a0-2a6f-4530-9bac-79b543286bda/ds3R7vP
This commit is contained in:
1 parent
bbad954935
commit
f881f668a6
89 files changed
+17095
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import sharp from "sharp";
|
||||
import { randomUUID } from "crypto";
|
||||
import path from "path";
|
||||
import fs from "fs/promises";
|
||||
|
||||
interface CropDimensions {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ProcessedImages {
|
||||
facebookFeed: string;
|
||||
instagramFeed: string;
|
||||
instagramStory: string;
|
||||
}
|
||||
|
||||
const CROP_FORMATS = {
|
||||
facebookFeed: { width: 1200, height: 630 } as CropDimensions,
|
||||
instagramFeed: { width: 1080, height: 1080 } as CropDimensions,
|
||||
instagramStory: { width: 1080, height: 1920 } as CropDimensions,
|
||||
};
|
||||
|
||||
export class ImageProcessor {
|
||||
private uploadsDir: string;
|
||||
|
||||
constructor() {
|
||||
this.uploadsDir = path.join(process.cwd(), "uploads");
|
||||
this.ensureUploadsDirExists();
|
||||
}
|
||||
|
||||
private async ensureUploadsDirExists() {
|
||||
try {
|
||||
await fs.access(this.uploadsDir);
|
||||
} catch {
|
||||
await fs.mkdir(this.uploadsDir, { recursive: true });
|
||||
}
|
||||
}
|
||||
|
||||
async processImage(buffer: Buffer, originalFilename: string): Promise<ProcessedImages> {
|
||||
const fileExt = path.extname(originalFilename);
|
||||
const baseId = randomUUID();
|
||||
|
||||
const processedImages: ProcessedImages = {
|
||||
facebookFeed: "",
|
||||
instagramFeed: "",
|
||||
instagramStory: "",
|
||||
};
|
||||
|
||||
// Process Facebook Feed (1200x630)
|
||||
const fbFeedFilename = `${baseId}-fb-feed${fileExt}`;
|
||||
const fbFeedPath = path.join(this.uploadsDir, fbFeedFilename);
|
||||
await sharp(buffer)
|
||||
.resize(CROP_FORMATS.facebookFeed.width, CROP_FORMATS.facebookFeed.height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.toFile(fbFeedPath);
|
||||
processedImages.facebookFeed = `/uploads/${fbFeedFilename}`;
|
||||
|
||||
// Process Instagram Feed (1080x1080)
|
||||
const igFeedFilename = `${baseId}-ig-feed${fileExt}`;
|
||||
const igFeedPath = path.join(this.uploadsDir, igFeedFilename);
|
||||
await sharp(buffer)
|
||||
.resize(CROP_FORMATS.instagramFeed.width, CROP_FORMATS.instagramFeed.height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.toFile(igFeedPath);
|
||||
processedImages.instagramFeed = `/uploads/${igFeedFilename}`;
|
||||
|
||||
// Process Instagram Story (1080x1920)
|
||||
const igStoryFilename = `${baseId}-ig-story${fileExt}`;
|
||||
const igStoryPath = path.join(this.uploadsDir, igStoryFilename);
|
||||
await sharp(buffer)
|
||||
.resize(CROP_FORMATS.instagramStory.width, CROP_FORMATS.instagramStory.height, {
|
||||
fit: "cover",
|
||||
position: "center",
|
||||
})
|
||||
.toFile(igStoryPath);
|
||||
processedImages.instagramStory = `/uploads/${igStoryFilename}`;
|
||||
|
||||
return processedImages;
|
||||
}
|
||||
|
||||
async saveOriginalFile(buffer: Buffer, filename: string): Promise<string> {
|
||||
const uniqueFilename = `${randomUUID()}-${filename}`;
|
||||
const filePath = path.join(this.uploadsDir, uniqueFilename);
|
||||
await fs.writeFile(filePath, buffer);
|
||||
return `/uploads/${uniqueFilename}`;
|
||||
}
|
||||
|
||||
async deleteFile(filePath: string): Promise<void> {
|
||||
try {
|
||||
const fullPath = path.join(process.cwd(), filePath);
|
||||
await fs.unlink(fullPath);
|
||||
} catch (error) {
|
||||
console.error("Error deleting file:", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const imageProcessor = new ImageProcessor();
|
||||
@@ -0,0 +1,148 @@
|
||||
interface ProductInfo {
|
||||
name: string;
|
||||
description?: string;
|
||||
price?: string;
|
||||
features?: string[];
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
interface GeneratedText {
|
||||
variant: string;
|
||||
text: string;
|
||||
characterCount: number;
|
||||
}
|
||||
|
||||
export class OpenRouterService {
|
||||
private apiKey: string;
|
||||
private baseUrl = "https://openrouter.ai/api/v1/chat/completions";
|
||||
|
||||
constructor() {
|
||||
this.apiKey = process.env.OPENROUTER_API_KEY || "";
|
||||
if (!this.apiKey) {
|
||||
console.warn("OPENROUTER_API_KEY not set. AI text generation will not work.");
|
||||
}
|
||||
}
|
||||
|
||||
async generatePostText(productInfo: ProductInfo): Promise<GeneratedText[]> {
|
||||
if (!this.apiKey) {
|
||||
throw new Error("OpenRouter API key not configured");
|
||||
}
|
||||
|
||||
const prompt = this.buildPrompt(productInfo);
|
||||
|
||||
try {
|
||||
const response = await fetch(this.baseUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Authorization": `Bearer ${this.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
"HTTP-Referer": process.env.APP_URL || "http://localhost:5000",
|
||||
"X-Title": "Social Flow"
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: "anthropic/claude-3.5-sonnet",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: prompt
|
||||
}
|
||||
],
|
||||
temperature: 0.8,
|
||||
max_tokens: 1000
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenRouter API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content = data.choices[0]?.message?.content || "";
|
||||
|
||||
return this.parseGeneratedText(content);
|
||||
} catch (error) {
|
||||
console.error("Error generating text with OpenRouter:", error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private buildPrompt(productInfo: ProductInfo): string {
|
||||
const features = productInfo.features?.join(", ") || "";
|
||||
|
||||
return `Tu es un expert en marketing sur les réseaux sociaux. Génère 3 variantes de texte pour une publication Facebook/Instagram pour ce produit:
|
||||
|
||||
Nom: ${productInfo.name}
|
||||
${productInfo.description ? `Description: ${productInfo.description}` : ''}
|
||||
${productInfo.price ? `Prix: ${productInfo.price}` : ''}
|
||||
${features ? `Caractéristiques: ${features}` : ''}
|
||||
|
||||
Crée 3 versions différentes:
|
||||
1. Version Dynamique - Énergique et engageante avec des emojis
|
||||
2. Version Informative - Factuelle et professionnelle
|
||||
3. Version Émotionnelle - Axée sur les bénéfices émotionnels
|
||||
|
||||
Chaque version doit:
|
||||
- Être en français
|
||||
- Faire entre 150-250 caractères
|
||||
- Être optimisée pour les réseaux sociaux
|
||||
- Inclure des emojis pertinents (sauf la version informative)
|
||||
|
||||
Format de réponse:
|
||||
VERSION 1 - DYNAMIQUE:
|
||||
[texte]
|
||||
|
||||
VERSION 2 - INFORMATIVE:
|
||||
[texte]
|
||||
|
||||
VERSION 3 - ÉMOTIONNELLE:
|
||||
[texte]`;
|
||||
}
|
||||
|
||||
private parseGeneratedText(content: string): GeneratedText[] {
|
||||
const variants: GeneratedText[] = [];
|
||||
|
||||
const version1Match = content.match(/VERSION 1 - DYNAMIQUE:\s*\n([\s\S]*?)(?=VERSION 2|$)/i);
|
||||
const version2Match = content.match(/VERSION 2 - INFORMATIVE:\s*\n([\s\S]*?)(?=VERSION 3|$)/i);
|
||||
const version3Match = content.match(/VERSION 3 - ÉMOTIONNELLE:\s*\n([\s\S]*?)$/i);
|
||||
|
||||
if (version1Match) {
|
||||
const text = version1Match[1].trim();
|
||||
variants.push({
|
||||
variant: "Version 1 - Dynamique",
|
||||
text,
|
||||
characterCount: text.length
|
||||
});
|
||||
}
|
||||
|
||||
if (version2Match) {
|
||||
const text = version2Match[1].trim();
|
||||
variants.push({
|
||||
variant: "Version 2 - Informative",
|
||||
text,
|
||||
characterCount: text.length
|
||||
});
|
||||
}
|
||||
|
||||
if (version3Match) {
|
||||
const text = version3Match[1].trim();
|
||||
variants.push({
|
||||
variant: "Version 3 - Émotionnelle",
|
||||
text,
|
||||
characterCount: text.length
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback if parsing fails
|
||||
if (variants.length === 0) {
|
||||
variants.push({
|
||||
variant: "Généré par IA",
|
||||
text: content.trim(),
|
||||
characterCount: content.trim().length
|
||||
});
|
||||
}
|
||||
|
||||
return variants;
|
||||
}
|
||||
}
|
||||
|
||||
export const openRouterService = new OpenRouterService();
|
||||
@@ -0,0 +1,69 @@
|
||||
import cron, { ScheduledTask } from "node-cron";
|
||||
import { storage } from "../storage";
|
||||
|
||||
export class SchedulerService {
|
||||
private task: ScheduledTask | null = null;
|
||||
|
||||
start() {
|
||||
// Run every minute to check for pending posts
|
||||
this.task = cron.schedule("* * * * *", async () => {
|
||||
await this.processPendingPosts();
|
||||
});
|
||||
|
||||
console.log("Scheduler service started");
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.task) {
|
||||
this.task.stop();
|
||||
console.log("Scheduler service stopped");
|
||||
}
|
||||
}
|
||||
|
||||
private async processPendingPosts() {
|
||||
try {
|
||||
const pendingPosts = await storage.getPendingScheduledPosts();
|
||||
|
||||
for (const scheduledPost of pendingPosts) {
|
||||
try {
|
||||
await this.publishPost(scheduledPost);
|
||||
} catch (error) {
|
||||
console.error(`Error publishing post ${scheduledPost.id}:`, error);
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error processing pending posts:", error);
|
||||
}
|
||||
}
|
||||
|
||||
private async publishPost(scheduledPost: any) {
|
||||
const post = await storage.getPost(scheduledPost.postId);
|
||||
const page = await storage.getSocialPage(scheduledPost.pageId);
|
||||
|
||||
if (!post || !page) {
|
||||
throw new Error("Post or page not found");
|
||||
}
|
||||
|
||||
// In a real implementation, this would call Facebook/Instagram Graph API
|
||||
// For now, we'll simulate the publishing
|
||||
console.log(`Publishing post ${post.id} to ${page.platform} page ${page.pageName}`);
|
||||
|
||||
// Update the scheduled post as published
|
||||
await storage.updateScheduledPost(scheduledPost.id, {
|
||||
publishedAt: new Date(),
|
||||
externalPostId: `simulated-${Date.now()}`, // In real app, this would be the ID from FB/IG
|
||||
});
|
||||
|
||||
// Update post status
|
||||
await storage.updatePost(post.id, {
|
||||
status: "published",
|
||||
});
|
||||
|
||||
console.log(`Successfully published post ${post.id}`);
|
||||
}
|
||||
}
|
||||
|
||||
export const schedulerService = new SchedulerService();
|
||||
Reference in new issue
Block a user