Update seo-multi-channel: remove image skill dependencies
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
---
|
---
|
||||||
name: seo-multi-channel
|
name: seo-multi-channel
|
||||||
description: Generate multi-channel marketing content (Facebook, Ads, Blog, X) with Thai language support, image generation, and website-creator integration. Use when user wants to create content for multiple channels from a single topic.
|
description: Generate multi-channel marketing content (Facebook, Ads, Blog, X) with Thai language support and website-creator integration. Use when user wants to create content for multiple channels from a single topic.
|
||||||
---
|
---
|
||||||
|
|
||||||
# 🎯 SEO Multi-Channel Content Generator
|
# 🎯 SEO Multi-Channel Content Generator
|
||||||
|
|
||||||
**Skill Name:** `seo-multi-channel`
|
**Skill Name:** `seo-multi-channel`
|
||||||
**Category:** `deep`
|
**Category:** `deep`
|
||||||
**Load Skills:** `['image-generation', 'image-edit', 'website-creator']`
|
**Load Skills:** `['website-creator']`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,8 +17,7 @@ Generate marketing content for multiple channels from a single topic with:
|
|||||||
|
|
||||||
- ✅ **Priority Channels:** Facebook > Facebook Ads > Google Ads > Blog > X (Twitter)
|
- ✅ **Priority Channels:** Facebook > Facebook Ads > Google Ads > Blog > X (Twitter)
|
||||||
- ✅ **Thai Language Support:** Full Thai text processing with PyThaiNLP
|
- ✅ **Thai Language Support:** Full Thai text processing with PyThaiNLP
|
||||||
- ✅ **Image Generation:** Auto-generate images for social/ads, save to website repo for blog
|
- ✅ **Image Handling:** Browse website repo for product images, or ask user to provide
|
||||||
- ✅ **Product Image Handling:** Browse website repo first, then ask user or enhance with image-edit
|
|
||||||
- ✅ **Website-Creator Integration:** Auto-publish blog posts to Astro content collections
|
- ✅ **Website-Creator Integration:** Auto-publish blog posts to Astro content collections
|
||||||
- ✅ **API-Ready Output:** Structured JSON for future ad platform API integration
|
- ✅ **API-Ready Output:** Structured JSON for future ad platform API integration
|
||||||
- ✅ **Per-Project Context:** Context files in each website repo
|
- ✅ **Per-Project Context:** Context files in each website repo
|
||||||
@@ -169,13 +168,11 @@ Output:
|
|||||||
|
|
||||||
2. If images found:
|
2. If images found:
|
||||||
- Select best image (highest quality, product-focused)
|
- Select best image (highest quality, product-focused)
|
||||||
- Call image-edit skill:
|
- Ask user: "Use this image or provide a new one?"
|
||||||
prompt: "Enhance product image for {channel}, professional lighting, clean background, {channel}-specific dimensions"
|
|
||||||
|
|
||||||
3. If no images found:
|
3. If no images found:
|
||||||
- Ask user: "No product images found in repo. Please provide image path or URL."
|
- Ask user: "No product images found in repo. Please provide image path or URL."
|
||||||
- Wait for user to provide
|
- Wait for user to provide
|
||||||
- Then call image-edit
|
|
||||||
```
|
```
|
||||||
|
|
||||||
#### **Non-Product Content:**
|
#### **Non-Product Content:**
|
||||||
@@ -187,8 +184,8 @@ Output:
|
|||||||
- Stats → Infographic with charts
|
- Stats → Infographic with charts
|
||||||
- News → Clean, modern announcement style
|
- News → Clean, modern announcement style
|
||||||
|
|
||||||
2. Call image-generation skill:
|
2. Ask user to provide image:
|
||||||
prompt: "{content_type} illustration for {topic}, {style}, Thai-friendly aesthetic, {channel}-optimized dimensions"
|
- "For this content, please provide an image or use: [suggestion]"
|
||||||
|
|
||||||
3. Save images:
|
3. Save images:
|
||||||
- Social/Ads → seo-multi-channel/generated-images/{topic}/{channel}/
|
- Social/Ads → seo-multi-channel/generated-images/{topic}/{channel}/
|
||||||
@@ -602,8 +599,8 @@ Each piece of content is scored before output:
|
|||||||
2. **Formality Detection:** Auto-detects from brand voice context. Defaults to casual for social, normal for blog.
|
2. **Formality Detection:** Auto-detects from brand voice context. Defaults to casual for social, normal for blog.
|
||||||
|
|
||||||
3. **Image Handling:**
|
3. **Image Handling:**
|
||||||
- Product content → Browse repo first → Edit with image-edit
|
- Product content → Browse repo first → Ask user to confirm or provide
|
||||||
- Non-product → Generate fresh with image-generation
|
- Non-product → Ask user to provide image
|
||||||
- Blog images → Website repo
|
- Blog images → Website repo
|
||||||
- Social/Ads images → Separate folder
|
- Social/Ads images → Separate folder
|
||||||
|
|
||||||
@@ -617,8 +614,6 @@ Each piece of content is scored before output:
|
|||||||
|
|
||||||
## 🔄 Integration with Other Skills
|
## 🔄 Integration with Other Skills
|
||||||
|
|
||||||
- **image-generation:** Called for fresh images (non-product content)
|
|
||||||
- **image-edit:** Called for product images (browse repo first)
|
|
||||||
- **website-creator:** Blog posts published to Astro content collections
|
- **website-creator:** Blog posts published to Astro content collections
|
||||||
- **seo-analyzers:** Quality scoring and Thai language analysis
|
- **seo-analyzers:** Quality scoring and Thai language analysis
|
||||||
- **seo-data:** Performance data for content optimization
|
- **seo-data:** Performance data for content optimization
|
||||||
@@ -630,8 +625,7 @@ Each piece of content is scored before output:
|
|||||||
|
|
||||||
- ✅ Content generated for all selected channels
|
- ✅ Content generated for all selected channels
|
||||||
- ✅ Thai language processing accurate (word count, keyword density)
|
- ✅ Thai language processing accurate (word count, keyword density)
|
||||||
- ✅ Product images found/enhanced or user asked to provide
|
- ✅ Product images found or user asked to provide
|
||||||
- ✅ Fresh images generated for non-product content
|
|
||||||
- ✅ Blog posts published to Astro (if enabled)
|
- ✅ Blog posts published to Astro (if enabled)
|
||||||
- ✅ Git commit + push successful (triggers auto-deploy)
|
- ✅ Git commit + push successful (triggers auto-deploy)
|
||||||
- ✅ Output structures API-ready for future integration
|
- ✅ Output structures API-ready for future integration
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import yaml
|
|||||||
|
|
||||||
# Load environment variables
|
# Load environment variables
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# Thai language processing
|
# Thai language processing
|
||||||
try:
|
try:
|
||||||
from pythainlp import word_tokenize, sent_tokenize
|
from pythainlp import word_tokenize, sent_tokenize
|
||||||
from pythainlp.util import normalize
|
from pythainlp.util import normalize
|
||||||
|
|
||||||
THAI_SUPPORT = True
|
THAI_SUPPORT = True
|
||||||
except ImportError:
|
except ImportError:
|
||||||
THAI_SUPPORT = False
|
THAI_SUPPORT = False
|
||||||
@@ -48,7 +50,7 @@ class ThaiTextProcessor:
|
|||||||
def count_sentences(text: str) -> int:
|
def count_sentences(text: str) -> int:
|
||||||
"""Count Thai sentences"""
|
"""Count Thai sentences"""
|
||||||
if not THAI_SUPPORT:
|
if not THAI_SUPPORT:
|
||||||
return len(text.split('.'))
|
return len(text.split("."))
|
||||||
|
|
||||||
sentences = sent_tokenize(text, engine="whitespace")
|
sentences = sent_tokenize(text, engine="whitespace")
|
||||||
return len(sentences)
|
return len(sentences)
|
||||||
@@ -70,11 +72,11 @@ class ThaiTextProcessor:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def detect_language(text: str) -> str:
|
def detect_language(text: str) -> str:
|
||||||
"""Detect if content is Thai or English"""
|
"""Detect if content is Thai or English"""
|
||||||
thai_chars = sum(1 for c in text if '\u0E00' <= c <= '\u0E7F')
|
thai_chars = sum(1 for c in text if "\u0e00" <= c <= "\u0e7f")
|
||||||
total_chars = len(text)
|
total_chars = len(text)
|
||||||
thai_ratio = thai_chars / total_chars if total_chars > 0 else 0
|
thai_ratio = thai_chars / total_chars if total_chars > 0 else 0
|
||||||
|
|
||||||
return 'th' if thai_ratio > 0.3 else 'en'
|
return "th" if thai_ratio > 0.3 else "en"
|
||||||
|
|
||||||
|
|
||||||
class ChannelTemplate:
|
class ChannelTemplate:
|
||||||
@@ -87,16 +89,16 @@ class ChannelTemplate:
|
|||||||
|
|
||||||
def _load_template(self) -> Dict:
|
def _load_template(self) -> Dict:
|
||||||
"""Load YAML template"""
|
"""Load YAML template"""
|
||||||
with open(self.template_path, 'r', encoding='utf-8') as f:
|
with open(self.template_path, "r", encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f)
|
return yaml.safe_load(f)
|
||||||
|
|
||||||
def get_specs(self) -> Dict:
|
def get_specs(self) -> Dict:
|
||||||
"""Get channel specifications"""
|
"""Get channel specifications"""
|
||||||
return self.template.get('fields', {})
|
return self.template.get("fields", {})
|
||||||
|
|
||||||
def get_quality_requirements(self) -> Dict:
|
def get_quality_requirements(self) -> Dict:
|
||||||
"""Get quality requirements"""
|
"""Get quality requirements"""
|
||||||
return self.template.get('quality', {})
|
return self.template.get("quality", {})
|
||||||
|
|
||||||
|
|
||||||
class ImageHandler:
|
class ImageHandler:
|
||||||
@@ -110,52 +112,44 @@ class ImageHandler:
|
|||||||
"""Find existing product images in website repo"""
|
"""Find existing product images in website repo"""
|
||||||
import glob
|
import glob
|
||||||
|
|
||||||
extensions = ['.jpg', '.jpeg', '.png', '.webp']
|
extensions = [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
found_images = []
|
found_images = []
|
||||||
|
|
||||||
search_patterns = [
|
search_patterns = [f"**/*{product_name}*{{ext}}" for ext in extensions] + [
|
||||||
f"**/*{product_name}*{{ext}}" for ext in extensions
|
|
||||||
] + [
|
|
||||||
"public/images/**/*{ext}",
|
"public/images/**/*{ext}",
|
||||||
"src/assets/**/*{ext}"
|
"src/assets/**/*{ext}",
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in search_patterns:
|
for pattern in search_patterns:
|
||||||
matches = glob.glob(
|
matches = glob.glob(
|
||||||
os.path.join(website_repo, pattern.format(ext='*')),
|
os.path.join(website_repo, pattern.format(ext="*")), recursive=True
|
||||||
recursive=True
|
|
||||||
)
|
)
|
||||||
# Try specific extensions
|
# Try specific extensions
|
||||||
for ext in extensions:
|
for ext in extensions:
|
||||||
specific_matches = glob.glob(
|
specific_matches = glob.glob(
|
||||||
os.path.join(website_repo, pattern.format(ext=ext)),
|
os.path.join(website_repo, pattern.format(ext=ext)), recursive=True
|
||||||
recursive=True
|
|
||||||
)
|
)
|
||||||
found_images.extend(specific_matches)
|
found_images.extend(specific_matches)
|
||||||
|
|
||||||
return list(set(found_images))[:10]
|
return list(set(found_images))[:10]
|
||||||
|
|
||||||
def generate_image_for_channel(self, topic: str, channel: str, content_type: str) -> str:
|
def generate_image_for_channel(
|
||||||
|
self, topic: str, channel: str, content_type: str
|
||||||
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Generate image for content.
|
Handle image for content.
|
||||||
For product: browse repo first, then ask user or use image-edit
|
For product: browse repo first, ask user to confirm/provide
|
||||||
For non-product: generate fresh with image-generation
|
For non-product: ask user to provide image
|
||||||
"""
|
"""
|
||||||
# This would call the image-generation or image-edit skills
|
|
||||||
# For now, return placeholder
|
|
||||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
output_dir = os.path.join(
|
output_dir = os.path.join(
|
||||||
self.output_base,
|
self.output_base, self._slugify(topic), channel, "images"
|
||||||
self._slugify(topic),
|
|
||||||
channel,
|
|
||||||
"images"
|
|
||||||
)
|
)
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
image_path = os.path.join(output_dir, f"generated_{timestamp}.png")
|
image_path = os.path.join(output_dir, f"generated_{timestamp}.png")
|
||||||
|
|
||||||
# Placeholder - in real implementation, would call image-generation skill
|
print(f" [Image] Please provide image for: {channel}")
|
||||||
print(f" [Image Generation] Would generate image for {channel}")
|
|
||||||
print(f" Topic: {topic}, Type: {content_type}")
|
print(f" Topic: {topic}, Type: {content_type}")
|
||||||
|
|
||||||
return image_path
|
return image_path
|
||||||
@@ -163,9 +157,10 @@ class ImageHandler:
|
|||||||
def _slugify(self, text: str) -> str:
|
def _slugify(self, text: str) -> str:
|
||||||
"""Convert text to URL-friendly slug"""
|
"""Convert text to URL-friendly slug"""
|
||||||
import re
|
import re
|
||||||
slug = re.sub(r'[^\w\s-]', '', text.lower())
|
|
||||||
slug = re.sub(r'[-\s]+', '-', slug)
|
slug = re.sub(r"[^\w\s-]", "", text.lower())
|
||||||
return slug.strip('-_')
|
slug = re.sub(r"[-\s]+", "-", slug)
|
||||||
|
return slug.strip("-_")
|
||||||
|
|
||||||
|
|
||||||
class ContentGenerator:
|
class ContentGenerator:
|
||||||
@@ -177,7 +172,7 @@ class ContentGenerator:
|
|||||||
channels: List[str],
|
channels: List[str],
|
||||||
website_repo: Optional[str] = None,
|
website_repo: Optional[str] = None,
|
||||||
auto_publish: bool = False,
|
auto_publish: bool = False,
|
||||||
language: Optional[str] = None
|
language: Optional[str] = None,
|
||||||
):
|
):
|
||||||
self.topic = topic
|
self.topic = topic
|
||||||
self.channels = channels
|
self.channels = channels
|
||||||
@@ -189,34 +184,35 @@ class ContentGenerator:
|
|||||||
|
|
||||||
# Initialize components
|
# Initialize components
|
||||||
self.text_processor = ThaiTextProcessor()
|
self.text_processor = ThaiTextProcessor()
|
||||||
self.image_handler = ImageHandler(os.getenv("CHUTES_API_TOKEN", ""))
|
|
||||||
|
|
||||||
# Load templates
|
# Load templates
|
||||||
self.templates = {}
|
self.templates = {}
|
||||||
for channel in channels:
|
for channel in channels:
|
||||||
template_name = self._get_template_name(channel)
|
template_name = self._get_template_name(channel)
|
||||||
if template_name:
|
if template_name:
|
||||||
self.templates[channel] = ChannelTemplate(template_name, self.templates_dir)
|
self.templates[channel] = ChannelTemplate(
|
||||||
|
template_name, self.templates_dir
|
||||||
|
)
|
||||||
|
|
||||||
def _get_template_name(self, channel: str) -> Optional[str]:
|
def _get_template_name(self, channel: str) -> Optional[str]:
|
||||||
"""Map channel name to template file"""
|
"""Map channel name to template file"""
|
||||||
mapping = {
|
mapping = {
|
||||||
'facebook': 'facebook',
|
"facebook": "facebook",
|
||||||
'facebook_ads': 'facebook_ads',
|
"facebook_ads": "facebook_ads",
|
||||||
'google_ads': 'google_ads',
|
"google_ads": "google_ads",
|
||||||
'blog': 'blog',
|
"blog": "blog",
|
||||||
'x': 'x_thread',
|
"x": "x_thread",
|
||||||
'twitter': 'x_thread'
|
"twitter": "x_thread",
|
||||||
}
|
}
|
||||||
return mapping.get(channel.lower())
|
return mapping.get(channel.lower())
|
||||||
|
|
||||||
def generate_all(self) -> Dict[str, Any]:
|
def generate_all(self) -> Dict[str, Any]:
|
||||||
"""Generate content for all channels"""
|
"""Generate content for all channels"""
|
||||||
results = {
|
results = {
|
||||||
'topic': self.topic,
|
"topic": self.topic,
|
||||||
'generated_at': datetime.now().isoformat(),
|
"generated_at": datetime.now().isoformat(),
|
||||||
'channels': {},
|
"channels": {},
|
||||||
'summary': {}
|
"summary": {},
|
||||||
}
|
}
|
||||||
|
|
||||||
print(f"\n🎯 Generating content for: {self.topic}")
|
print(f"\n🎯 Generating content for: {self.topic}")
|
||||||
@@ -227,7 +223,7 @@ class ContentGenerator:
|
|||||||
if channel in self.templates:
|
if channel in self.templates:
|
||||||
print(f" Generating {channel}...")
|
print(f" Generating {channel}...")
|
||||||
channel_result = self._generate_for_channel(channel)
|
channel_result = self._generate_for_channel(channel)
|
||||||
results['channels'][channel] = channel_result
|
results["channels"][channel] = channel_result
|
||||||
|
|
||||||
# Save results
|
# Save results
|
||||||
self._save_results(results)
|
self._save_results(results)
|
||||||
@@ -244,101 +240,106 @@ class ContentGenerator:
|
|||||||
|
|
||||||
# Generate variations (placeholder - real implementation would use LLM)
|
# Generate variations (placeholder - real implementation would use LLM)
|
||||||
variations = []
|
variations = []
|
||||||
num_variations = template.template.get('output', {}).get('variations', 5)
|
num_variations = template.template.get("output", {}).get("variations", 5)
|
||||||
|
|
||||||
for i in range(num_variations):
|
for i in range(num_variations):
|
||||||
variation = self._create_variation(channel, i, lang, specs)
|
variation = self._create_variation(channel, i, lang, specs)
|
||||||
variations.append(variation)
|
variations.append(variation)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'channel': channel,
|
"channel": channel,
|
||||||
'language': lang,
|
"language": lang,
|
||||||
'variations': variations,
|
"variations": variations,
|
||||||
'api_ready': template.template.get('api_ready', False)
|
"api_ready": template.template.get("api_ready", False),
|
||||||
}
|
}
|
||||||
|
|
||||||
def _create_variation(
|
def _create_variation(
|
||||||
self,
|
self, channel: str, variation_num: int, language: str, specs: Dict
|
||||||
channel: str,
|
|
||||||
variation_num: int,
|
|
||||||
language: str,
|
|
||||||
specs: Dict
|
|
||||||
) -> Dict:
|
) -> Dict:
|
||||||
"""Create single content variation"""
|
"""Create single content variation"""
|
||||||
# This is a placeholder - real implementation would call LLM
|
# This is a placeholder - real implementation would call LLM
|
||||||
# with proper prompts based on channel template
|
# with proper prompts based on channel template
|
||||||
|
|
||||||
base_variation = {
|
base_variation = {
|
||||||
'id': f"{channel}_var_{variation_num + 1}",
|
"id": f"{channel}_var_{variation_num + 1}",
|
||||||
'created_at': datetime.now().isoformat()
|
"created_at": datetime.now().isoformat(),
|
||||||
}
|
}
|
||||||
|
|
||||||
# Channel-specific structure
|
# Channel-specific structure
|
||||||
if channel == 'facebook':
|
if channel == "facebook":
|
||||||
base_variation.update({
|
base_variation.update(
|
||||||
'primary_text': f"[Facebook Post {variation_num + 1}] {self.topic}...",
|
{
|
||||||
'headline': f"[Headline] {self.topic}",
|
"primary_text": f"[Facebook Post {variation_num + 1}] {self.topic}...",
|
||||||
'cta': "เรียนรู้เพิ่มเติม" if language == 'th' else "Learn More",
|
"headline": f"[Headline] {self.topic}",
|
||||||
'hashtags': [f"#{self.topic.replace(' ', '')}"],
|
"cta": "เรียนรู้เพิ่มเติม" if language == "th" else "Learn More",
|
||||||
'image': {
|
"hashtags": [f"#{self.topic.replace(' ', '')}"],
|
||||||
'path': self.image_handler.generate_image_for_channel(
|
"image": {
|
||||||
self.topic, channel, 'social'
|
"path": self.generate_image_for_channel(
|
||||||
|
self.topic, channel, "social"
|
||||||
)
|
)
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
elif channel == 'facebook_ads':
|
elif channel == "facebook_ads":
|
||||||
base_variation.update({
|
base_variation.update(
|
||||||
'primary_text': f"[FB Ad Primary Text] {self.topic}...",
|
{
|
||||||
'headline': f"[FB Ad Headline - 40 chars]",
|
"primary_text": f"[FB Ad Primary Text] {self.topic}...",
|
||||||
'description': f"[FB Ad Description - 90 chars]",
|
"headline": f"[FB Ad Headline - 40 chars]",
|
||||||
'cta': "SHOP_NOW",
|
"description": f"[FB Ad Description - 90 chars]",
|
||||||
'api_ready': {
|
"cta": "SHOP_NOW",
|
||||||
'platform': 'meta',
|
"api_ready": {
|
||||||
'api_version': 'v18.0',
|
"platform": "meta",
|
||||||
'endpoint': '/act_{ad_account_id}/adcreatives'
|
"api_version": "v18.0",
|
||||||
|
"endpoint": "/act_{ad_account_id}/adcreatives",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
})
|
)
|
||||||
|
|
||||||
elif channel == 'google_ads':
|
elif channel == "google_ads":
|
||||||
base_variation.update({
|
base_variation.update(
|
||||||
'headlines': [
|
{
|
||||||
{'text': f"[Headline {i+1}] {self.topic}"}
|
"headlines": [
|
||||||
for i in range(15)
|
{"text": f"[Headline {i + 1}] {self.topic}"} for i in range(15)
|
||||||
],
|
],
|
||||||
'descriptions': [
|
"descriptions": [
|
||||||
{'text': f"[Description {i+1}] Learn more about {self.topic}"}
|
{"text": f"[Description {i + 1}] Learn more about {self.topic}"}
|
||||||
for i in range(4)
|
for i in range(4)
|
||||||
],
|
],
|
||||||
'keywords': [self.topic, f"บริการ {self.topic}"],
|
"keywords": [self.topic, f"บริการ {self.topic}"],
|
||||||
'api_ready': {
|
"api_ready": {
|
||||||
'platform': 'google',
|
"platform": "google",
|
||||||
'api_version': 'v15.0',
|
"api_version": "v15.0",
|
||||||
'endpoint': '/google.ads.googleads.v15.services/GoogleAdsService:Mutate'
|
"endpoint": "/google.ads.googleads.v15.services/GoogleAdsService:Mutate",
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
elif channel == 'blog':
|
|
||||||
base_variation.update({
|
|
||||||
'markdown': self._generate_blog_markdown(language),
|
|
||||||
'frontmatter': {
|
|
||||||
'title': f"{self.topic} - Complete Guide",
|
|
||||||
'description': f"Learn about {self.topic}",
|
|
||||||
'slug': self._slugify(self.topic),
|
|
||||||
'lang': language
|
|
||||||
},
|
},
|
||||||
'word_count': 2000 if language == 'en' else 1500,
|
}
|
||||||
'publish_status': 'draft'
|
)
|
||||||
})
|
|
||||||
|
|
||||||
elif channel in ['x', 'twitter']:
|
elif channel == "blog":
|
||||||
base_variation.update({
|
base_variation.update(
|
||||||
'tweets': [
|
{
|
||||||
f"[Tweet {i+1}/7] Content about {self.topic}..."
|
"markdown": self._generate_blog_markdown(language),
|
||||||
|
"frontmatter": {
|
||||||
|
"title": f"{self.topic} - Complete Guide",
|
||||||
|
"description": f"Learn about {self.topic}",
|
||||||
|
"slug": self._slugify(self.topic),
|
||||||
|
"lang": language,
|
||||||
|
},
|
||||||
|
"word_count": 2000 if language == "en" else 1500,
|
||||||
|
"publish_status": "draft",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
elif channel in ["x", "twitter"]:
|
||||||
|
base_variation.update(
|
||||||
|
{
|
||||||
|
"tweets": [
|
||||||
|
f"[Tweet {i + 1}/7] Content about {self.topic}..."
|
||||||
for i in range(7)
|
for i in range(7)
|
||||||
],
|
],
|
||||||
'thread_title': f"Everything about {self.topic} 🧵"
|
"thread_title": f"Everything about {self.topic} 🧵",
|
||||||
})
|
}
|
||||||
|
)
|
||||||
|
|
||||||
return base_variation
|
return base_variation
|
||||||
|
|
||||||
@@ -354,7 +355,7 @@ slug: {slug}
|
|||||||
lang: {language}
|
lang: {language}
|
||||||
category: guides
|
category: guides
|
||||||
tags: ["{self.topic}", "guide"]
|
tags: ["{self.topic}", "guide"]
|
||||||
created: {datetime.now().strftime('%Y-%m-%d')}
|
created: {datetime.now().strftime("%Y-%m-%d")}
|
||||||
---
|
---
|
||||||
|
|
||||||
# {self.topic}: Complete Guide
|
# {self.topic}: Complete Guide
|
||||||
@@ -387,14 +388,11 @@ created: {datetime.now().strftime('%Y-%m-%d')}
|
|||||||
|
|
||||||
def _save_results(self, results: Dict):
|
def _save_results(self, results: Dict):
|
||||||
"""Save results to output directory"""
|
"""Save results to output directory"""
|
||||||
output_dir = os.path.join(
|
output_dir = os.path.join(self.output_base, self._slugify(self.topic))
|
||||||
self.output_base,
|
|
||||||
self._slugify(self.topic)
|
|
||||||
)
|
|
||||||
os.makedirs(output_dir, exist_ok=True)
|
os.makedirs(output_dir, exist_ok=True)
|
||||||
|
|
||||||
output_file = os.path.join(output_dir, "results.json")
|
output_file = os.path.join(output_dir, "results.json")
|
||||||
with open(output_file, 'w', encoding='utf-8') as f:
|
with open(output_file, "w", encoding="utf-8") as f:
|
||||||
json.dump(results, f, indent=2, ensure_ascii=False)
|
json.dump(results, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
print(f"\n✅ Results saved to: {output_file}")
|
print(f"\n✅ Results saved to: {output_file}")
|
||||||
@@ -402,51 +400,50 @@ created: {datetime.now().strftime('%Y-%m-%d')}
|
|||||||
def _slugify(self, text: str) -> str:
|
def _slugify(self, text: str) -> str:
|
||||||
"""Convert text to URL-friendly slug"""
|
"""Convert text to URL-friendly slug"""
|
||||||
import re
|
import re
|
||||||
slug = re.sub(r'[^\w\s-]', '', text.lower())
|
|
||||||
slug = re.sub(r'[-\s]+', '-', slug)
|
slug = re.sub(r"[^\w\s-]", "", text.lower())
|
||||||
return slug.strip('-_')
|
slug = re.sub(r"[-\s]+", "-", slug)
|
||||||
|
return slug.strip("-_")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Main entry point"""
|
"""Main entry point"""
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description='Generate multi-channel marketing content from a single topic'
|
description="Generate multi-channel marketing content from a single topic"
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--topic', '-t',
|
"--topic", "-t", required=True, help="Topic to generate content about"
|
||||||
required=True,
|
|
||||||
help='Topic to generate content about'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--channels', '-c',
|
"--channels",
|
||||||
nargs='+',
|
"-c",
|
||||||
default=['facebook', 'facebook_ads', 'google_ads', 'blog', 'x'],
|
nargs="+",
|
||||||
choices=['facebook', 'facebook_ads', 'google_ads', 'blog', 'x', 'twitter'],
|
default=["facebook", "facebook_ads", "google_ads", "blog", "x"],
|
||||||
help='Channels to generate content for'
|
choices=["facebook", "facebook_ads", "google_ads", "blog", "x", "twitter"],
|
||||||
|
help="Channels to generate content for",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--website-repo', '-w',
|
"--website-repo",
|
||||||
help='Path to website repository (for blog auto-publish)'
|
"-w",
|
||||||
|
help="Path to website repository (for blog auto-publish)",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--auto-publish',
|
"--auto-publish", action="store_true", help="Auto-publish blog posts to website"
|
||||||
action='store_true',
|
|
||||||
help='Auto-publish blog posts to website'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--language', '-l',
|
"--language",
|
||||||
choices=['th', 'en'],
|
"-l",
|
||||||
help='Content language (default: auto-detect)'
|
choices=["th", "en"],
|
||||||
|
help="Content language (default: auto-detect)",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
'--product-name', '-p',
|
"--product-name", "-p", help="Product name (for product image handling)"
|
||||||
help='Product name (for product image handling)'
|
|
||||||
)
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
@@ -457,7 +454,7 @@ def main():
|
|||||||
channels=args.channels,
|
channels=args.channels,
|
||||||
website_repo=args.website_repo,
|
website_repo=args.website_repo,
|
||||||
auto_publish=args.auto_publish,
|
auto_publish=args.auto_publish,
|
||||||
language=args.language
|
language=args.language,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate content
|
# Generate content
|
||||||
@@ -468,11 +465,11 @@ def main():
|
|||||||
print(f" Topic: {results['topic']}")
|
print(f" Topic: {results['topic']}")
|
||||||
print(f" Channels generated: {len(results['channels'])}")
|
print(f" Channels generated: {len(results['channels'])}")
|
||||||
|
|
||||||
for channel, data in results['channels'].items():
|
for channel, data in results["channels"].items():
|
||||||
print(f" - {channel}: {len(data['variations'])} variations")
|
print(f" - {channel}: {len(data['variations'])} variations")
|
||||||
|
|
||||||
print(f"\n✨ Done!")
|
print(f"\n✨ Done!")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
@@ -2,161 +2,20 @@
|
|||||||
"""
|
"""
|
||||||
Image Integration Module
|
Image Integration Module
|
||||||
|
|
||||||
Integrates with image-generation and image-edit skills.
|
|
||||||
Handles product vs non-product image workflows.
|
Handles product vs non-product image workflows.
|
||||||
|
Since image-generation and image-edit skills are removed, this module
|
||||||
|
provides utilities to find existing images and ask user to provide new ones.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import glob
|
||||||
import subprocess
|
|
||||||
import argparse
|
import argparse
|
||||||
from pathlib import Path
|
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
|
|
||||||
|
|
||||||
class ImageIntegration:
|
class ImageIntegration:
|
||||||
"""Integrate with image-generation and image-edit skills"""
|
def __init__(self, skills_base_path: str = ""):
|
||||||
|
pass
|
||||||
def __init__(self, skills_base_path: str = None):
|
|
||||||
"""
|
|
||||||
Initialize image integration
|
|
||||||
|
|
||||||
Args:
|
|
||||||
skills_base_path: Base path to skills directory
|
|
||||||
"""
|
|
||||||
if skills_base_path is None:
|
|
||||||
# Default: assume we're in skills/seo-multi-channel/scripts/
|
|
||||||
base = Path(__file__).parent.parent.parent
|
|
||||||
self.skills_base = str(base)
|
|
||||||
else:
|
|
||||||
self.skills_base = skills_base
|
|
||||||
|
|
||||||
self.image_gen_script = os.path.join(self.skills_base, 'image-generation/scripts/image_gen.py')
|
|
||||||
self.image_edit_script = os.path.join(self.skills_base, 'image-edit/scripts/image_edit.py')
|
|
||||||
|
|
||||||
def generate_image(self, prompt: str, output_dir: str, width: int = 1024,
|
|
||||||
height: int = 1024, topic: str = None, channel: str = None) -> str:
|
|
||||||
"""
|
|
||||||
Generate image using image-generation skill
|
|
||||||
|
|
||||||
Args:
|
|
||||||
prompt: Image generation prompt
|
|
||||||
output_dir: Directory to save image
|
|
||||||
width: Image width
|
|
||||||
height: Image height
|
|
||||||
topic: Topic name (for filename)
|
|
||||||
channel: Channel name (for subfolder)
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to generated image
|
|
||||||
"""
|
|
||||||
# Create output directory
|
|
||||||
if topic and channel:
|
|
||||||
output_path = os.path.join(output_dir, topic, channel, 'images')
|
|
||||||
else:
|
|
||||||
output_path = output_dir
|
|
||||||
|
|
||||||
os.makedirs(output_path, exist_ok=True)
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
cmd = [
|
|
||||||
sys.executable,
|
|
||||||
self.image_gen_script,
|
|
||||||
'generate',
|
|
||||||
prompt,
|
|
||||||
'--width', str(width),
|
|
||||||
'--height', str(height)
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"\n🎨 Generating image...")
|
|
||||||
print(f" Prompt: {prompt[:100]}...")
|
|
||||||
print(f" Size: {width}x{height}")
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Run image generation
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(self.image_gen_script))
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
# Parse output (format: "filename.png [id]")
|
|
||||||
output_line = result.stdout.strip().split('\n')[-1]
|
|
||||||
image_path = output_line.split(' ')[0]
|
|
||||||
|
|
||||||
# Move to our output directory if needed
|
|
||||||
if image_path and os.path.exists(image_path):
|
|
||||||
dest_path = os.path.join(output_path, os.path.basename(image_path))
|
|
||||||
if image_path != dest_path:
|
|
||||||
import shutil
|
|
||||||
shutil.copy(image_path, dest_path)
|
|
||||||
print(f" ✓ Saved: {dest_path}")
|
|
||||||
return dest_path
|
|
||||||
|
|
||||||
print(f" ✗ Generation failed: {result.stderr}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def edit_product_image(self, base_image_path: str, edit_prompt: str,
|
|
||||||
output_dir: str, topic: str = None, channel: str = None) -> str:
|
|
||||||
"""
|
|
||||||
Edit product image using image-edit skill
|
|
||||||
|
|
||||||
Args:
|
|
||||||
base_image_path: Path to existing product image
|
|
||||||
edit_prompt: Edit instructions
|
|
||||||
output_dir: Directory to save edited image
|
|
||||||
topic: Topic name
|
|
||||||
channel: Channel name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to edited image
|
|
||||||
"""
|
|
||||||
if not os.path.exists(base_image_path):
|
|
||||||
print(f" ✗ Base image not found: {base_image_path}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Create output directory
|
|
||||||
if topic and channel:
|
|
||||||
output_path = os.path.join(output_dir, topic, channel, 'images')
|
|
||||||
else:
|
|
||||||
output_path = output_dir
|
|
||||||
|
|
||||||
os.makedirs(output_path, exist_ok=True)
|
|
||||||
|
|
||||||
# Build command
|
|
||||||
cmd = [
|
|
||||||
sys.executable,
|
|
||||||
self.image_edit_script,
|
|
||||||
edit_prompt,
|
|
||||||
base_image_path
|
|
||||||
]
|
|
||||||
|
|
||||||
print(f"\n✏️ Editing product image...")
|
|
||||||
print(f" Base: {base_image_path}")
|
|
||||||
print(f" Edit: {edit_prompt[:100]}...")
|
|
||||||
|
|
||||||
try:
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True, cwd=os.path.dirname(self.image_edit_script))
|
|
||||||
|
|
||||||
if result.returncode == 0:
|
|
||||||
output_line = result.stdout.strip().split('\n')[-1]
|
|
||||||
image_path = output_line.split(' ')[0]
|
|
||||||
|
|
||||||
if image_path and os.path.exists(image_path):
|
|
||||||
dest_path = os.path.join(output_path, os.path.basename(image_path))
|
|
||||||
if image_path != dest_path:
|
|
||||||
import shutil
|
|
||||||
shutil.copy(image_path, dest_path)
|
|
||||||
print(f" ✓ Saved: {dest_path}")
|
|
||||||
return dest_path
|
|
||||||
|
|
||||||
print(f" ✗ Edit failed: {result.stderr}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
print(f" ✗ Error: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
def find_product_images(self, product_name: str, website_repo: str) -> List[str]:
|
def find_product_images(self, product_name: str, website_repo: str) -> List[str]:
|
||||||
"""
|
"""
|
||||||
@@ -169,136 +28,82 @@ class ImageIntegration:
|
|||||||
Returns:
|
Returns:
|
||||||
List of image paths
|
List of image paths
|
||||||
"""
|
"""
|
||||||
import glob
|
if not website_repo or not os.path.exists(website_repo):
|
||||||
|
return []
|
||||||
|
|
||||||
extensions = ['.jpg', '.jpeg', '.png', '.webp']
|
extensions = [".jpg", ".jpeg", ".png", ".webp"]
|
||||||
found_images = []
|
found_images = []
|
||||||
|
|
||||||
# Search patterns
|
|
||||||
patterns = [
|
patterns = [
|
||||||
f"**/*{product_name}*{{ext}}",
|
f"**/*{product_name}*{{ext}}",
|
||||||
f"public/images/**/*{{ext}}",
|
f"public/images/**/*{{ext}}",
|
||||||
f"src/assets/**/*{{ext}}"
|
f"src/assets/**/*{{ext}}",
|
||||||
]
|
]
|
||||||
|
|
||||||
for pattern in patterns:
|
for pattern in patterns:
|
||||||
for ext in extensions:
|
for ext in extensions:
|
||||||
search_pattern = pattern.format(ext=ext)
|
search_pattern = pattern.format(ext=ext)
|
||||||
matches = glob.glob(os.path.join(website_repo, search_pattern), recursive=True)
|
matches = glob.glob(
|
||||||
found_images.extend(matches[:5]) # Limit per pattern
|
os.path.join(website_repo, search_pattern), recursive=True
|
||||||
|
)
|
||||||
|
found_images.extend(matches[:5])
|
||||||
|
|
||||||
return list(set(found_images))[:10] # Return unique, max 10
|
return list(set(found_images))[:10]
|
||||||
|
|
||||||
def handle_product_content(self, product_name: str, website_repo: str,
|
def handle_product_content(
|
||||||
edit_prompt: str, output_dir: str,
|
self, product_name: str, website_repo: str
|
||||||
topic: str, channel: str) -> Optional[str]:
|
) -> Optional[List[str]]:
|
||||||
"""
|
"""Handle image for product content - returns found images for user to select"""
|
||||||
Handle image for product content
|
|
||||||
|
|
||||||
Workflow:
|
|
||||||
1. Browse website repo for product images
|
|
||||||
2. If found: edit with image-edit
|
|
||||||
3. If not found: ask user to provide
|
|
||||||
|
|
||||||
Args:
|
|
||||||
product_name: Product name
|
|
||||||
website_repo: Path to website repo
|
|
||||||
edit_prompt: Edit instructions
|
|
||||||
output_dir: Output directory
|
|
||||||
topic: Topic name
|
|
||||||
channel: Channel name
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Path to image or None
|
|
||||||
"""
|
|
||||||
print(f"\n🔍 Looking for product images: {product_name}")
|
print(f"\n🔍 Looking for product images: {product_name}")
|
||||||
|
|
||||||
# Step 1: Find existing images
|
|
||||||
images = self.find_product_images(product_name, website_repo)
|
images = self.find_product_images(product_name, website_repo)
|
||||||
|
|
||||||
if images:
|
if images:
|
||||||
print(f" ✓ Found {len(images)} image(s)")
|
print(f" ✓ Found {len(images)} image(s):")
|
||||||
best_image = images[0] # Use first/best match
|
for i, img in enumerate(images[:5], 1):
|
||||||
|
print(f" {i}. {img}")
|
||||||
# Step 2: Edit image
|
return images
|
||||||
return self.edit_product_image(
|
|
||||||
best_image,
|
|
||||||
edit_prompt,
|
|
||||||
output_dir,
|
|
||||||
topic,
|
|
||||||
channel
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
print(f" ✗ No product images found in repo")
|
print(f" ✗ No product images found in repo")
|
||||||
print(f" Please provide product image manually")
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def handle_non_product_content(self, content_type: str, topic: str,
|
def suggest_non_product_image(self, content_type: str, topic: str) -> str:
|
||||||
output_dir: str, channel: str) -> Optional[str]:
|
|
||||||
"""
|
"""
|
||||||
Generate fresh image for non-product content
|
Suggest image for non-product content
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
content_type: Type (service, stats, knowledge)
|
content_type: Type (service, stats, knowledge)
|
||||||
topic: Topic name
|
topic: Topic name
|
||||||
output_dir: Output directory
|
|
||||||
channel: Channel name
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Path to generated image
|
Suggestion message
|
||||||
"""
|
"""
|
||||||
# Create prompt based on content type
|
suggestions = {
|
||||||
prompts = {
|
"service": f"Please provide a professional service illustration for: {topic}",
|
||||||
'service': f"Professional illustration of {topic}, modern flat design, business context, Thai-friendly aesthetic",
|
"stats": f"Please provide a data visualization/infographic image for: {topic}",
|
||||||
'stats': f"Data visualization infographic for {topic}, clean charts, professional style",
|
"knowledge": f"Please provide an educational illustration for: {topic}",
|
||||||
'knowledge': f"Educational illustration for {topic}, clear visual metaphor, engaging style",
|
"default": f"Please provide an image for: {topic}",
|
||||||
'default': f"Professional image for {topic}, modern design, high quality"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
prompt = prompts.get(content_type, prompts['default'])
|
return suggestions.get(content_type, suggestions["default"])
|
||||||
|
|
||||||
# Generate image
|
|
||||||
return self.generate_image(
|
|
||||||
prompt,
|
|
||||||
output_dir,
|
|
||||||
topic=topic,
|
|
||||||
channel=channel
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
"""Test image integration"""
|
"""Test image integration"""
|
||||||
parser = argparse.ArgumentParser(description='Test Image Integration')
|
parser = argparse.ArgumentParser(description="Test Image Integration")
|
||||||
parser.add_argument('--action', choices=['generate', 'edit', 'find'], required=True)
|
parser.add_argument("--action", choices=["find", "suggest"], required=True)
|
||||||
parser.add_argument('--prompt', help='Image prompt or edit instructions')
|
parser.add_argument("--topic", help="Topic name")
|
||||||
parser.add_argument('--topic', help='Topic name')
|
parser.add_argument("--product-name", help="Product name (for find action)")
|
||||||
parser.add_argument('--channel', help='Channel name')
|
parser.add_argument("--website-repo", help="Website repo path (for find action)")
|
||||||
parser.add_argument('--output-dir', default='./output', help='Output directory')
|
parser.add_argument(
|
||||||
parser.add_argument('--product-name', help='Product name (for find action)')
|
"--content-type", default="default", help="Content type (for suggest action)"
|
||||||
parser.add_argument('--website-repo', help='Website repo path (for find action)')
|
)
|
||||||
|
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
integration = ImageIntegration()
|
integration = ImageIntegration()
|
||||||
|
|
||||||
if args.action == 'generate':
|
if args.action == "find":
|
||||||
result = integration.handle_non_product_content(
|
|
||||||
'service', args.topic, args.output_dir, args.channel
|
|
||||||
)
|
|
||||||
print(f"\nResult: {result}")
|
|
||||||
|
|
||||||
elif args.action == 'edit':
|
|
||||||
if not args.product_name or not args.website_repo:
|
|
||||||
print("Error: --product-name and --website-repo required for edit")
|
|
||||||
return
|
|
||||||
|
|
||||||
result = integration.handle_product_content(
|
|
||||||
args.product_name, args.website_repo, args.prompt,
|
|
||||||
args.output_dir, args.topic, args.channel
|
|
||||||
)
|
|
||||||
print(f"\nResult: {result}")
|
|
||||||
|
|
||||||
elif args.action == 'find':
|
|
||||||
if not args.product_name or not args.website_repo:
|
if not args.product_name or not args.website_repo:
|
||||||
print("Error: --product-name and --website-repo required for find")
|
print("Error: --product-name and --website-repo required for find")
|
||||||
return
|
return
|
||||||
@@ -308,6 +113,12 @@ def main():
|
|||||||
for img in images:
|
for img in images:
|
||||||
print(f" - {img}")
|
print(f" - {img}")
|
||||||
|
|
||||||
|
elif args.action == "suggest":
|
||||||
|
suggestion = integration.suggest_non_product_image(
|
||||||
|
args.content_type, args.topic or "your topic"
|
||||||
|
)
|
||||||
|
print(f"\n{suggestion}")
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
Reference in New Issue
Block a user