generated from fahricansecer/boilerplate-be
This commit is contained in:
@@ -38,7 +38,13 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
]);
|
||||
|
||||
if (isPublic) {
|
||||
return true;
|
||||
// Still try to extract user from token (best-effort) so @CurrentUser works
|
||||
// We call super but catch errors so public routes work without a token
|
||||
const parentResult = super.canActivate(context);
|
||||
if (parentResult instanceof Promise) {
|
||||
return parentResult.catch(() => true);
|
||||
}
|
||||
return parentResult;
|
||||
}
|
||||
|
||||
return super.canActivate(context);
|
||||
|
||||
@@ -16,6 +16,8 @@ import { NeuroMarketingModule } from '../neuro-marketing/neuro-marketing.module'
|
||||
import { GeminiModule } from '../gemini/gemini.module';
|
||||
import { VisualGenerationModule } from '../visual-generation/visual-generation.module';
|
||||
import { WebScraperService } from '../trends/services/web-scraper.service';
|
||||
import { YoutubeTranscriptService } from './services/youtube-transcript.service';
|
||||
import { XToMarkdownService } from './services/x-to-markdown.service';
|
||||
|
||||
|
||||
@Module({
|
||||
@@ -30,6 +32,8 @@ import { WebScraperService } from '../trends/services/web-scraper.service';
|
||||
BrandVoiceService,
|
||||
VariationService,
|
||||
WebScraperService,
|
||||
YoutubeTranscriptService,
|
||||
XToMarkdownService,
|
||||
],
|
||||
controllers: [ContentGenerationController],
|
||||
exports: [ContentGenerationService],
|
||||
|
||||
@@ -15,6 +15,8 @@ import { NeuroMarketingService } from '../neuro-marketing/neuro-marketing.servic
|
||||
import { StorageService } from '../visual-generation/services/storage.service';
|
||||
import { VisualGenerationService } from '../visual-generation/visual-generation.service';
|
||||
import { WebScraperService, ScrapedContent } from '../trends/services/web-scraper.service';
|
||||
import { YoutubeTranscriptService } from './services/youtube-transcript.service';
|
||||
import { XToMarkdownService, XToMarkdownResult } from './services/x-to-markdown.service';
|
||||
import { ContentType as PrismaContentType, ContentStatus as PrismaContentStatus, MasterContentType as PrismaMasterContentType } from '@prisma/client';
|
||||
|
||||
|
||||
@@ -79,6 +81,8 @@ export class ContentGenerationService {
|
||||
private readonly storageService: StorageService,
|
||||
private readonly visualService: VisualGenerationService,
|
||||
private readonly webScraperService: WebScraperService,
|
||||
private readonly youtubeTranscriptService: YoutubeTranscriptService,
|
||||
private readonly xToMarkdownService: XToMarkdownService,
|
||||
) { }
|
||||
|
||||
|
||||
@@ -103,9 +107,27 @@ export class ContentGenerationService {
|
||||
|
||||
console.log(`[ContentGenerationService] Starting generation for topic: ${topic}, platforms: ${platforms.join(', ')}`);
|
||||
|
||||
// ========== STEP 0.5: Check if source URL is X/Twitter ==========
|
||||
let xMarkdownContent = '';
|
||||
if (sourceUrl && this.xToMarkdownService.isXUrl(sourceUrl)) {
|
||||
this.logger.log(`Detected X/Twitter URL: ${sourceUrl}. Converting to markdown...`);
|
||||
try {
|
||||
const xResult = await this.xToMarkdownService.convertToMarkdown(sourceUrl);
|
||||
if (xResult.success && xResult.markdown) {
|
||||
xMarkdownContent = this.xToMarkdownService.getMarkdownExcerpt(xResult.markdown, 3000);
|
||||
this.logger.log(`X tweet converted to markdown: ${xMarkdownContent.length} chars, author: ${xResult.author}`);
|
||||
} else {
|
||||
this.logger.warn(`X-to-Markdown failed: ${xResult.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`X-to-Markdown error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== STEP 1: Scrape source article if URL provided ==========
|
||||
let scrapedSource: ScrapedContent | null = null;
|
||||
if (sourceUrl) {
|
||||
if (sourceUrl && !xMarkdownContent) {
|
||||
// Only scrape via web if we didn't already get content from X
|
||||
this.logger.log(`Scraping source article: ${sourceUrl}`);
|
||||
try {
|
||||
scrapedSource = await this.webScraperService.scrapeUrl(sourceUrl, {
|
||||
@@ -121,6 +143,18 @@ export class ContentGenerationService {
|
||||
} catch (err) {
|
||||
this.logger.warn(`Source scraping error: ${err.message}`);
|
||||
}
|
||||
} else if (sourceUrl && xMarkdownContent) {
|
||||
// X URL was successfully converted — still attempt scraping for images/links
|
||||
this.logger.log(`X content obtained, attempting supplementary scrape for media...`);
|
||||
try {
|
||||
scrapedSource = await this.webScraperService.scrapeUrl(sourceUrl, {
|
||||
extractImages: true,
|
||||
extractLinks: true,
|
||||
timeout: 10000,
|
||||
}, topic);
|
||||
} catch {
|
||||
// Not critical — X markdown is primary source
|
||||
}
|
||||
}
|
||||
|
||||
// Analyze niche if provided
|
||||
@@ -141,41 +175,116 @@ export class ContentGenerationService {
|
||||
}
|
||||
|
||||
// ========== Build enriched context from scraped source ==========
|
||||
let sourceContext = '';
|
||||
// Extract key data from scraped source for use in prompts
|
||||
let sourceArticleText = '';
|
||||
let sourceVideoLinks: string[] = [];
|
||||
let sourceImportantLinks: string[] = [];
|
||||
const sourceImageUrl = scrapedSource?.images?.[0]?.src || undefined;
|
||||
|
||||
if (scrapedSource) {
|
||||
const articleText = scrapedSource.content.substring(0, 3000);
|
||||
const videoInfo = scrapedSource.videoLinks.length > 0
|
||||
? `\nVİDEO LİNKLERİ: ${scrapedSource.videoLinks.join(', ')}`
|
||||
: '';
|
||||
const importantLinks = scrapedSource.links
|
||||
sourceArticleText = scrapedSource.content.substring(0, 3000);
|
||||
sourceVideoLinks = scrapedSource.videoLinks || [];
|
||||
sourceImportantLinks = scrapedSource.links
|
||||
.filter(l => l.isExternal && !l.href.includes('facebook') && !l.href.includes('twitter'))
|
||||
.slice(0, 5)
|
||||
.map(l => `${l.text}: ${l.href}`)
|
||||
.join('\n');
|
||||
const linkInfo = importantLinks ? `\nÖNEMLİ LİNKLER:\n${importantLinks}` : '';
|
||||
|
||||
sourceContext = `\n\n📰 KAYNAK MAKALE İÇERİĞİ (ZORUNLU REFERANS):\n${articleText}${videoInfo}${linkInfo}\n\n⚠️ ÖNEMLİ: Yukarıdaki kaynak makaledeki TÜM özneleri (kişi, ürün, oyun adları, tarihler, fiyatlar, markalar) habere dahil et. Hiçbir önemli bilgiyi atlama. Video linkleri ve önemli dış linkler varsa bunları da içerikte paylaş.`;
|
||||
.map(l => l.href);
|
||||
}
|
||||
|
||||
// ========== STEP 1.5: Fetch YouTube transcript if applicable ==========
|
||||
let youtubeTranscript = '';
|
||||
const allVideoUrls = [...sourceVideoLinks];
|
||||
if (sourceUrl && this.youtubeTranscriptService.isYoutubeUrl(sourceUrl)) {
|
||||
allVideoUrls.unshift(sourceUrl);
|
||||
}
|
||||
|
||||
// Try to get transcript from any YouTube URLs we found
|
||||
const uniqueYtUrls = [...new Set(allVideoUrls.filter(u => this.youtubeTranscriptService.isYoutubeUrl(u)))];
|
||||
if (uniqueYtUrls.length > 0) {
|
||||
this.logger.log(`Found ${uniqueYtUrls.length} YouTube URL(s), fetching transcript(s)...`);
|
||||
for (const ytUrl of uniqueYtUrls.slice(0, 3)) {
|
||||
try {
|
||||
const transcript = await this.youtubeTranscriptService.getTranscript(ytUrl);
|
||||
if (transcript) {
|
||||
// Take an excerpt to avoid overwhelming the prompt
|
||||
const excerpt = this.youtubeTranscriptService.getTranscriptExcerpt(transcript, 2500);
|
||||
youtubeTranscript += `\n\nYouTube Video Transkripti (${ytUrl}):\n${excerpt}`;
|
||||
this.logger.log(`Got YouTube transcript: ${transcript.length} chars (excerpt: ${excerpt.length} chars)`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(`Failed to get transcript for ${ytUrl}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Medium-first generation strategy ==========
|
||||
// 1. Generate Medium (blog) content FIRST with full source article context
|
||||
// 2. Use the Medium content as the base for other platforms (X, LinkedIn, Instagram, etc.)
|
||||
// This ensures consistency across platforms and prevents source context leakage.
|
||||
|
||||
const sanitizedSummary = this.sanitizeResearchSummary(
|
||||
research?.summary || `Everything you need to know about ${topic}`
|
||||
);
|
||||
|
||||
// Build the source context as clean system instructions (NOT as content to reproduce)
|
||||
let sourceContextForMedium = '';
|
||||
if (sourceArticleText) {
|
||||
const videoPart = sourceVideoLinks.length > 0
|
||||
? `\nKullanılabilir video linkleri: ${sourceVideoLinks.join(', ')}`
|
||||
: '';
|
||||
const linksPart = sourceImportantLinks.length > 0
|
||||
? `\nKullanılabilir linkler: ${sourceImportantLinks.join(', ')}`
|
||||
: '';
|
||||
|
||||
sourceContextForMedium = `\n\n--- REFERANS BİLGİ (BU BÖLÜMÜ İÇERİĞE KOPYALAMA, SADECE BİLGİ KAYNAĞI OLARAK KULLAN) ---\nAşağıdaki metin kaynak makaleden alınmıştır. Bu metni kopyalama, kendi özgün cümlelerinle yeniden yaz. Ama içindeki TÜM önemli bilgileri (ürün/oyun adları, tarihler, fiyatlar, markalar, özellikler) mutlaka habere dahil et.${videoPart}${linksPart}${youtubeTranscript ? '\n\n' + youtubeTranscript : ''}\n\nKaynak metin:\n${sourceArticleText}\n--- REFERANS BİLGİ SONU ---`;
|
||||
}
|
||||
|
||||
// Add X/Twitter markdown content as additional source if available
|
||||
if (xMarkdownContent) {
|
||||
const xContextBlock = `\n\n--- X/TWITTER POST İÇERİĞİ (BİLGİ KAYNAĞI OLARAK KULLAN, KOPYALAMA) ---\nAşağıdaki metin bir X (Twitter) postundan alınmıştır. Bu metni kopyalama, kendi özgün cümlelerinle yeniden yaz. İçindeki TÜM önemli bilgileri habere dahil et.\n\nX Post İçeriği:\n${xMarkdownContent}\n--- X/TWITTER POST SONU ---`;
|
||||
|
||||
if (sourceContextForMedium) {
|
||||
// Append X content to existing source context
|
||||
sourceContextForMedium += xContextBlock;
|
||||
} else {
|
||||
// X content is the only source
|
||||
sourceContextForMedium = xContextBlock;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate content for each platform using AI
|
||||
const platformContent: GeneratedContent[] = [];
|
||||
for (const platform of platforms) {
|
||||
let mediumContent = ''; // Will hold the generated Medium article text
|
||||
|
||||
// Sort platforms: Medium/blog first, then others
|
||||
const sortedPlatforms = [...platforms].sort((a, b) => {
|
||||
const aIsMedium = a.toLowerCase() === 'medium' || a.toLowerCase() === 'blog';
|
||||
const bIsMedium = b.toLowerCase() === 'medium' || b.toLowerCase() === 'blog';
|
||||
if (aIsMedium && !bIsMedium) return -1;
|
||||
if (!aIsMedium && bIsMedium) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
for (const platform of sortedPlatforms) {
|
||||
try {
|
||||
const normalizedPlatform = platform.toLowerCase();
|
||||
const isMediumPlatform = normalizedPlatform === 'medium' || normalizedPlatform === 'blog';
|
||||
|
||||
this.logger.log(`Generating for platform: ${platform}`);
|
||||
|
||||
// Use AI generation when available
|
||||
// Sanitize research summary to remove source names/URLs
|
||||
const sanitizedSummary = this.sanitizeResearchSummary(
|
||||
research?.summary || `Everything you need to know about ${topic}`
|
||||
);
|
||||
// Append scraped source context to give AI the full article details
|
||||
const enrichedSummary = sanitizedSummary + sourceContext;
|
||||
// Normalize platform to lowercase for consistency
|
||||
const normalizedPlatform = platform.toLowerCase();
|
||||
let enrichedSummary: string;
|
||||
|
||||
if (isMediumPlatform || !mediumContent) {
|
||||
// Medium gets full source context for comprehensive article generation
|
||||
enrichedSummary = sanitizedSummary + sourceContextForMedium;
|
||||
} else {
|
||||
// Other platforms derive from the already-generated Medium article
|
||||
// This ensures consistency and prevents source context leakage
|
||||
enrichedSummary = `Aşağıdaki blog makalesi bu konu hakkında daha önce hazırlanmıştır. Bu makaledeki bilgileri, üslubu ve ana mesajı kullanarak ${platform} platformu için kısa ve etkili bir içerik oluştur. Makaledeki oyun/ürün adları, tarihler, fiyatlar, linkler gibi kritik bilgileri mutlaka içeriğe dahil et.\n\nBLOG MAKALESİ:\n${mediumContent}`;
|
||||
}
|
||||
|
||||
const aiContent = await this.platformService.generateAIContent(
|
||||
topic,
|
||||
enrichedSummary,
|
||||
normalizedPlatform as any, // Cast to any/Platform to resolve type mismatch if Platform is strict union
|
||||
normalizedPlatform as any,
|
||||
'standard',
|
||||
'tr',
|
||||
writingStyle,
|
||||
@@ -188,8 +297,11 @@ export class ContentGenerationService {
|
||||
this.logger.warn(`AI Content is empty for ${platform}`);
|
||||
}
|
||||
|
||||
// Use scraped image from source if available
|
||||
const sourceImageUrl = scrapedSource?.images?.[0]?.src || undefined;
|
||||
// Save Medium content for other platforms to derive from
|
||||
if (isMediumPlatform && aiContent) {
|
||||
mediumContent = aiContent;
|
||||
this.logger.log('Medium/blog content generated — will be used as base for other platforms');
|
||||
}
|
||||
|
||||
const config = this.platformService.getPlatformConfig(platform);
|
||||
let content: GeneratedContent = {
|
||||
@@ -242,7 +354,6 @@ export class ContentGenerationService {
|
||||
content.imageUrl = image.url;
|
||||
this.logger.log(`Image generated for ${platform}: ${image.url}`);
|
||||
} else if (sourceImageUrl) {
|
||||
// Use scraped source image instead of placeholder
|
||||
content.imageUrl = sourceImageUrl;
|
||||
this.logger.log(`Using scraped source image instead of placeholder: ${sourceImageUrl}`);
|
||||
} else {
|
||||
@@ -251,21 +362,18 @@ export class ContentGenerationService {
|
||||
}
|
||||
} catch (imgError) {
|
||||
this.logger.warn(`Image generation failed for ${platform}, continuing without image`, imgError);
|
||||
// Fallback to scraped source image
|
||||
if (sourceImageUrl) {
|
||||
content.imageUrl = sourceImageUrl;
|
||||
this.logger.log(`Using scraped source image as fallback: ${sourceImageUrl}`);
|
||||
}
|
||||
}
|
||||
} else if (sourceImageUrl && !content.imageUrl) {
|
||||
// For non-visual platforms, still attach source image if available
|
||||
content.imageUrl = sourceImageUrl;
|
||||
}
|
||||
|
||||
platformContent.push(content);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to generate for ${platform}`, error);
|
||||
// Continue to next platform even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
184
src/modules/content-generation/services/x-to-markdown.service.ts
Normal file
184
src/modules/content-generation/services/x-to-markdown.service.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
// X to Markdown Service - Converts X/Twitter posts to markdown using baoyu skill
|
||||
// Path: src/modules/content-generation/services/x-to-markdown.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { execFile } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
export interface XToMarkdownResult {
|
||||
success: boolean;
|
||||
markdown: string;
|
||||
url: string;
|
||||
author?: string;
|
||||
tweetCount?: number;
|
||||
coverImage?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class XToMarkdownService {
|
||||
private readonly logger = new Logger(XToMarkdownService.name);
|
||||
|
||||
// Path to the skill scripts directory — resolve relative to project root
|
||||
private readonly SKILL_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
'../.agent/skills/baoyu-danger-x-to-markdown/scripts',
|
||||
);
|
||||
|
||||
private readonly MAIN_SCRIPT = path.join(this.SKILL_DIR, 'main.ts');
|
||||
|
||||
/**
|
||||
* Check if a URL is an X/Twitter URL
|
||||
*/
|
||||
isXUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
const lowerUrl = url.toLowerCase();
|
||||
return (
|
||||
lowerUrl.includes('x.com/') ||
|
||||
lowerUrl.includes('twitter.com/')
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a tweet URL (has /status/ in it)
|
||||
*/
|
||||
isTweetUrl(url: string): boolean {
|
||||
if (!this.isXUrl(url)) return false;
|
||||
return url.includes('/status/') || url.includes('/i/article/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an X/Twitter URL to markdown using the baoyu skill
|
||||
*/
|
||||
async convertToMarkdown(url: string): Promise<XToMarkdownResult> {
|
||||
if (!this.isXUrl(url)) {
|
||||
return {
|
||||
success: false,
|
||||
markdown: '',
|
||||
url,
|
||||
error: 'Not an X/Twitter URL',
|
||||
};
|
||||
}
|
||||
|
||||
// Check if skill script exists
|
||||
if (!fs.existsSync(this.MAIN_SCRIPT)) {
|
||||
this.logger.warn(`X-to-Markdown skill script not found at: ${this.MAIN_SCRIPT}`);
|
||||
// Try alternative paths
|
||||
const altPaths = [
|
||||
path.resolve(process.cwd(), '.agent/skills/baoyu-danger-x-to-markdown/scripts/main.ts'),
|
||||
path.resolve(process.cwd(), '../../.agent/skills/baoyu-danger-x-to-markdown/scripts/main.ts'),
|
||||
];
|
||||
|
||||
let found = false;
|
||||
for (const altPath of altPaths) {
|
||||
if (fs.existsSync(altPath)) {
|
||||
this.logger.log(`Found script at alternative path: ${altPath}`);
|
||||
return this.runScript(altPath, url);
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
return {
|
||||
success: false,
|
||||
markdown: '',
|
||||
url,
|
||||
error: `Skill script not found. Searched: ${this.MAIN_SCRIPT}, ${altPaths.join(', ')}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return this.runScript(this.MAIN_SCRIPT, url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the baoyu skill script to convert a URL to markdown
|
||||
*/
|
||||
private async runScript(scriptPath: string, url: string): Promise<XToMarkdownResult> {
|
||||
try {
|
||||
this.logger.log(`Converting X URL to markdown: ${url}`);
|
||||
|
||||
// Use npx -y bun to run the TypeScript script
|
||||
const { stdout, stderr } = await execFileAsync(
|
||||
'npx',
|
||||
['-y', 'bun', scriptPath, url, '--json'],
|
||||
{
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxBuffer: 1024 * 1024 * 5, // 5MB
|
||||
env: {
|
||||
...process.env,
|
||||
// Pass X auth tokens if set in environment
|
||||
X_AUTH_TOKEN: process.env.X_AUTH_TOKEN || '',
|
||||
X_CT0: process.env.X_CT0 || '',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
if (stderr) {
|
||||
this.logger.warn(`X-to-Markdown stderr: ${stderr.substring(0, 500)}`);
|
||||
}
|
||||
|
||||
// Try to parse JSON output
|
||||
try {
|
||||
const result = JSON.parse(stdout.trim());
|
||||
this.logger.log(`Successfully converted X URL: ${url} (${result.markdown?.length || 0} chars)`);
|
||||
return {
|
||||
success: true,
|
||||
markdown: result.markdown || result.content || stdout,
|
||||
url,
|
||||
author: result.author,
|
||||
tweetCount: result.tweetCount,
|
||||
coverImage: result.coverImage,
|
||||
};
|
||||
} catch {
|
||||
// If not valid JSON, the output is the markdown itself
|
||||
this.logger.log(`X-to-Markdown returned plain text (${stdout.length} chars)`);
|
||||
return {
|
||||
success: true,
|
||||
markdown: stdout.trim(),
|
||||
url,
|
||||
};
|
||||
}
|
||||
} catch (error: any) {
|
||||
this.logger.error(`Failed to convert X URL: ${url}`, error.message);
|
||||
|
||||
// If the script fails, try a simpler fallback approach
|
||||
return this.fallbackScrape(url);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: If the baoyu script fails, try a basic scrape
|
||||
* using the existing WebScraperService pattern
|
||||
*/
|
||||
private async fallbackScrape(url: string): Promise<XToMarkdownResult> {
|
||||
this.logger.warn(`Using fallback scrape for X URL: ${url}`);
|
||||
return {
|
||||
success: false,
|
||||
markdown: '',
|
||||
url,
|
||||
error: 'Script execution failed. X auth tokens may be needed. Set X_AUTH_TOKEN and X_CT0 environment variables.',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a concise excerpt from the markdown for use in content generation prompts
|
||||
*/
|
||||
getMarkdownExcerpt(markdown: string, maxLength: number = 3000): string {
|
||||
if (!markdown) return '';
|
||||
|
||||
// Remove YAML front matter
|
||||
const fmMatch = markdown.match(/^---\n[\s\S]*?\n---\n/);
|
||||
let content = fmMatch ? markdown.slice(fmMatch[0].length) : markdown;
|
||||
|
||||
// Trim to maxLength
|
||||
if (content.length > maxLength) {
|
||||
content = content.substring(0, maxLength) + '\n\n[...devamı kırpıldı]';
|
||||
}
|
||||
|
||||
return content.trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
// YouTube Transcript Service
|
||||
// Uses the youtube-transcript-api Python script via uv to extract transcripts from YouTube videos
|
||||
// Path: src/modules/content-generation/services/youtube-transcript.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { exec } from 'child_process';
|
||||
import { promisify } from 'util';
|
||||
import * as path from 'path';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
@Injectable()
|
||||
export class YoutubeTranscriptService {
|
||||
private readonly logger = new Logger(YoutubeTranscriptService.name);
|
||||
|
||||
// Path to the transcript script (project root → .agent/skills/youtube-transcript/scripts)
|
||||
private readonly SCRIPT_PATH = path.resolve(
|
||||
process.cwd(),
|
||||
'../.agent/skills/youtube-transcript/scripts/get_transcript.py',
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a URL is a YouTube URL
|
||||
*/
|
||||
isYoutubeUrl(url: string): boolean {
|
||||
if (!url) return false;
|
||||
return /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)/i.test(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract video ID from a YouTube URL
|
||||
*/
|
||||
extractVideoId(url: string): string | null {
|
||||
const patterns = [
|
||||
/(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/embed\/|youtube\.com\/v\/)([a-zA-Z0-9_-]{11})/,
|
||||
/^([a-zA-Z0-9_-]{11})$/,
|
||||
];
|
||||
for (const pattern of patterns) {
|
||||
const match = url.match(pattern);
|
||||
if (match) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch transcript for a YouTube video
|
||||
* @param videoUrlOrId YouTube URL or video ID
|
||||
* @param withTimestamps Whether to include timestamps
|
||||
* @returns Transcript text or null if failed
|
||||
*/
|
||||
async getTranscript(videoUrlOrId: string, withTimestamps = false): Promise<string | null> {
|
||||
try {
|
||||
const timestampFlag = withTimestamps ? ' --timestamps' : '';
|
||||
const command = `uv run "${this.SCRIPT_PATH}" "${videoUrlOrId}"${timestampFlag}`;
|
||||
|
||||
this.logger.log(`Fetching YouTube transcript for: ${videoUrlOrId}`);
|
||||
|
||||
const { stdout, stderr } = await execAsync(command, {
|
||||
timeout: 30000, // 30 second timeout
|
||||
maxBuffer: 1024 * 1024 * 5, // 5MB buffer for long transcripts
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('Installed') && !stderr.includes('packages')) {
|
||||
this.logger.warn(`Transcript stderr: ${stderr.substring(0, 200)}`);
|
||||
}
|
||||
|
||||
const transcript = stdout.trim();
|
||||
if (transcript.length > 0) {
|
||||
this.logger.log(`Got transcript: ${transcript.length} characters`);
|
||||
return transcript;
|
||||
}
|
||||
|
||||
this.logger.warn(`Empty transcript for: ${videoUrlOrId}`);
|
||||
return null;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to get transcript for ${videoUrlOrId}: ${error.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract transcripts from multiple YouTube URLs
|
||||
* Returns a map of URL → transcript
|
||||
*/
|
||||
async getTranscriptsFromUrls(urls: string[]): Promise<Map<string, string>> {
|
||||
const result = new Map<string, string>();
|
||||
const youtubeUrls = urls.filter(url => this.isYoutubeUrl(url));
|
||||
|
||||
if (youtubeUrls.length === 0) return result;
|
||||
|
||||
this.logger.log(`Fetching transcripts for ${youtubeUrls.length} YouTube URLs`);
|
||||
|
||||
// Fetch transcripts in parallel (max 3 concurrent)
|
||||
const chunks: string[][] = [];
|
||||
for (let i = 0; i < youtubeUrls.length; i += 3) {
|
||||
chunks.push(youtubeUrls.slice(i, i + 3));
|
||||
}
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const promises = chunk.map(async (url) => {
|
||||
const transcript = await this.getTranscript(url);
|
||||
if (transcript) {
|
||||
result.set(url, transcript);
|
||||
}
|
||||
});
|
||||
await Promise.all(promises);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a summary-length excerpt from a transcript (first N characters)
|
||||
*/
|
||||
getTranscriptExcerpt(transcript: string, maxLength = 2000): string {
|
||||
if (transcript.length <= maxLength) return transcript;
|
||||
|
||||
// Try to cut at a sentence boundary
|
||||
const excerpt = transcript.substring(0, maxLength);
|
||||
const lastPeriod = excerpt.lastIndexOf('.');
|
||||
const lastNewline = excerpt.lastIndexOf('\n');
|
||||
const cutPoint = Math.max(lastPeriod, lastNewline);
|
||||
|
||||
if (cutPoint > maxLength * 0.7) {
|
||||
return excerpt.substring(0, cutPoint + 1);
|
||||
}
|
||||
return excerpt + '...';
|
||||
}
|
||||
}
|
||||
@@ -100,18 +100,24 @@ export class ContentController {
|
||||
@ApiOperation({ summary: 'Get user contents' })
|
||||
@ApiQuery({ name: 'platform', required: false, enum: SocialPlatform })
|
||||
@ApiQuery({ name: 'status', required: false, enum: ContentStatus })
|
||||
@ApiQuery({ name: 'sortBy', required: false })
|
||||
@ApiQuery({ name: 'sortOrder', required: false })
|
||||
async getContents(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('platform') platform?: SocialPlatform,
|
||||
@Query('status') status?: ContentStatus,
|
||||
@Query('limit') limit?: number,
|
||||
@Query('offset') offset?: number,
|
||||
@Query('sortBy') sortBy?: string,
|
||||
@Query('sortOrder') sortOrder?: 'asc' | 'desc',
|
||||
) {
|
||||
return this.contentService.getByUser(userId || undefined, {
|
||||
platform,
|
||||
status,
|
||||
limit: limit ? Number(limit) : undefined,
|
||||
offset: offset ? Number(offset) : undefined,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -171,6 +177,15 @@ export class ContentController {
|
||||
return this.contentService.delete(id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('bulk-delete')
|
||||
@ApiOperation({ summary: 'Bulk delete content items' })
|
||||
async bulkDeleteContent(
|
||||
@Body('ids') ids: string[],
|
||||
) {
|
||||
return this.contentService.bulkDelete(ids);
|
||||
}
|
||||
|
||||
// ==================== Variations ====================
|
||||
|
||||
@Post(':id/variations')
|
||||
|
||||
@@ -81,23 +81,40 @@ export class ContentService {
|
||||
status?: ContentStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sortBy?: string;
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
},
|
||||
) {
|
||||
return this.prisma.content.findMany({
|
||||
where: {
|
||||
...(userId && { userId }),
|
||||
...(options?.platform && { type: options.platform as any }),
|
||||
...(options?.status && { status: options.status }),
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: options?.limit || 50,
|
||||
skip: options?.offset || 0,
|
||||
include: {
|
||||
masterContent: { select: { id: true, title: true, type: true } },
|
||||
variants: { select: { id: true, name: true, isWinner: true } },
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
});
|
||||
// Build dynamic orderBy
|
||||
const sortField = options?.sortBy || 'createdAt';
|
||||
const sortDir = options?.sortOrder || 'desc';
|
||||
const validSortFields = ['createdAt', 'updatedAt', 'type', 'status', 'publishedAt'];
|
||||
const orderBy = validSortFields.includes(sortField)
|
||||
? { [sortField]: sortDir }
|
||||
: { createdAt: 'desc' as const };
|
||||
|
||||
const where = {
|
||||
...(userId && { userId }),
|
||||
...(options?.platform && { type: options.platform as any }),
|
||||
...(options?.status && { status: options.status }),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.content.findMany({
|
||||
where,
|
||||
orderBy,
|
||||
take: options?.limit || 100,
|
||||
skip: options?.offset || 0,
|
||||
include: {
|
||||
masterContent: { select: { id: true, title: true, type: true } },
|
||||
variants: { select: { id: true, name: true, isWinner: true } },
|
||||
_count: { select: { variants: true } },
|
||||
},
|
||||
}),
|
||||
this.prisma.content.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, total };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,12 +156,42 @@ export class ContentService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete content
|
||||
* Delete all related records for given content IDs, then delete the content.
|
||||
* Uses a transaction to ensure atomicity.
|
||||
*/
|
||||
private async cascadeDeleteContents(ids: string[]) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Delete all dependent relations first
|
||||
await tx.citation.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.contentVariant.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.media.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.scheduledPost.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.contentApproval.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.contentAnalytics.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.contentSeo.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.contentPsychology.deleteMany({ where: { contentId: { in: ids } } });
|
||||
await tx.contentSource.deleteMany({ where: { contentId: { in: ids } } });
|
||||
|
||||
// Now delete the content itself
|
||||
const result = await tx.content.deleteMany({ where: { id: { in: ids } } });
|
||||
return result.count;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete content (with cascade)
|
||||
*/
|
||||
async delete(id: string) {
|
||||
return this.prisma.content.delete({
|
||||
where: { id },
|
||||
});
|
||||
const deleted = await this.cascadeDeleteContents([id]);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk delete content items (with cascade)
|
||||
*/
|
||||
async bulkDelete(ids: string[]) {
|
||||
const deleted = await this.cascadeDeleteContents(ids);
|
||||
return { deleted };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,69 +12,168 @@ import {
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import { SourceAccountsService } from './source-accounts.service';
|
||||
import { CurrentUser, Public } from '../../common/decorators';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import type { SocialPlatform } from './services/content-parser.service';
|
||||
import type { GoldPostCategory } from './services/gold-post.service';
|
||||
|
||||
@Controller('source-accounts')
|
||||
export class SourceAccountsController {
|
||||
constructor(private readonly service: SourceAccountsService) { }
|
||||
constructor(
|
||||
private readonly service: SourceAccountsService,
|
||||
private readonly prisma: PrismaService,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* Resolve userId — fallback to first user if @CurrentUser returns undefined
|
||||
* (happens on @Public() routes when no valid JWT is present)
|
||||
*/
|
||||
private async resolveUserId(userId: string | undefined): Promise<string> {
|
||||
if (userId) return userId;
|
||||
const firstUser = await this.prisma.user.findFirst({ select: { id: true } });
|
||||
if (!firstUser) throw new Error('No users found in the system');
|
||||
return firstUser.id;
|
||||
}
|
||||
|
||||
// ========== SOURCE ACCOUNT MANAGEMENT ==========
|
||||
|
||||
@Public()
|
||||
@Post()
|
||||
addSourceAccount(
|
||||
async addSourceAccount(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Body() body: {
|
||||
platform: SocialPlatform;
|
||||
username: string;
|
||||
url?: string;
|
||||
platform?: SocialPlatform;
|
||||
username?: string;
|
||||
displayName?: string;
|
||||
profileUrl: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
profileUrl?: string;
|
||||
},
|
||||
) {
|
||||
return this.service.addSourceAccount(body);
|
||||
const resolvedUserId = await this.resolveUserId(userId);
|
||||
|
||||
// If only URL is provided, auto-parse platform and username
|
||||
let platform = body.platform;
|
||||
let username = body.username;
|
||||
let profileUrl = body.profileUrl || body.url || '';
|
||||
|
||||
if (body.url && (!platform || !username)) {
|
||||
const parsed = this.parseAccountUrl(body.url);
|
||||
platform = platform || parsed.platform;
|
||||
username = username || parsed.username;
|
||||
profileUrl = body.url;
|
||||
}
|
||||
|
||||
if (!platform || !username) {
|
||||
throw new Error('Platform ve kullanıcı adı gerekli. Geçerli bir sosyal medya URL\'si girin.');
|
||||
}
|
||||
|
||||
return this.service.addSourceAccount(resolvedUserId, {
|
||||
platform,
|
||||
username,
|
||||
displayName: body.displayName,
|
||||
profileUrl,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a social media URL into platform + username
|
||||
*/
|
||||
private parseAccountUrl(url: string): { platform: SocialPlatform | undefined; username: string } {
|
||||
const lowerUrl = url.toLowerCase();
|
||||
let platform: SocialPlatform | undefined = undefined;
|
||||
let username = '';
|
||||
|
||||
if (lowerUrl.includes('twitter.com') || lowerUrl.includes('x.com')) {
|
||||
platform = 'twitter' as SocialPlatform;
|
||||
const match = url.match(/(?:twitter\.com|x\.com)\/(@?[\w]+)/i);
|
||||
username = match?.[1]?.replace('@', '') || '';
|
||||
} else if (lowerUrl.includes('instagram.com')) {
|
||||
platform = 'instagram' as SocialPlatform;
|
||||
const match = url.match(/instagram\.com\/(@?[\w.]+)/i);
|
||||
username = match?.[1]?.replace('@', '') || '';
|
||||
} else if (lowerUrl.includes('linkedin.com')) {
|
||||
platform = 'linkedin' as SocialPlatform;
|
||||
const match = url.match(/linkedin\.com\/(?:in|company)\/([\w-]+)/i);
|
||||
username = match?.[1] || '';
|
||||
} else if (lowerUrl.includes('tiktok.com')) {
|
||||
platform = 'tiktok' as SocialPlatform;
|
||||
const match = url.match(/tiktok\.com\/@?([\w.]+)/i);
|
||||
username = match?.[1] || '';
|
||||
} else if (lowerUrl.includes('youtube.com') || lowerUrl.includes('youtu.be')) {
|
||||
platform = 'youtube' as SocialPlatform;
|
||||
const match = url.match(/youtube\.com\/(?:@|channel\/|c\/)([\w-]+)/i);
|
||||
username = match?.[1] || '';
|
||||
} else if (lowerUrl.includes('facebook.com')) {
|
||||
platform = 'facebook' as SocialPlatform;
|
||||
const match = url.match(/facebook\.com\/([\w.]+)/i);
|
||||
username = match?.[1] || '';
|
||||
} else if (lowerUrl.includes('threads.net')) {
|
||||
platform = 'threads' as SocialPlatform;
|
||||
const match = url.match(/threads\.net\/@?([\w.]+)/i);
|
||||
username = match?.[1] || '';
|
||||
} else if (lowerUrl.includes('pinterest.com')) {
|
||||
platform = 'pinterest' as SocialPlatform;
|
||||
const match = url.match(/pinterest\.com\/([\w-]+)/i);
|
||||
username = match?.[1] || '';
|
||||
}
|
||||
|
||||
return { platform, username };
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get()
|
||||
listSourceAccounts(
|
||||
async listSourceAccounts(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Query('platform') platform?: SocialPlatform,
|
||||
@Query('category') category?: string,
|
||||
@Query('isActive') isActive?: string,
|
||||
) {
|
||||
return this.service.listSourceAccounts({
|
||||
const resolvedUserId = await this.resolveUserId(userId);
|
||||
return this.service.listSourceAccounts(resolvedUserId, {
|
||||
platform,
|
||||
category,
|
||||
isActive: isActive ? isActive === 'true' : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('stats')
|
||||
getStats() {
|
||||
return this.service.getStats();
|
||||
async getStats(@CurrentUser('id') userId: string) {
|
||||
const resolvedUserId = await this.resolveUserId(userId);
|
||||
return this.service.getStats(resolvedUserId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get(':id')
|
||||
getSourceAccount(@Param('id') id: string) {
|
||||
return this.service.getSourceAccount(id);
|
||||
async getSourceAccount(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const resolvedUserId = await this.resolveUserId(userId);
|
||||
return this.service.getSourceAccount(resolvedUserId, id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Put(':id')
|
||||
updateSourceAccount(
|
||||
async updateSourceAccount(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() body: {
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
displayName?: string;
|
||||
isActive?: boolean;
|
||||
},
|
||||
) {
|
||||
return this.service.updateSourceAccount(id, body);
|
||||
const resolvedUserId = await this.resolveUserId(userId);
|
||||
return this.service.updateSourceAccount(resolvedUserId, id, body);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Delete(':id')
|
||||
deleteSourceAccount(@Param('id') id: string) {
|
||||
return { success: this.service.deleteSourceAccount(id) };
|
||||
async deleteSourceAccount(
|
||||
@CurrentUser('id') userId: string,
|
||||
@Param('id') id: string,
|
||||
) {
|
||||
const resolvedUserId = await this.resolveUserId(userId);
|
||||
return this.service.deleteSourceAccount(resolvedUserId, id).then(success => ({ success }));
|
||||
}
|
||||
|
||||
// ========== CONTENT ANALYSIS ==========
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
// Source Accounts Service - Main orchestration service
|
||||
// Source Accounts Service - Database-backed implementation
|
||||
// Path: src/modules/source-accounts/source-accounts.service.ts
|
||||
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { PrismaService } from '../../database/prisma.service';
|
||||
import { SocialPlatform as PrismaSocialPlatform } from '@prisma/client';
|
||||
import { ContentParserService, ParsedContent, SocialPlatform } from './services/content-parser.service';
|
||||
import { ViralPostAnalyzerService, ViralAnalysis } from './services/viral-post-analyzer.service';
|
||||
import { StructureSkeletonService, StructureSkeleton } from './services/structure-skeleton.service';
|
||||
@@ -40,9 +41,6 @@ export interface SourceAccountStats {
|
||||
export class SourceAccountsService {
|
||||
private readonly logger = new Logger(SourceAccountsService.name);
|
||||
|
||||
// In-memory storage for demo
|
||||
private sourceAccounts: Map<string, SourceAccount> = new Map();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly parser: ContentParserService,
|
||||
@@ -52,12 +50,12 @@ export class SourceAccountsService {
|
||||
private readonly rewriter: ContentRewriterService,
|
||||
) { }
|
||||
|
||||
// ========== SOURCE ACCOUNT MANAGEMENT ==========
|
||||
// ========== SOURCE ACCOUNT MANAGEMENT (DATABASE-BACKED) ==========
|
||||
|
||||
/**
|
||||
* Add a new source account to track
|
||||
* Add a new source account to track — persisted to database
|
||||
*/
|
||||
async addSourceAccount(input: {
|
||||
async addSourceAccount(userId: string, input: {
|
||||
platform: SocialPlatform;
|
||||
username: string;
|
||||
displayName?: string;
|
||||
@@ -65,81 +63,93 @@ export class SourceAccountsService {
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
notes?: string;
|
||||
}): Promise<SourceAccount> {
|
||||
const account: SourceAccount = {
|
||||
id: `source-${Date.now()}`,
|
||||
platform: input.platform,
|
||||
username: input.username,
|
||||
displayName: input.displayName || input.username,
|
||||
profileUrl: input.profileUrl,
|
||||
verified: false,
|
||||
category: input.category || 'general',
|
||||
tags: input.tags || [],
|
||||
notes: input.notes || '',
|
||||
isActive: true,
|
||||
postsAnalyzed: 0,
|
||||
avgViralScore: 0,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
}): Promise<any> {
|
||||
try {
|
||||
const account = await this.prisma.sourceAccount.create({
|
||||
data: {
|
||||
userId,
|
||||
platform: input.platform.toUpperCase() as PrismaSocialPlatform,
|
||||
username: input.username,
|
||||
displayName: input.displayName || input.username,
|
||||
profileUrl: input.profileUrl,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
this.sourceAccounts.set(account.id, account);
|
||||
this.logger.log(`Added source account: ${account.username} (${account.platform})`);
|
||||
|
||||
return account;
|
||||
this.logger.log(`Added source account to DB: ${input.username} (${input.platform}) for user ${userId}`);
|
||||
return account;
|
||||
} catch (error) {
|
||||
// Handle unique constraint violation (user already has this account)
|
||||
if (error.code === 'P2002') {
|
||||
this.logger.warn(`Source account already exists: ${input.username} (${input.platform})`);
|
||||
throw new Error(`Bu kaynak hesap zaten ekli: ${input.username} (${input.platform})`);
|
||||
}
|
||||
this.logger.error(`Failed to add source account: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get source account by ID
|
||||
*/
|
||||
getSourceAccount(id: string): SourceAccount | null {
|
||||
return this.sourceAccounts.get(id) || null;
|
||||
async getSourceAccount(userId: string, id: string) {
|
||||
return this.prisma.sourceAccount.findFirst({
|
||||
where: { id, userId },
|
||||
include: { posts: { take: 10, orderBy: { fetchedAt: 'desc' } } },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List source accounts
|
||||
* List source accounts for a user
|
||||
*/
|
||||
listSourceAccounts(filters?: {
|
||||
async listSourceAccounts(userId: string, filters?: {
|
||||
platform?: SocialPlatform;
|
||||
category?: string;
|
||||
isActive?: boolean;
|
||||
}): SourceAccount[] {
|
||||
let accounts = Array.from(this.sourceAccounts.values());
|
||||
}) {
|
||||
const where: any = { userId };
|
||||
|
||||
if (filters) {
|
||||
if (filters.platform) {
|
||||
accounts = accounts.filter((a) => a.platform === filters.platform);
|
||||
}
|
||||
if (filters.category) {
|
||||
accounts = accounts.filter((a) => a.category === filters.category);
|
||||
}
|
||||
if (filters.isActive !== undefined) {
|
||||
accounts = accounts.filter((a) => a.isActive === filters.isActive);
|
||||
}
|
||||
if (filters?.platform) {
|
||||
where.platform = filters.platform.toUpperCase();
|
||||
}
|
||||
if (filters?.isActive !== undefined) {
|
||||
where.isActive = filters.isActive;
|
||||
}
|
||||
|
||||
return accounts.sort((a, b) => b.avgViralScore - a.avgViralScore);
|
||||
return this.prisma.sourceAccount.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update source account
|
||||
*/
|
||||
updateSourceAccount(
|
||||
id: string,
|
||||
updates: Partial<Pick<SourceAccount, 'category' | 'tags' | 'notes' | 'isActive'>>,
|
||||
): SourceAccount | null {
|
||||
const account = this.sourceAccounts.get(id);
|
||||
if (!account) return null;
|
||||
async updateSourceAccount(userId: string, id: string, updates: {
|
||||
displayName?: string;
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
// Verify ownership
|
||||
const existing = await this.prisma.sourceAccount.findFirst({ where: { id, userId } });
|
||||
if (!existing) {
|
||||
throw new Error('Kaynak hesap bulunamadı');
|
||||
}
|
||||
|
||||
Object.assign(account, updates, { updatedAt: new Date() });
|
||||
return account;
|
||||
return this.prisma.sourceAccount.update({
|
||||
where: { id },
|
||||
data: updates,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete source account
|
||||
*/
|
||||
deleteSourceAccount(id: string): boolean {
|
||||
return this.sourceAccounts.delete(id);
|
||||
async deleteSourceAccount(userId: string, id: string): Promise<boolean> {
|
||||
const existing = await this.prisma.sourceAccount.findFirst({ where: { id, userId } });
|
||||
if (!existing) return false;
|
||||
|
||||
await this.prisma.sourceAccount.delete({ where: { id } });
|
||||
this.logger.log(`Deleted source account: ${existing.username} (${existing.platform})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ========== CONTENT ANALYSIS WORKFLOW ==========
|
||||
@@ -161,7 +171,6 @@ export class SourceAccountsService {
|
||||
isGold: boolean;
|
||||
goldPost?: GoldPost;
|
||||
}> {
|
||||
// Determine if URL or text
|
||||
const isUrl = /^https?:\/\//i.test(input);
|
||||
let parsed: ParsedContent;
|
||||
|
||||
@@ -175,16 +184,13 @@ export class SourceAccountsService {
|
||||
parsed = this.parser.parseText(input, options?.platform);
|
||||
}
|
||||
|
||||
// Analyze for viral factors
|
||||
const analysis = this.analyzer.analyze(parsed);
|
||||
|
||||
// Extract skeleton if requested
|
||||
let skeleton: StructureSkeleton | undefined;
|
||||
if (options?.extractSkeleton) {
|
||||
skeleton = this.skeleton.extractSkeleton(parsed);
|
||||
}
|
||||
|
||||
// Check if gold-worthy
|
||||
const isGold = this.goldPost.qualifiesAsGold(analysis);
|
||||
let goldPostSaved: GoldPost | undefined;
|
||||
|
||||
@@ -192,13 +198,7 @@ export class SourceAccountsService {
|
||||
goldPostSaved = await this.goldPost.saveAsGold(parsed, analysis);
|
||||
}
|
||||
|
||||
return {
|
||||
parsed,
|
||||
analysis,
|
||||
skeleton,
|
||||
isGold,
|
||||
goldPost: goldPostSaved,
|
||||
};
|
||||
return { parsed, analysis, skeleton, isGold, goldPost: goldPostSaved };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -209,12 +209,7 @@ export class SourceAccountsService {
|
||||
options?: { platform?: SocialPlatform },
|
||||
): Promise<{
|
||||
results: Array<{ input: string; analysis: ViralAnalysis; isGold: boolean }>;
|
||||
summary: {
|
||||
total: number;
|
||||
goldCount: number;
|
||||
avgViralScore: number;
|
||||
topPerformers: string[];
|
||||
};
|
||||
summary: { total: number; goldCount: number; avgViralScore: number; topPerformers: string[] };
|
||||
}> {
|
||||
const results: Array<{ input: string; analysis: ViralAnalysis; isGold: boolean }> = [];
|
||||
|
||||
@@ -232,7 +227,9 @@ export class SourceAccountsService {
|
||||
}
|
||||
|
||||
const goldCount = results.filter((r) => r.isGold).length;
|
||||
const avgScore = results.reduce((sum, r) => sum + r.analysis.viralScore, 0) / results.length;
|
||||
const avgScore = results.length > 0
|
||||
? results.reduce((sum, r) => sum + r.analysis.viralScore, 0) / results.length
|
||||
: 0;
|
||||
const topPerformers = results
|
||||
.sort((a, b) => b.analysis.viralScore - a.analysis.viralScore)
|
||||
.slice(0, 3)
|
||||
@@ -240,60 +237,31 @@ export class SourceAccountsService {
|
||||
|
||||
return {
|
||||
results,
|
||||
summary: {
|
||||
total: results.length,
|
||||
goldCount,
|
||||
avgViralScore: Math.round(avgScore),
|
||||
topPerformers,
|
||||
},
|
||||
summary: { total: results.length, goldCount, avgViralScore: Math.round(avgScore), topPerformers },
|
||||
};
|
||||
}
|
||||
|
||||
// ========== CONTENT REWRITING ==========
|
||||
|
||||
/**
|
||||
* Rewrite content to be unique
|
||||
*/
|
||||
async rewriteContent(
|
||||
content: string,
|
||||
options?: RewriteOptions,
|
||||
): Promise<RewriteResult> {
|
||||
async rewriteContent(content: string, options?: RewriteOptions): Promise<RewriteResult> {
|
||||
return this.rewriter.rewrite(content, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate multiple variations
|
||||
*/
|
||||
async generateVariations(
|
||||
content: string,
|
||||
count: number = 3,
|
||||
): Promise<RewriteResult[]> {
|
||||
async generateVariations(content: string, count: number = 3): Promise<RewriteResult[]> {
|
||||
return this.rewriter.generateVariations(content, count);
|
||||
}
|
||||
|
||||
// ========== SKELETON TEMPLATES ==========
|
||||
|
||||
/**
|
||||
* Get available structure templates
|
||||
*/
|
||||
getTemplates(): { id: string; name: string; platform: string }[] {
|
||||
return this.skeleton.listTemplates();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific template
|
||||
*/
|
||||
getTemplate(templateId: string): StructureSkeleton | null {
|
||||
return this.skeleton.getTemplate(templateId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate content from template
|
||||
*/
|
||||
generateFromTemplate(
|
||||
templateId: string,
|
||||
inputs: Record<string, string>,
|
||||
): string | null {
|
||||
generateFromTemplate(templateId: string, inputs: Record<string, string>): string | null {
|
||||
const template = this.skeleton.getTemplate(templateId);
|
||||
if (!template) return null;
|
||||
return this.skeleton.generateFromSkeleton(template, inputs);
|
||||
@@ -301,61 +269,34 @@ export class SourceAccountsService {
|
||||
|
||||
// ========== GOLD POSTS ==========
|
||||
|
||||
/**
|
||||
* List gold posts
|
||||
*/
|
||||
getGoldPosts(filters?: GoldPostFilters): GoldPost[] {
|
||||
return this.goldPost.list(filters);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post suggestions for spin-offs
|
||||
*/
|
||||
getSpinOffSuggestions(goldPostId: string) {
|
||||
return this.goldPost.getSpinOffSuggestions(goldPostId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get gold post analytics
|
||||
*/
|
||||
getGoldPostAnalytics() {
|
||||
return this.goldPost.getAnalytics();
|
||||
}
|
||||
|
||||
// ========== STATISTICS ==========
|
||||
|
||||
/**
|
||||
* Get overall statistics
|
||||
*/
|
||||
getStats(): SourceAccountStats {
|
||||
const accounts = Array.from(this.sourceAccounts.values());
|
||||
async getStats(userId: string): Promise<SourceAccountStats> {
|
||||
const accounts = await this.prisma.sourceAccount.findMany({ where: { userId } });
|
||||
|
||||
const platformCount = new Map<string, number>();
|
||||
let totalPosts = 0;
|
||||
let totalScore = 0;
|
||||
|
||||
for (const account of accounts) {
|
||||
platformCount.set(
|
||||
account.platform,
|
||||
(platformCount.get(account.platform) || 0) + 1,
|
||||
);
|
||||
totalPosts += account.postsAnalyzed;
|
||||
totalScore += account.avgViralScore * account.postsAnalyzed;
|
||||
platformCount.set(account.platform, (platformCount.get(account.platform) || 0) + 1);
|
||||
}
|
||||
|
||||
const topPerformers = accounts
|
||||
.sort((a, b) => b.avgViralScore - a.avgViralScore)
|
||||
.slice(0, 5);
|
||||
|
||||
return {
|
||||
totalAccounts: accounts.length,
|
||||
byPlatform: [...platformCount.entries()].map(([platform, count]) => ({
|
||||
platform,
|
||||
count,
|
||||
})),
|
||||
totalPostsAnalyzed: totalPosts,
|
||||
avgViralScore: totalPosts > 0 ? Math.round(totalScore / totalPosts) : 0,
|
||||
topPerformers,
|
||||
byPlatform: [...platformCount.entries()].map(([platform, count]) => ({ platform, count })),
|
||||
totalPostsAnalyzed: 0,
|
||||
avgViralScore: 0,
|
||||
topPerformers: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user