I recently taught a local SEO workshop for military veterans transitioning into entrepreneurship. They were sharp, driven, and ready to build their businesses, but they kept hitting the same wall: the sheer, soul-crushing manual labor of market research. Hours were spent trying to answer questions like: "Who are my real competitors, not just the big names?" "What search terms do my customers actually use?" "What kind of content should I even write for my website?" "Who are my real competitors, not just the big names?" "What search terms do my customers actually use?" actually "What kind of content should I even write for my website?" The existing tools were either too expensive, too complex, or just glorified keyword lists. I knew there had to be a better way. So, I decided to build one: an AI-powered "Mission Control" that automates the entire strategic process, from understanding the customer to generating a complete content plan. This isn't just a simple script. It's a full-stack Django application that acts as an intelligent agent, using a local LLM and Google's APIs to deliver a strategic playbook in minutes, not weeks. Here’s how I built it. The Core Idea: An Automated Strategic Workflow The goal wasn't just to find keywords. It was to replicate the thinking process of a human strategist. I broke this down into a seven-phase workflow, where the output of each phase becomes the input for the next. The entire application is built around this chain of logic. Foundation: Define the business. Empathize: Understand the customer's mind. Brainstorm: Generate initial search ideas. Recon: Find real-world local competitors. Analyze: Scrape competitor sites and reviews. Extract & Validate: Find what's working and prove it with data. Synthesize: Create an actionable content strategy. Foundation: Define the business. Foundation: Empathize: Understand the customer's mind. Empathize: Brainstorm: Generate initial search ideas. Brainstorm: Recon: Find real-world local competitors. Recon: Analyze: Scrape competitor sites and reviews. Analyze: Extract & Validate: Find what's working and prove it with data. Extract & Validate: Synthesize: Create an actionable content strategy. Synthesize: I chose Django for its robust structure, but to keep it fast and simple for this purpose, I used a session-based system that writes all the data to a single JSON file. This made it a "single-player" tool, perfect for a workshop. The real magic, however, happens in the service layer. Phase 1 & 2: Building Empathy with an LLM First, the user fills out a simple form about their business. Then, instead of just guessing at customer types, I use the LLM to generate three distinct customer personas. The key here is iterative prompting. I don't just ask for three personas at once. I generate the first one, then feed it back into the prompt for the second one with an instruction: "Do NOT repeat the same themes, problems, or persona types. Create a new, completely distinct narrative." iterative prompting Do NOT repeat the same themes, problems, or persona types. Create a new, completely distinct narrative. This ensures variety and covers a wider range of customer motivations. core/llm_service.py - Generating Distinct Narratives core/llm_service.py - Generating Distinct Narratives A simplified look at the loop in generate_customer_profiles() all_narratives = [] for i in range(3): exclusion_prompt_part = "" if all_narratives: previous_narratives_text =\n".join(all_narratives) exclusion_prompt_part = f""" IMPORTANT: You have already generated the narratives below. Do NOT repeat them. PREVIOUSLY GENERATED NARRATIVES: {previous_narratives_text} """ # ... build the rest of the prompt and call the LLM ... # ... add the new narrative to all_narratives ... A simplified look at the loop in generate_customer_profiles() all_narratives = [] for i in range(3): exclusion_prompt_part = "" if all_narratives: previous_narratives_text =\n".join(all_narratives) exclusion_prompt_part = f""" IMPORTANT: You have already generated the narratives below. Do NOT repeat them. PREVIOUSLY GENERATED NARRATIVES: {previous_narratives_text} """ # ... build the rest of the prompt and call the LLM ... # ... add the new narrative to all_narratives ... This simple trick dramatically improves the quality of the output. We now have a deep understanding of why someone is searching, not just what they're searching for. why what Phase 3 & 4: From Personas to Real-World Competitors With the business profile and customer personas, the LLM can now brainstorm a list of "seed keywords." Critically, I instruct it to be location-agnostic. location-agnostic Prompt Snippet: "CRITICAL INSTRUCTION: Do NOT include any city, state, or location names in your phrases. The location will be added later. Focus only on the service or product." Prompt Snippet: "CRITICAL INSTRUCTION: Do NOT include any city, state, or location names in your phrases. The location will be added later. Focus only on the service or product." Prompt Snippet: Why? Because we're about to combine these pure service keywords with the business's location using the Google Maps API. # A look inside find_local_competitors() def find_local_competitors(keywords, location_string, radius_meters=5000): center_coords = _get_coordinates(location_string) # Geocoding API if not center_coords: return [] unique_competitors = {} for keyword in keywords: # Use Google's Nearby Search API search_params = { 'location': f"{center_coords['lat']},{center_coords['lng']}", 'radius': radius_meters, 'keyword': keyword, 'key': API_KEY, } # ... make request ... for place in search_results: place_id = place.get('place_id') if not place_id or place_id in unique_competitors: continue # Use Place Details API to get website, reviews, etc. # ... get details ... unique_competitors[place_id] = { ... structured data ... } return list(unique_competitors.values()) # A look inside find_local_competitors() def find_local_competitors(keywords, location_string, radius_meters=5000): center_coords = _get_coordinates(location_string) # Geocoding API if not center_coords: return [] unique_competitors = {} for keyword in keywords: # Use Google's Nearby Search API search_params = { 'location': f"{center_coords['lat']},{center_coords['lng']}", 'radius': radius_meters, 'keyword': keyword, 'key': API_KEY, } # ... make request ... for place in search_results: place_id = place.get('place_id') if not place_id or place_id in unique_competitors: continue # Use Place Details API to get website, reviews, etc. # ... get details ... unique_competitors[place_id] = { ... structured data ... } return list(unique_competitors.values()) This gives us a hyper-relevant list of actual local businesses, complete with their websites and customer reviews—goldmines of information for the next step. Phase 5 & 6: Extraction and Validation, The AI-Human Loop This is where the system gets really powerful. Scraping: I use undetected-chromedriver to scrape the text from the top competitor websites selected by the user. It's more resilient to bot detection than standard Selenium. AI Keyword Extraction: I feed the raw, messy text from each competitor site to the LLM. The prompt is highly structured, asking it to act as an SEO expert and categorize multi-word phrases into buckets like 'service_phrases', 'problem_phrases', and 'qualifier_phrases'. Data-Driven Validation: We now have a list of keywords that competitors are actually using. But are people searching for them? I use the pytrends library to query Google Trends for each keyword within the business's specific metro area (DMA). Scraping: I use undetected-chromedriver to scrape the text from the top competitor websites selected by the user. It's more resilient to bot detection than standard Selenium. Scraping: AI Keyword Extraction: I feed the raw, messy text from each competitor site to the LLM. The prompt is highly structured, asking it to act as an SEO expert and categorize multi-word phrases into buckets like 'service_phrases', 'problem_phrases', and 'qualifier_phrases'. AI Keyword Extraction: Data-Driven Validation: We now have a list of keywords that competitors are actually using. But are people searching for them? I use the pytrends library to query Google Trends for each keyword within the business's specific metro area (DMA). Data-Driven Validation: actually core/trends_service.py - Scoring Keywords core/trends_service.py - Scoring Keywords # A look inside validate_keywords_in_dma() def validate_keywords_in_dma(keywords, location_string, ...): pytrends = TrendReq(hl='en-US', tz=360) dma_code = get_dma_code(location_string, ...) # Helper to find local market code geo_target = dma_code if dma_code else 'US' keyword_scores = [] for keyword in keywords: # ... with retry logic for 429 errors ... pytrends.build_payload([keyword], timeframe='today 12-m', geo=geo_target) interest_df = pytrends.interest_over_time() # We get a score from 0-100 representing relative interest score = interest_df[keyword].mean() if not interest_df.empty else 0 keyword_scores.append({'keyword': keyword, 'score': score}) return {'scores': sorted(keyword_scores, ...)} # A look inside validate_keywords_in_dma() def validate_keywords_in_dma(keywords, location_string, ...): pytrends = TrendReq(hl='en-US', tz=360) dma_code = get_dma_code(location_string, ...) # Helper to find local market code geo_target = dma_code if dma_code else 'US' keyword_scores = [] for keyword in keywords: # ... with retry logic for 429 errors ... pytrends.build_payload([keyword], timeframe='today 12-m', geo=geo_target) interest_df = pytrends.interest_over_time() # We get a score from 0-100 representing relative interest score = interest_df[keyword].mean() if not interest_df.empty else 0 keyword_scores.append({'keyword': keyword, 'score': score}) return {'scores': sorted(keyword_scores, ...)} The user is then presented with a scored list of proven keywords. They curate this list, adding their own expertise and creating the final, validated foundation for the content strategy. Phase 7: The Grand Finale—The AI-Generated Content Strategy This is the payoff. With all the validated data—business profile, customer personas, competitor summaries, and top keywords—we make one final, complex call to the LLM. But a single massive prompt is unreliable. Instead, I built an "iterative synthesizer" in analyze_content_ideas_view. It runs a series of smaller, more focused LLM calls: analyze_content_ideas_view Generate a Core Value Proposition: "Based on all this data, write a powerful 3-sentence value prop." Write a Google Business Profile Description: "Write a keyword-rich, 750-character description with a call to action." Create Page-Specific Recommendations: "Give me a headline/sub-headline for the homepage and an opening paragraph for the about page." Brainstorm Blog Post Ideas: "Generate three distinct blog post titles and descriptions that target a specific customer persona and a high-scoring keyword." Generate a Core Value Proposition: "Based on all this data, write a powerful 3-sentence value prop." Generate a Core Value Proposition: Write a Google Business Profile Description: "Write a keyword-rich, 750-character description with a call to action." Write a Google Business Profile Description: Create Page-Specific Recommendations: "Give me a headline/sub-headline for the homepage and an opening paragraph for the about page." Create Page-Specific Recommendations: Brainstorm Blog Post Ideas: "Generate three distinct blog post titles and descriptions that target a specific customer persona and a high-scoring keyword." Brainstorm Blog Post Ideas: This chained approach is far more robust and produces a "Website Content Starter Kit"—a comprehensive, actionable document that a business owner can immediately use. What I Learned LLMs Are Creative, Not Precise: Getting perfectly formatted JSON from an LLM requires a bulletproof parser. Don't trust the model to always follow instructions. My _clean_and_parse_json function became essential, stripping out markdown fences and sanitizing characters before parsing. Chaining is the Superpower: The real magic isn't one prompt, but a series of them. The output of the persona generator becomes the input for the keyword generator, which informs the API calls, which creates the data for the final synthesizer. Combine AI with External APIs: The LLM is great for qualitative tasks (writing, summarizing, brainstorming). But combining it with quantitative data from Google Trends and Google Maps creates a result that's grounded in reality. LLMs Are Creative, Not Precise: Getting perfectly formatted JSON from an LLM requires a bulletproof parser. Don't trust the model to always follow instructions. My _clean_and_parse_json function became essential, stripping out markdown fences and sanitizing characters before parsing. LLMs Are Creative, Not Precise: _clean_and_parse_json Chaining is the Superpower: The real magic isn't one prompt, but a series of them. The output of the persona generator becomes the input for the keyword generator, which informs the API calls, which creates the data for the final synthesizer. Chaining is the Superpower: Combine AI with External APIs: The LLM is great for qualitative tasks (writing, summarizing, brainstorming). But combining it with quantitative data from Google Trends and Google Maps creates a result that's grounded in reality. Combine AI with External APIs: This project was a blast to build and a huge success in the workshop. It showed the veterans that AI isn't just a gimmick; it's a powerful force multiplier that can help them compete and win. For anyone looking to build similar tools, my advice is to think in workflows. Break your problem down into logical steps and use the AI to bridge the gaps.