feat: SEO schema, Word Count Goals, Internal Linking targets, and Admin UI updates

This commit is contained in:
cawcenter
2025-12-12 23:36:22 -05:00
parent f6041af538
commit ad1e1705b7
8 changed files with 365 additions and 26 deletions

192
CAMPAIGN_SETUP_GUIDE.md Normal file
View File

@@ -0,0 +1,192 @@
# Spark Platform: Master Data & Campaign Manual
This manual provides comprehensive instructions for managing the "Brains" of your content factory. It covers data import formats, advanced Spintax usage, Geo-Intelligence configuration, and full Content Framework management.
---
## 📚 1. Content Intelligence Mastery
### A. Spintax & Variables Guide
Spintax (Spin Syntax) allows you to create dynamic variations of text. The engine processes this *before* it processes variables.
#### **Basic Syntax**
* **Format:** `{Option A|Option B|Option C}`
* **Example:** `{Best|Top Rated|Premier}` Solar Installer
* **Output:** Randomly selects one option.
#### **Nested Spintax (Advanced)**
You can nest options inside others for exponential variations.
* **Example:** `{Get {Started|Going}|Start Your Journey}` today.
* **Logic:**
* 50% Direct: "Start Your Journey today"
* 50% Nested:
* 25%: "Get Started today"
* 25%: "Get Going today"
#### **System Variables**
These are placeholders that the Generator replaces automatically based on the context (Campaign settings, Location, or Current Time).
| Variable | Description | Example Output |
| :--- | :--- | :--- |
| `{City}` | Target City Name | "Austin" |
| `{State}` | Full State Name | "Texas" |
| `{State_Abbr}` | 2-Letter Code | "TX" |
| `{County}` | Target County | "Travis County" |
| `{Year}` | Current Year | "2025" |
| `{Month}` | Current Month | "December" |
| `{Niche}` | Campaign Niche (if set) | "Solar" |
| `{Avatar_Name}` | Targeted Avatar | "The Skeptic" |
#### **Example Usage in a Headline:**
`"{Top Rated|Best} {Niche} Services in {City}, {State_Abbr} - {Year} Update"`
* *Output 1:* "Top Rated Solar Services in Austin, TX - 2025 Update"
* *Output 2:* "Best Solar Services in Miami, FL - 2025 Update"
---
## 🛠 2. Data Import Templates (JSON)
Use the **Import / Export** feature in the Directus Admin sidebar for each collection. Ensure you import as **JSON**.
### A. Headline Inventory
*Collection:* `headline_inventory`
*Advanced Feature:* You can include variables in your headlines.
```json
[
{
"final_title_text": "The {Ultimate|Definitive} Guide to {Niche} in {City}",
"status": "available",
"notes": "Good for long-form SEO landers"
},
{
"final_title_text": "How {City} Residents Are Saving Money in {Year}",
"status": "available"
}
]
```
### B. Content Fragments (With HTML & Lists)
*Collection:* `content_fragments`
*Power User Tip:* You can use full HTML, including bullet lists `<ul>`, bolding `<strong>`, and Spintax within the HTML.
```json
[
{
"fragment_type": "pillar_advantages",
"content_body": "<h3>Why Choose Us In {City}?</h3><p>We provide:</p><ul><li><strong>Speed:</strong> {Fast|Quick} installation.</li><li><strong>Value:</strong> Best rates in {State}.</li><li><strong>Trust:</strong> 5-Star rated.</li></ul>",
"word_count": 50
},
{
"fragment_type": "intro_hook",
"content_body": "<p>Are you tired of high energy bills in <strong>{City}</strong>? You aren't alone. {Thousands|Hundreds} of homeowners have made the switch.</p>"
}
]
```
### C. Geo Intelligence (Counties, Landmarks & Metadata)
*Collection:* `geo_intelligence`
*Advanced Feature:* You can add arbitrary metadata like `landmarks`, `neighborhoods`, or `weather_zone` to the `data` blob. The engine (if configured) can read these.
**Example: Bulk Importing a County with City Landmarks**
```json
[
{
"cluster_key": "orange_county_ca_attractions",
"data": {
"cluster_name": "Orange County Metro",
"region_type": "county_cluster",
"cities": [
{
"city": "Anaheim",
"state": "CA",
"zip_focus": "92801",
"landmarks": ["Disneyland", "Angel Stadium", "Honda Center"],
"population": "346,000"
},
{
"city": "Irvine",
"state": "CA",
"zip_focus": "92602",
"landmarks": ["UC Irvine", "Spectrum Center", "Great Park"],
"population": "307,000"
}
]
}
}
]
```
*Note: This allows you to write Spintax like "Located near {Landmark}" if your engine logic supports picking a random landmark from the current city data.*
### D. Complete Frameworks (Article Templates)
*Collection:* `article_templates`
*Concept:* A "Framework" is a blueprint that tells the engine which Fragments to assemble and in what order.
**Example: "The Authority Framework"**
```json
[
{
"name": "Authority SEO Framework",
"structure_json": [
"intro_hook",
"image_hero",
"pillar_1_keyword",
"ad_block_mid",
"pillar_2_uniqueness",
"pillar_3_relevance",
"faq_section",
"cta_footer"
],
"description": "Standard high-ranking structure for local service pages."
},
{
"name": "Quick Lead Magnet Framework",
"structure_json": [
"intro_hook_aggressive",
"form_embed_top",
"social_proof_slider",
"pillar_benefits_bullets",
"cta_sticky_bottom"
],
"description": "Short form page designed for PPC traffic."
}
]
```
*To use this, you would ensure you have `content_fragments` matching these types (e.g., `intro_hook`, `pillar_1_keyword`) available in the library.*
---
## ⚙️ 3. Managing Your Data (CRUD)
### How to Import
1. Navigate to the Collection in Directus (e.g., "Geo Intelligence").
2. Look for the **Import / Export** option in the right sidebar (often a box arrow icon).
3. Select **Import**.
4. Upload your `.json` file.
5. Click **Start Import**. The system will notify you of success or errors.
### How to Edit
1. Click on any row in the Collection list.
2. Edit the fields directly in the form (e.g., fix a typo in `final_title_text`).
3. Click the **Checkmark** (Save) in the top right.
### How to Delete
1. **Single:** Click the item, then click the **Trash Can** (Delete) icon in the toolbar.
2. **Bulk:** Select multiple checkboxes on the left side of the list view. A red "Delete" button will appear in the header. Click specific items or "Select All" to wipe a test batch.
### How to Export (Backup)
1. Using the same **Import / Export** menu, select **Export**.
2. Choose **JSON**.
3. Dowload the file. Use this to backup your "Frameworks" or "Geo Clusters" before making bulk changes.
---
## 🚀 4. Workflow: Importing a Complete Campaign Strategy
If you want to move a strategy from one environment (e.g., Test) to another (e.g., Prod), follow this order:
1. **Step 1: Import Avatars** (`avatar_intelligence`) - Defines WHO you are targeting.
2. **Step 2: Import Geo Data** (`geo_intelligence`) - Defines WHERE you are targeting.
3. **Step 3: Import Fragments** (`content_fragments`) - Imports the raw text blocks.
4. **Step 4: Import Templates** (`article_templates`) - Defines the structure (HOW to assemble Fragments).
5. **Step 5: Create Campaign** (`campaign_masters`) - Link the Template, Geo Cluster, and Avatar together to start generating.

62
extra-schema-updates.sql Normal file
View File

@@ -0,0 +1,62 @@
-- Add Schema JSON fields for SEO
ALTER TABLE posts ADD COLUMN IF NOT EXISTS schema_json JSONB;
ALTER TABLE pages ADD COLUMN IF NOT EXISTS schema_json JSONB;
ALTER TABLE generated_articles
ADD COLUMN IF NOT EXISTS schema_json JSONB;
-- Add Word Count Goal to Campaign
ALTER TABLE campaign_masters
ADD COLUMN IF NOT EXISTS target_word_count INTEGER DEFAULT 1500;
-- Create Link Targets table for Internal Linking Engine
CREATE TABLE IF NOT EXISTS link_targets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid (),
site VARCHAR(255),
target_url VARCHAR(255),
target_post UUID,
anchor_text VARCHAR(255),
anchor_variations JSONB,
priority INTEGER DEFAULT 5,
is_active BOOLEAN DEFAULT TRUE,
is_hub BOOLEAN DEFAULT FALSE,
max_per_article INTEGER DEFAULT 2,
date_created TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
-- Attempt to improve Admin UI Inputs via Directus Meta Tables
-- (This assumes standard Directus 10 schema structure)
-- Make 'site' a dropdown
UPDATE directus_fields
SET
interface = 'select-dropdown'
WHERE
field = 'site'
AND collection IN (
'posts',
'pages',
'campaign_masters',
'generation_jobs'
);
-- Make 'campaign' a dropdown
UPDATE directus_fields
SET
interface = 'select-dropdown-m2o'
WHERE
field = 'campaign'
AND collection IN (
'generated_articles',
'headline_inventory',
'content_fragments'
);
-- Make 'status' a color badge dropdown
UPDATE directus_fields
SET
interface = 'select-dropdown',
options = '{"choices":[{"text":"Published","value":"published","color":"#2ECDA7"},{"text":"Draft","value":"draft","color":"#D3D3D3"}]}'
WHERE
field = 'status'
AND collection IN ('posts', 'pages');

View File

@@ -79,15 +79,15 @@ export default function ContentFactoryDashboard() {
{/* Header Actions */}
<div className="flex justify-between items-center">
<div>
<h2 className="text-xl font-bold text-white">Production Overview</h2>
<p className="text-slate-400">Monitoring Content Velocity & Integrity</p>
<h2 className="text-xl font-bold text-white">Tactical Command Center</h2>
<p className="text-slate-400">Shields (SEO Defense) & Weapons (Content Offense) Status</p>
</div>
<div className="flex gap-4">
<Button variant="outline" className="text-slate-200 border-slate-700 hover:bg-slate-800" onClick={() => window.open(`${DIRECTUS_ADMIN_URL}/content/posts`, '_blank')}>
Manage Articles (Backend)
Manage Arsenal (Posts)
</Button>
<Button className="bg-blue-600 hover:bg-blue-700" onClick={() => window.open(`${DIRECTUS_ADMIN_URL}`, '_blank')}>
Open Directus Admin
Open HQ (Directus)
</Button>
</div>
</div>
@@ -96,7 +96,7 @@ export default function ContentFactoryDashboard() {
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="bg-slate-900 border-slate-800">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-400">Total Articles</CardTitle>
<CardTitle className="text-sm font-medium text-slate-400">Total Units (Articles)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{stats.total}</div>
@@ -104,7 +104,7 @@ export default function ContentFactoryDashboard() {
</Card>
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-purple-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-400">Ghost (Staged)</CardTitle>
<CardTitle className="text-sm font-medium text-slate-400">Stealth (Ghost)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{stats.ghost}</div>
@@ -112,7 +112,7 @@ export default function ContentFactoryDashboard() {
</Card>
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-green-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-400">Indexed (Live)</CardTitle>
<CardTitle className="text-sm font-medium text-slate-400">Deployed (Live)</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{stats.indexed}</div>
@@ -120,7 +120,7 @@ export default function ContentFactoryDashboard() {
</Card>
<Card className="bg-slate-900 border-slate-800 border-l-4 border-l-blue-500">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-slate-400">Active Jobs</CardTitle>
<CardTitle className="text-sm font-medium text-slate-400">Active Operations</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-white">{queues.filter(q => q.status === 'Processing').length}</div>

View File

@@ -8,6 +8,7 @@ interface Props {
globals?: Globals;
navigation?: Navigation[];
canonical?: string;
schemaJson?: Record<string, any>;
}
const {
@@ -16,7 +17,8 @@ const {
image,
globals,
navigation = [],
canonical
canonical,
schemaJson
} = Astro.props;
const siteUrl = Astro.url.origin;
@@ -63,6 +65,11 @@ const ogImage = image || globals?.logo || '';
<!-- Head Scripts -->
{globals?.scripts_head && <Fragment set:html={globals.scripts_head} />}
<!-- JSON-LD Schema -->
{schemaJson && (
<script type="application/ld+json" set:html={JSON.stringify(schemaJson)} />
)}
<!-- Styles -->
<style is:global>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap');

View File

@@ -218,6 +218,34 @@ export async function fetchGeneratedArticles(
}
}
/**
* Fetch a single generated article by slug
*/
export async function fetchGeneratedArticleBySlug(
slug: string,
siteId: string
): Promise<any | null> {
try {
const articles = await directus.request(
readItems('generated_articles', {
filter: {
_and: [
{ slug: { _eq: slug } },
{ site: { _eq: siteId } },
{ is_published: { _eq: true } }
]
},
limit: 1,
fields: ['*']
})
);
return articles?.[0] || null;
} catch (err) {
console.error('Error fetching generated article:', err);
return null;
}
}
/**
* Fetch SEO campaigns
*/

View File

@@ -1,6 +1,6 @@
---
import BaseLayout from '../layouts/BaseLayout.astro';
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation } from '../lib/directus/fetchers';
import { fetchPageByPermalink, fetchSiteGlobals, fetchNavigation, fetchGeneratedArticleBySlug } from '../lib/directus/fetchers';
import BlockHero from '../components/blocks/BlockHero.astro';
import BlockRichText from '../components/blocks/BlockRichText.astro';
import BlockColumns from '../components/blocks/BlockColumns.astro';
@@ -13,16 +13,18 @@ import BlockPosts from '../components/blocks/BlockPosts.astro';
import BlockForm from '../components/blocks/BlockForm.astro';
const siteId = Astro.locals.siteId;
const permalink = '/' + (Astro.params.slug || '');
const slug = Astro.params.slug || '';
const permalink = '/' + slug;
// Fetch data
const [globals, navigation, page] = await Promise.all([
const [globals, navigation, page, generatedArticle] = await Promise.all([
siteId ? fetchSiteGlobals(siteId) : null,
siteId ? fetchNavigation(siteId) : [],
siteId ? fetchPageByPermalink(permalink, siteId) : null
siteId ? fetchPageByPermalink(permalink, siteId) : null,
siteId ? fetchGeneratedArticleBySlug(slug, siteId) : null
]);
if (!page) {
if (!page && !generatedArticle) {
return Astro.redirect('/404');
}
@@ -39,20 +41,31 @@ const blockComponents: Record<string, any> = {
block_posts: BlockPosts,
block_form: BlockForm,
};
const activeEntity = page || generatedArticle;
const isGenerated = !!generatedArticle && !page;
---
<BaseLayout
title={page.seo_title || page.title}
description={page.seo_description}
image={page.seo_image}
title={activeEntity.seo_title || activeEntity.meta_title || activeEntity.title || activeEntity.headline}
description={activeEntity.seo_description || activeEntity.meta_description}
image={activeEntity.seo_image || (activeEntity.featured_image_filename ? `/assets/content/${activeEntity.featured_image_filename}` : undefined)}
globals={globals}
navigation={navigation}
schemaJson={activeEntity.schema_json}
>
{page.blocks?.map((block) => {
const Component = blockComponents[block.collection];
if (Component) {
return <Component {...block.item} />;
}
return null;
})}
{isGenerated ? (
<article class="prose dark:prose-invert max-w-none container mx-auto px-4 py-8">
<h1 class="text-4xl font-bold mb-6">{activeEntity.headline}</h1>
<div set:html={activeEntity.full_html_body} />
</article>
) : (
page.blocks?.map((block) => {
const Component = blockComponents[block.collection];
if (Component) {
return <Component {...block.item} />;
}
return null;
})
)}
</BaseLayout>

View File

@@ -213,7 +213,26 @@ export const POST: APIRoute = async ({ request, locals }) => {
: undefined
});
// Create article record with featured image
// Generate JSON-LD Schema
const schemaJson = {
"@context": "https://schema.org",
"@type": "Article",
"headline": processedHeadline,
"description": metaDescription,
"wordCount": wordCount,
"datePublished": new Date().toISOString(),
"author": {
"@type": "Organization",
"name": locationVars.state ? `${locationVars.state} Services` : "Local Service Provider"
},
"image": featuredImage.filename ? `/assets/content/${featuredImage.filename}` : undefined
};
// Check Word Count Goal
const targetWordCount = campaign.target_word_count || 1500;
const wordCountStatus = wordCount >= targetWordCount ? 'optimal' : 'under_target';
// Create article record with featured image and schema
const article = await directus.request(
createItem('generated_articles', {
site: siteId || campaign.site,
@@ -223,13 +242,15 @@ export const POST: APIRoute = async ({ request, locals }) => {
meta_description: metaDescription,
full_html_body: fullHtmlBody,
word_count: wordCount,
word_count_status: wordCountStatus,
is_published: false,
location_city: locationVars.city || null,
location_county: locationVars.county || null,
location_state: locationVars.state || null,
featured_image_svg: featuredImage.svg,
featured_image_filename: featuredImage.filename,
featured_image_alt: featuredImage.alt
featured_image_alt: featuredImage.alt,
schema_json: schemaJson
})
);

View File

@@ -102,6 +102,7 @@ export interface CampaignMaster {
location_target?: string;
batch_count?: number;
status: 'active' | 'paused' | 'completed';
target_word_count?: number;
date_created?: string;
}
@@ -222,6 +223,7 @@ export interface GeneratedArticle {
meta_desc?: string;
is_published?: boolean;
sync_status?: string;
schema_json?: Record<string, any>;
date_created?: string;
}
@@ -262,4 +264,18 @@ export interface SparkSchema {
newsletter_subscribers: NewsletterSubscriber[];
forms: Form[];
form_submissions: FormSubmission[];
link_targets: LinkTarget[];
}
export interface LinkTarget {
id: string;
site: string;
target_url?: string;
target_post?: string;
anchor_text: string;
anchor_variations?: string[];
priority?: number;
is_active?: boolean;
is_hub?: boolean;
max_per_article?: number;
}