Refactor keyword handling: Replace 'intent' with 'country' across backend and frontend
- Updated AutomationService to include estimated_word_count. - Increased stage_1_batch_size from 20 to 50 in AutomationViewSet. - Changed Keywords model to replace 'intent' property with 'country'. - Adjusted ClusteringService to allow a maximum of 50 keywords for clustering. - Modified admin and management commands to remove 'intent' and use 'country' instead. - Updated serializers to reflect the change from 'intent' to 'country'. - Adjusted views and filters to use 'country' instead of 'intent'. - Updated frontend forms, filters, and pages to replace 'intent' with 'country'. - Added migration to remove 'intent' field and add 'country' field to SeedKeyword model.
This commit is contained in:
@@ -97,7 +97,6 @@ class AutoClusterFunction(BaseAIFunction):
|
||||
'keyword': kw.keyword,
|
||||
'volume': kw.volume,
|
||||
'difficulty': kw.difficulty,
|
||||
'intent': kw.intent,
|
||||
}
|
||||
for kw in keywords
|
||||
],
|
||||
@@ -111,7 +110,7 @@ class AutoClusterFunction(BaseAIFunction):
|
||||
|
||||
# Format keywords
|
||||
keywords_text = '\n'.join([
|
||||
f"- {kw['keyword']} (Volume: {kw['volume']}, Difficulty: {kw['difficulty']}, Intent: {kw['intent']})"
|
||||
f"- {kw['keyword']} (Volume: {kw['volume']}, Difficulty: {kw['difficulty']})"
|
||||
for kw in keyword_data
|
||||
])
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ class IntegrationTestBase(TestCase):
|
||||
sector=self.industry_sector,
|
||||
volume=1000,
|
||||
difficulty=50,
|
||||
intent="informational"
|
||||
country="US"
|
||||
)
|
||||
|
||||
# Authenticate client
|
||||
|
||||
@@ -548,18 +548,18 @@ class IndustrySectorAdmin(Igny8ModelAdmin):
|
||||
@admin.register(SeedKeyword)
|
||||
class SeedKeywordAdmin(Igny8ModelAdmin):
|
||||
"""SeedKeyword admin - Global reference data, no account filtering"""
|
||||
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active', 'created_at']
|
||||
list_filter = ['is_active', 'industry', 'sector', 'intent']
|
||||
list_display = ['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active', 'created_at']
|
||||
list_filter = ['is_active', 'industry', 'sector', 'country']
|
||||
search_fields = ['keyword']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
actions = ['delete_selected'] # Enable bulk delete
|
||||
|
||||
fieldsets = (
|
||||
('Keyword Info', {
|
||||
'fields': ('keyword', 'industry', 'sector', 'is_active')
|
||||
'fields': ('keyword', 'industry', 'sector', 'country', 'is_active')
|
||||
}),
|
||||
('SEO Metrics', {
|
||||
'fields': ('volume', 'difficulty', 'intent')
|
||||
'fields': ('volume', 'difficulty')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at')
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 5.2.9 on 2025-12-17 06:04
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('igny8_core_auth', '0017_add_history_tracking'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RemoveIndex(
|
||||
model_name='seedkeyword',
|
||||
name='igny8_seed__intent_15020d_idx',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='seedkeyword',
|
||||
name='intent',
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='seedkeyword',
|
||||
name='country',
|
||||
field=models.CharField(choices=[('US', 'United States'), ('CA', 'Canada'), ('GB', 'United Kingdom'), ('AE', 'United Arab Emirates'), ('AU', 'Australia'), ('IN', 'India'), ('PK', 'Pakistan')], default='US', help_text='Target country for this keyword', max_length=2),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='seedkeyword',
|
||||
index=models.Index(fields=['country'], name='igny8_seed__country_4127a5_idx'),
|
||||
),
|
||||
]
|
||||
@@ -517,11 +517,14 @@ class SeedKeyword(models.Model):
|
||||
These are canonical keywords that can be imported into account-specific Keywords.
|
||||
Non-deletable global reference data.
|
||||
"""
|
||||
INTENT_CHOICES = [
|
||||
('informational', 'Informational'),
|
||||
('navigational', 'Navigational'),
|
||||
('commercial', 'Commercial'),
|
||||
('transactional', 'Transactional'),
|
||||
COUNTRY_CHOICES = [
|
||||
('US', 'United States'),
|
||||
('CA', 'Canada'),
|
||||
('GB', 'United Kingdom'),
|
||||
('AE', 'United Arab Emirates'),
|
||||
('AU', 'Australia'),
|
||||
('IN', 'India'),
|
||||
('PK', 'Pakistan'),
|
||||
]
|
||||
|
||||
keyword = models.CharField(max_length=255, db_index=True)
|
||||
@@ -533,7 +536,7 @@ class SeedKeyword(models.Model):
|
||||
validators=[MinValueValidator(0), MaxValueValidator(100)],
|
||||
help_text='Keyword difficulty (0-100)'
|
||||
)
|
||||
intent = models.CharField(max_length=50, choices=INTENT_CHOICES, default='informational')
|
||||
country = models.CharField(max_length=2, choices=COUNTRY_CHOICES, default='US', help_text='Target country for this keyword')
|
||||
is_active = models.BooleanField(default=True, db_index=True)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
@@ -547,7 +550,7 @@ class SeedKeyword(models.Model):
|
||||
models.Index(fields=['keyword']),
|
||||
models.Index(fields=['industry', 'sector']),
|
||||
models.Index(fields=['industry', 'sector', 'is_active']),
|
||||
models.Index(fields=['intent']),
|
||||
models.Index(fields=['country']),
|
||||
]
|
||||
ordering = ['keyword']
|
||||
|
||||
|
||||
@@ -524,14 +524,14 @@ class SeedKeywordSerializer(serializers.ModelSerializer):
|
||||
industry_slug = serializers.CharField(source='industry.slug', read_only=True)
|
||||
sector_name = serializers.CharField(source='sector.name', read_only=True)
|
||||
sector_slug = serializers.CharField(source='sector.slug', read_only=True)
|
||||
intent_display = serializers.CharField(source='get_intent_display', read_only=True)
|
||||
country_display = serializers.CharField(source='get_country_display', read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = SeedKeyword
|
||||
fields = [
|
||||
'id', 'keyword', 'industry', 'industry_name', 'industry_slug',
|
||||
'sector', 'sector_name', 'sector_slug',
|
||||
'volume', 'difficulty', 'intent', 'intent_display',
|
||||
'volume', 'difficulty', 'country', 'country_display',
|
||||
'is_active', 'created_at', 'updated_at'
|
||||
]
|
||||
read_only_fields = ['created_at', 'updated_at']
|
||||
|
||||
@@ -839,7 +839,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
search_fields = ['keyword']
|
||||
ordering_fields = ['keyword', 'volume', 'difficulty', 'created_at']
|
||||
ordering = ['keyword']
|
||||
filterset_fields = ['industry', 'sector', 'intent', 'is_active']
|
||||
filterset_fields = ['industry', 'sector', 'country', 'is_active']
|
||||
|
||||
def retrieve(self, request, *args, **kwargs):
|
||||
"""Override retrieve to return unified format"""
|
||||
@@ -877,7 +877,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
def import_seed_keywords(self, request):
|
||||
"""
|
||||
Import seed keywords from CSV (Admin/Superuser only).
|
||||
Expected columns: keyword, industry_name, sector_name, volume, difficulty, intent
|
||||
Expected columns: keyword, industry_name, sector_name, volume, difficulty, country
|
||||
"""
|
||||
import csv
|
||||
from django.db import transaction
|
||||
@@ -960,7 +960,7 @@ class SeedKeywordViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
sector=sector,
|
||||
volume=int(row.get('volume', 0) or 0),
|
||||
difficulty=int(row.get('difficulty', 0) or 0),
|
||||
intent=row.get('intent', 'informational') or 'informational',
|
||||
country=row.get('country', 'US') or 'US',
|
||||
is_active=True
|
||||
)
|
||||
imported_count += 1
|
||||
@@ -1487,9 +1487,9 @@ def seedkeyword_csv_template(request):
|
||||
response['Content-Disposition'] = 'attachment; filename="seedkeyword_template.csv"'
|
||||
|
||||
writer = csv.writer(response)
|
||||
writer.writerow(['keyword', 'industry', 'sector', 'volume', 'difficulty', 'intent', 'is_active'])
|
||||
writer.writerow(['python programming', 'Technology', 'Software Development', '10000', '45', 'Informational', 'true'])
|
||||
writer.writerow(['medical software', 'Healthcare', 'Healthcare IT', '5000', '60', 'Commercial', 'true'])
|
||||
writer.writerow(['keyword', 'industry', 'sector', 'volume', 'difficulty', 'country', 'is_active'])
|
||||
writer.writerow(['python programming', 'Technology', 'Software Development', '10000', '45', 'US', 'true'])
|
||||
writer.writerow(['medical software', 'Healthcare', 'Healthcare IT', '5000', '60', 'CA', 'true'])
|
||||
|
||||
return response
|
||||
|
||||
@@ -1534,7 +1534,7 @@ def seedkeyword_csv_import(request):
|
||||
defaults={
|
||||
'volume': int(row.get('volume', 0)),
|
||||
'difficulty': int(row.get('difficulty', 0)),
|
||||
'intent': row.get('intent', 'Informational'),
|
||||
'country': row.get('country', 'US'),
|
||||
'is_active': is_active
|
||||
}
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ class AutomationConfig(models.Model):
|
||||
scheduled_time = models.TimeField(default='02:00', help_text="Time to run (e.g., 02:00)")
|
||||
|
||||
# Batch sizes per stage
|
||||
stage_1_batch_size = models.IntegerField(default=20, help_text="Keywords per batch")
|
||||
stage_1_batch_size = models.IntegerField(default=50, help_text="Keywords per batch")
|
||||
stage_2_batch_size = models.IntegerField(default=1, help_text="Clusters at a time")
|
||||
stage_3_batch_size = models.IntegerField(default=20, help_text="Ideas per batch")
|
||||
stage_4_batch_size = models.IntegerField(default=1, help_text="Tasks - sequential")
|
||||
|
||||
@@ -637,6 +637,7 @@ class AutomationService:
|
||||
content_type=idea.content_type or 'post',
|
||||
content_structure=idea.content_structure or 'article',
|
||||
keywords=keywords_str,
|
||||
word_count=idea.estimated_word_count,
|
||||
status='queued',
|
||||
account=idea.account,
|
||||
site=idea.site,
|
||||
|
||||
@@ -82,7 +82,7 @@ class AutomationViewSet(viewsets.ViewSet):
|
||||
"is_enabled": true,
|
||||
"frequency": "daily",
|
||||
"scheduled_time": "02:00",
|
||||
"stage_1_batch_size": 20,
|
||||
"stage_1_batch_size": 50,
|
||||
...
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -131,9 +131,9 @@ class Keywords(SoftDeletableModel, SiteSectorBaseModel):
|
||||
return self.difficulty_override if self.difficulty_override is not None else (self.seed_keyword.difficulty if self.seed_keyword else 0)
|
||||
|
||||
@property
|
||||
def intent(self):
|
||||
"""Get intent from seed_keyword"""
|
||||
return self.seed_keyword.intent if self.seed_keyword else 'informational'
|
||||
def country(self):
|
||||
"""Get country from seed_keyword"""
|
||||
return self.seed_keyword.country if self.seed_keyword else 'US'
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
"""Validate that seed_keyword's industry/sector matches site's industry/sector"""
|
||||
|
||||
@@ -38,10 +38,10 @@ class ClusteringService:
|
||||
'error': 'No keyword IDs provided'
|
||||
}
|
||||
|
||||
if len(keyword_ids) > 20:
|
||||
if len(keyword_ids) > 50:
|
||||
return {
|
||||
'success': False,
|
||||
'error': 'Maximum 20 keywords allowed for clustering'
|
||||
'error': 'Maximum 50 keywords allowed for clustering'
|
||||
}
|
||||
|
||||
# Check credits (fixed cost per clustering operation)
|
||||
|
||||
@@ -18,7 +18,7 @@ class KeywordsResource(resources.ModelResource):
|
||||
class Meta:
|
||||
model = Keywords
|
||||
fields = ('id', 'keyword', 'seed_keyword__keyword', 'site__name', 'sector__name',
|
||||
'cluster__name', 'volume', 'difficulty', 'intent', 'status', 'created_at')
|
||||
'cluster__name', 'volume', 'difficulty', 'country', 'status', 'created_at')
|
||||
export_order = fields
|
||||
|
||||
|
||||
@@ -55,11 +55,11 @@ class ClustersAdmin(SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
@admin.register(Keywords)
|
||||
class KeywordsAdmin(ExportMixin, SiteSectorAdminMixin, Igny8ModelAdmin):
|
||||
resource_class = KeywordsResource
|
||||
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'intent', 'status', 'created_at']
|
||||
list_display = ['keyword', 'seed_keyword', 'site', 'sector', 'cluster', 'volume', 'difficulty', 'country', 'status', 'created_at']
|
||||
list_editable = ['status'] # Enable inline editing for status
|
||||
list_filter = [
|
||||
('status', ChoicesDropdownFilter),
|
||||
('intent', ChoicesDropdownFilter),
|
||||
('country', ChoicesDropdownFilter),
|
||||
('site', RelatedDropdownFilter),
|
||||
('sector', RelatedDropdownFilter),
|
||||
('cluster', RelatedDropdownFilter),
|
||||
|
||||
@@ -109,7 +109,6 @@ class Command(BaseCommand):
|
||||
'account': site.account,
|
||||
'volume': 1000 + (created_count * 100), # Varying volumes
|
||||
'difficulty': 30 + (created_count * 10), # Varying difficulty (0-100 scale)
|
||||
'intent': 'informational' if created_count % 2 == 0 else 'commercial',
|
||||
'status': 'active',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -243,7 +243,6 @@ class Command(BaseCommand):
|
||||
'account': site.account,
|
||||
'volume': 500 + (created_count * 50), # Varying volumes
|
||||
'difficulty': 20 + (created_count * 8), # Varying difficulty (0-100 scale)
|
||||
'intent': 'informational' if created_count % 2 == 0 else 'commercial',
|
||||
'status': 'active',
|
||||
}
|
||||
)
|
||||
|
||||
@@ -93,7 +93,7 @@ class Command(BaseCommand):
|
||||
sector=industry_sector,
|
||||
volume=keyword.volume or 0,
|
||||
difficulty=keyword.difficulty or 0,
|
||||
intent=keyword.intent or 'informational',
|
||||
country='US', # Default country for migration
|
||||
is_active=True
|
||||
)
|
||||
created_count += 1
|
||||
|
||||
@@ -13,7 +13,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
keyword = serializers.CharField(read_only=True) # From seed_keyword.keyword
|
||||
volume = serializers.IntegerField(read_only=True) # From seed_keyword.volume or volume_override
|
||||
difficulty = serializers.IntegerField(read_only=True) # From seed_keyword.difficulty or difficulty_override
|
||||
intent = serializers.CharField(read_only=True) # From seed_keyword.intent
|
||||
country = serializers.CharField(read_only=True) # From seed_keyword.country
|
||||
|
||||
# SeedKeyword relationship
|
||||
# Optional for create - can either provide seed_keyword_id OR custom keyword fields
|
||||
@@ -24,11 +24,11 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
custom_keyword = serializers.CharField(write_only=True, required=False, allow_blank=False)
|
||||
custom_volume = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
||||
custom_difficulty = serializers.IntegerField(write_only=True, required=False, allow_null=True)
|
||||
custom_intent = serializers.ChoiceField(
|
||||
custom_country = serializers.ChoiceField(
|
||||
write_only=True,
|
||||
required=False,
|
||||
choices=['informational', 'navigational', 'transactional', 'commercial'],
|
||||
default='informational'
|
||||
choices=['US', 'CA', 'GB', 'AE', 'AU', 'IN', 'PK'],
|
||||
default='US'
|
||||
)
|
||||
|
||||
# Overrides
|
||||
@@ -50,11 +50,11 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
'keyword',
|
||||
'volume',
|
||||
'difficulty',
|
||||
'intent',
|
||||
'country',
|
||||
'custom_keyword', # Write-only field for creating custom keywords
|
||||
'custom_volume', # Write-only
|
||||
'custom_difficulty', # Write-only
|
||||
'custom_intent', # Write-only
|
||||
'custom_country', # Write-only
|
||||
'volume_override',
|
||||
'difficulty_override',
|
||||
'cluster_id',
|
||||
@@ -67,7 +67,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
'sector_id',
|
||||
'account_id',
|
||||
]
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keyword', 'volume', 'difficulty', 'intent']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at', 'account_id', 'keyword', 'volume', 'difficulty', 'country']
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
@@ -106,7 +106,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
custom_keyword = validated_data.pop('custom_keyword', None)
|
||||
custom_volume = validated_data.pop('custom_volume', None)
|
||||
custom_difficulty = validated_data.pop('custom_difficulty', None)
|
||||
custom_intent = validated_data.pop('custom_intent', None) or 'informational'
|
||||
custom_country = validated_data.pop('custom_country', None) or 'US'
|
||||
|
||||
# Get site and sector - they're passed as objects via save() in the view
|
||||
site = validated_data.get('site')
|
||||
@@ -132,7 +132,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
defaults={
|
||||
'volume': custom_volume or 0,
|
||||
'difficulty': custom_difficulty or 0,
|
||||
'intent': custom_intent or 'informational',
|
||||
'country': custom_country or 'US',
|
||||
'is_active': True,
|
||||
}
|
||||
)
|
||||
@@ -162,7 +162,7 @@ class KeywordSerializer(serializers.ModelSerializer):
|
||||
validated_data.pop('custom_keyword', None)
|
||||
validated_data.pop('custom_volume', None)
|
||||
validated_data.pop('custom_difficulty', None)
|
||||
validated_data.pop('custom_intent', None)
|
||||
validated_data.pop('custom_country', None)
|
||||
|
||||
# seed_keyword_id is optional for updates - only update if provided
|
||||
if 'seed_keyword_id' in validated_data:
|
||||
|
||||
@@ -55,7 +55,7 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
ordering = ['-created_at'] # Default ordering (newest first)
|
||||
|
||||
# Filter configuration - filter by status, cluster_id, and seed_keyword fields
|
||||
filterset_fields = ['status', 'cluster_id', 'seed_keyword__intent', 'seed_keyword_id']
|
||||
filterset_fields = ['status', 'cluster_id', 'seed_keyword__country', 'seed_keyword_id']
|
||||
|
||||
def get_queryset(self):
|
||||
"""
|
||||
@@ -475,7 +475,7 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
|
||||
writer = csv.writer(response)
|
||||
# Header row
|
||||
writer.writerow(['ID', 'Keyword', 'Volume', 'Difficulty', 'Intent', 'Status', 'Cluster ID', 'Created At'])
|
||||
writer.writerow(['ID', 'Keyword', 'Volume', 'Difficulty', 'Country', 'Status', 'Cluster ID', 'Created At'])
|
||||
|
||||
# Data rows
|
||||
for keyword in keywords:
|
||||
@@ -484,7 +484,7 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
keyword.keyword,
|
||||
keyword.volume,
|
||||
keyword.difficulty,
|
||||
keyword.intent,
|
||||
keyword.country,
|
||||
keyword.status,
|
||||
keyword.cluster_id or '',
|
||||
keyword.created_at.isoformat() if keyword.created_at else '',
|
||||
@@ -631,12 +631,13 @@ class KeywordViewSet(SiteSectorModelViewSet):
|
||||
skipped_count += 1
|
||||
continue
|
||||
|
||||
# Create keyword
|
||||
# Note: This direct creation bypasses seed_keyword linkage
|
||||
# Keywords should ideally be created through seed_keyword FK
|
||||
# Country comes from seed_keyword.country property
|
||||
Keywords.objects.create(
|
||||
keyword=keyword_text,
|
||||
volume=int(row.get('volume', 0) or 0),
|
||||
difficulty=int(row.get('difficulty', 0) or 0),
|
||||
intent=row.get('intent', 'informational') or 'informational',
|
||||
status=row.get('status', 'new') or 'new',
|
||||
site=site,
|
||||
sector=sector,
|
||||
@@ -1193,6 +1194,7 @@ class ContentIdeasViewSet(SiteSectorModelViewSet):
|
||||
content_structure=idea.content_structure or 'article',
|
||||
taxonomy_term=None, # Can be set later if taxonomy is available
|
||||
keywords=keywords_str, # Comma-separated keywords string
|
||||
word_count=idea.estimated_word_count, # Copy word count from idea
|
||||
status='queued',
|
||||
account=idea.account,
|
||||
site=idea.site,
|
||||
|
||||
@@ -1050,7 +1050,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
|
||||
def cluster_keywords(
|
||||
self,
|
||||
keywords: List[Dict[str, Any]], # List of keyword dicts with 'keyword', 'volume', 'difficulty', 'intent'
|
||||
keywords: List[Dict[str, Any]], # List of keyword dicts with 'keyword', 'volume', 'difficulty'
|
||||
sector_name: Optional[str] = None,
|
||||
account=None,
|
||||
response_steps=None,
|
||||
@@ -1069,7 +1069,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
Based on reference plugin's clustering prompt.
|
||||
|
||||
Args:
|
||||
keywords: List of keyword dicts with keyword, volume, difficulty, intent
|
||||
keywords: List of keyword dicts with keyword, volume, difficulty
|
||||
sector_name: Optional sector name for context
|
||||
account: Optional account for getting custom prompts
|
||||
|
||||
@@ -1085,7 +1085,7 @@ Make sure each prompt is detailed enough for image generation, describing the vi
|
||||
|
||||
# Format keywords for prompt
|
||||
keywords_text = '\n'.join([
|
||||
f"- {kw.get('keyword', '')} (Volume: {kw.get('volume', 0)}, Difficulty: {kw.get('difficulty', 0)}, Intent: {kw.get('intent', 'unknown')})"
|
||||
f"- {kw.get('keyword', '')} (Volume: {kw.get('volume', 0)}, Difficulty: {kw.get('difficulty', 0)})"
|
||||
for kw in keywords
|
||||
])
|
||||
|
||||
|
||||
Reference in New Issue
Block a user