# Phase 1b: Phynite Analytics for WordPress

**Duration:** Weeks 1-4 (parallel with Phase 1a)
**Goal:** WordPress plugin that supplements crawl data with WordPress-native information

---

## Overview

"Phynite Analytics for WordPress" is a WordPress plugin that syncs content data to Phynite Analytics. It provides data that web crawling cannot access:

- True publish/update dates from WordPress
- Categories and tags (taxonomy)
- SEO plugin data (Yoast, RankMath, AIOSEO focus keywords and scores)
- Recipe plugin data (WPRM, Tasty, Create)
- Internal links parsed from post content (source of truth)
- Real-time publish/update events

**Philosophy:**
- Optional enhancement, not required
- Simple: API key and done
- Data conduit now, dashboard features later

---

## Architecture

```
┌─────────────────────┐         ┌─────────────────────┐         ┌──────────────┐
│     WordPress       │         │       Stewie        │         │   BigQuery   │
│                     │  HTTPS  │                     │         │              │
│  Phynite Analytics  │────────▶│  /api/wp-connector  │────────▶│ content_*    │
│  for WordPress      │         │                     │         │              │
└─────────────────────┘         └─────────────────────┘         └──────────────┘
        │                               │
        │ Events:                       │ Validates:
        │ • publish                     │ • API key
        │ • update                      │ • Rate limits
        │ • bulk sync                   │ • Payload
        │                               │
        │                               │
┌───────▼─────────┐             ┌───────▼─────────┐
│   Sidney        │             │   Sidney        │
│                 │             │                 │
│ • API key UI    │             │ • Enhanced      │
│ • Download link │             │   content view  │
│ • Status display│             │                 │
└─────────────────┘             └─────────────────┘
```

---

## Data Collected

### Post Data

| Field | Source | Purpose |
|-------|--------|---------|
| `url` | `get_permalink()` | Match with existing content data |
| `title` | `get_the_title()` | Display |
| `post_type` | `$post->post_type` | Filter (post, page, recipe, etc.) |
| `post_status` | `$post->post_status` | Only sync published |
| `published_at` | `$post->post_date_gmt` | True publish date |
| `modified_at` | `$post->post_modified_gmt` | Track updates |
| `author_id` | `$post->post_author` | Multi-author sites |
| `author_name` | `get_the_author_meta()` | Display |
| `excerpt` | `get_the_excerpt()` | Content preview |
| `word_count` | `str_word_count(strip_tags())` | Content depth |
| `categories` | `get_the_category()` | Taxonomy |
| `tags` | `get_the_tags()` | Taxonomy |
| `featured_image` | `get_the_post_thumbnail_url()` | Display |

### SEO Plugin Data

| Field | Yoast | RankMath | AIOSEO |
|-------|-------|----------|--------|
| `seo_title` | `_yoast_wpseo_title` | `rank_math_title` | `_aioseo_title` |
| `seo_description` | `_yoast_wpseo_metadesc` | `rank_math_description` | `_aioseo_description` |
| `focus_keyword` | `_yoast_wpseo_focuskw` | `rank_math_focus_keyword` | `_aioseo_keywords` |
| `seo_score` | `_yoast_wpseo_linkdex` | `rank_math_seo_score` | `_aioseo_seo_score` |
| `readability_score` | `_yoast_wpseo_content_score` | — | — |
| `canonical_url` | `_yoast_wpseo_canonical` | `rank_math_canonical_url` | `_aioseo_canonical_url` |
| `robots` | `_yoast_wpseo_meta-robots-noindex` | `rank_math_robots` | `_aioseo_robots_noindex` |

### Recipe Plugin Data

| Field | WPRM | Tasty | Create |
|-------|------|-------|--------|
| `has_recipe` | Check if recipe exists | Check if recipe exists | Check if recipe exists |
| `recipe_name` | `wprm_recipe_name` | `tasty_recipes_name` | `mv_create_recipe_name` |
| `prep_time` | `wprm_prep_time` | `tasty_recipes_prep_time` | — |
| `cook_time` | `wprm_cook_time` | `tasty_recipes_cook_time` | — |
| `total_time` | `wprm_total_time` | `tasty_recipes_total_time` | — |
| `servings` | `wprm_servings` | `tasty_recipes_yield` | — |
| `ingredients` | `wprm_ingredients` | `tasty_recipes_ingredients` | — |
| `cuisine` | `wprm_cuisine` | — | — |
| `course` | `wprm_course` | — | — |

### Internal Links

Parsed from `post_content`:

```php
[
  {
    "target_url": "/turkey-brine/",
    "anchor_text": "my easy turkey brine",
    "context": "Before roasting, I always use my easy turkey brine for..."
  },
  {
    "target_url": "/thanksgiving-tips/",
    "anchor_text": "Thanksgiving tips",
    "context": "Check out my other Thanksgiving tips for more ideas."
  }
]
```

---

## WordPress Plugin Structure

```
phynite-analytics-for-wordpress/
├── phynite-analytics.php              # Main plugin file
├── readme.txt                         # WordPress.org readme
├── uninstall.php                      # Cleanup on uninstall
├── assets/
│   ├── css/
│   │   └── admin.css                  # Admin styles
│   ├── js/
│   │   └── admin.js                   # Admin scripts (sync button)
│   └── images/
│       └── phynite-logo.svg           # Logo
├── includes/
│   ├── class-phynite-activator.php    # Activation hooks
│   ├── class-phynite-deactivator.php  # Deactivation hooks
│   ├── class-phynite-admin.php        # Admin settings page
│   ├── class-phynite-api-client.php   # API communication
│   ├── class-phynite-extractor.php    # Data extraction base
│   ├── class-phynite-sync.php         # Sync orchestration
│   └── extractors/
│       ├── class-post-extractor.php       # Core post data
│       ├── class-yoast-extractor.php      # Yoast SEO
│       ├── class-rankmath-extractor.php   # RankMath
│       ├── class-aioseo-extractor.php     # AIOSEO
│       ├── class-wprm-extractor.php       # WP Recipe Maker
│       ├── class-tasty-extractor.php      # Tasty Recipes
│       ├── class-create-extractor.php     # Mediavine Create
│       └── class-links-extractor.php      # Internal links
├── templates/
│   └── admin-page.php                 # Settings page template
└── languages/
    └── phynite-analytics.pot          # Translation template
```

---

## Plugin Implementation

### Main Plugin File

```php
<?php
/**
 * Plugin Name: Phynite Analytics for WordPress
 * Plugin URI: https://phyniteanalytics.com/wordpress
 * Description: Connect your WordPress site to Phynite Analytics for AI-powered content insights and recommendations.
 * Version: 1.0.0
 * Author: Phynite
 * Author URI: https://phyniteanalytics.com
 * License: GPL v2 or later
 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
 * Text Domain: phynite-analytics
 * Domain Path: /languages
 * Requires at least: 6.0
 * Requires PHP: 7.4
 */

// Prevent direct access
if (!defined('ABSPATH')) {
    exit;
}

// Plugin constants
define('PHYNITE_VERSION', '1.0.0');
define('PHYNITE_PLUGIN_DIR', plugin_dir_path(__FILE__));
define('PHYNITE_PLUGIN_URL', plugin_dir_url(__FILE__));
define('PHYNITE_PLUGIN_BASENAME', plugin_basename(__FILE__));
define('PHYNITE_API_URL', 'https://api.phyniteanalytics.com');

// Autoloader
spl_autoload_register(function ($class) {
    $prefix = 'Phynite_';
    if (strpos($class, $prefix) !== 0) {
        return;
    }
    
    $relative_class = substr($class, strlen($prefix));
    $file = PHYNITE_PLUGIN_DIR . 'includes/class-phynite-' . 
            strtolower(str_replace('_', '-', $relative_class)) . '.php';
    
    if (file_exists($file)) {
        require $file;
    }
});

// Activation/Deactivation hooks
register_activation_hook(__FILE__, ['Phynite_Activator', 'activate']);
register_deactivation_hook(__FILE__, ['Phynite_Deactivator', 'deactivate']);

// Initialize plugin
add_action('plugins_loaded', function () {
    // Load text domain
    load_plugin_textdomain('phynite-analytics', false, dirname(PHYNITE_PLUGIN_BASENAME) . '/languages');
    
    // Initialize admin
    if (is_admin()) {
        new Phynite_Admin();
    }
    
    // Initialize sync hooks (always, for publish/update events)
    new Phynite_Sync();
});
```

### API Client

```php
<?php
/**
 * Handles communication with Phynite Analytics API
 */
class Phynite_API_Client {
    
    private string $api_key;
    private string $api_url;
    
    public function __construct() {
        $this->api_key = get_option('phynite_api_key', '');
        $this->api_url = PHYNITE_API_URL;
    }
    
    /**
     * Check if API key is configured
     */
    public function is_configured(): bool {
        return !empty($this->api_key);
    }
    
    /**
     * Validate API key with server
     */
    public function validate_api_key(): array {
        $response = $this->request('GET', '/wp-connector/validate');
        return $response;
    }
    
    /**
     * Get connection status
     */
    public function get_status(): array {
        $response = $this->request('GET', '/wp-connector/status');
        return $response;
    }
    
    /**
     * Sync posts to Phynite
     */
    public function sync_posts(array $posts): array {
        $response = $this->request('POST', '/wp-connector/sync', [
            'posts' => $posts,
            'site_url' => home_url(),
            'site_name' => get_bloginfo('name'),
            'wp_version' => get_bloginfo('version'),
            'plugin_version' => PHYNITE_VERSION,
            'timezone' => wp_timezone_string(),
        ]);
        return $response;
    }
    
    /**
     * Notify of single post update
     */
    public function sync_single_post(array $post): array {
        $response = $this->request('POST', '/wp-connector/sync-single', [
            'post' => $post,
            'site_url' => home_url(),
        ]);
        return $response;
    }
    
    /**
     * Make API request
     */
    private function request(string $method, string $endpoint, array $body = []): array {
        $url = $this->api_url . $endpoint;
        
        $args = [
            'method' => $method,
            'timeout' => 30,
            'headers' => [
                'Authorization' => 'Bearer ' . $this->api_key,
                'Content-Type' => 'application/json',
                'X-Plugin-Version' => PHYNITE_VERSION,
            ],
        ];
        
        if (!empty($body)) {
            $args['body'] = json_encode($body);
        }
        
        $response = wp_remote_request($url, $args);
        
        if (is_wp_error($response)) {
            return [
                'success' => false,
                'error' => $response->get_error_message(),
            ];
        }
        
        $status_code = wp_remote_retrieve_response_code($response);
        $body = json_decode(wp_remote_retrieve_body($response), true);
        
        if ($status_code >= 400) {
            return [
                'success' => false,
                'error' => $body['message'] ?? 'API request failed',
                'status_code' => $status_code,
            ];
        }
        
        return [
            'success' => true,
            'data' => $body,
        ];
    }
}
```

### Post Extractor

```php
<?php
/**
 * Extracts core post data from WordPress
 */
class Phynite_Post_Extractor {
    
    /**
     * Extract data from a single post
     */
    public function extract(WP_Post $post): array {
        $content = $post->post_content;
        $plain_content = wp_strip_all_tags($content);
        
        return [
            // Identifiers
            'wp_post_id' => $post->ID,
            'url' => get_permalink($post),
            'slug' => $post->post_name,
            
            // Content
            'title' => get_the_title($post),
            'excerpt' => get_the_excerpt($post),
            'word_count' => str_word_count($plain_content),
            
            // Type and status
            'post_type' => $post->post_type,
            'post_status' => $post->post_status,
            
            // Dates (GMT for consistency)
            'published_at' => $post->post_date_gmt,
            'modified_at' => $post->post_modified_gmt,
            
            // Author
            'author_id' => (int) $post->post_author,
            'author_name' => get_the_author_meta('display_name', $post->post_author),
            
            // Taxonomy
            'categories' => $this->get_categories($post->ID),
            'tags' => $this->get_tags($post->ID),
            
            // Media
            'featured_image' => get_the_post_thumbnail_url($post, 'full'),
            'image_count' => $this->count_images($content),
            
            // Structure
            'has_toc' => $this->has_table_of_contents($content),
            'heading_count' => $this->count_headings($content),
        ];
    }
    
    private function get_categories(int $post_id): array {
        $categories = get_the_category($post_id);
        return array_map(function ($cat) {
            return [
                'id' => $cat->term_id,
                'name' => $cat->name,
                'slug' => $cat->slug,
            ];
        }, $categories);
    }
    
    private function get_tags(int $post_id): array {
        $tags = get_the_tags($post_id);
        if (!$tags) {
            return [];
        }
        return array_map(function ($tag) {
            return [
                'id' => $tag->term_id,
                'name' => $tag->name,
                'slug' => $tag->slug,
            ];
        }, $tags);
    }
    
    private function count_images(string $content): int {
        preg_match_all('/<img[^>]+>/i', $content, $matches);
        return count($matches[0]);
    }
    
    private function has_table_of_contents(string $content): bool {
        // Check for common TOC patterns
        $toc_patterns = [
            'id="table-of-contents"',
            'class="toc"',
            'class="table-of-contents"',
            'id="toc"',
            '[toc]',
            '[table_of_contents]',
        ];
        
        foreach ($toc_patterns as $pattern) {
            if (stripos($content, $pattern) !== false) {
                return true;
            }
        }
        
        return false;
    }
    
    private function count_headings(string $content): array {
        $counts = ['h2' => 0, 'h3' => 0, 'h4' => 0];
        
        foreach ($counts as $tag => &$count) {
            preg_match_all('/<' . $tag . '[^>]*>/i', $content, $matches);
            $count = count($matches[0]);
        }
        
        return $counts;
    }
}
```

### Yoast Extractor

```php
<?php
/**
 * Extracts Yoast SEO data
 */
class Phynite_Yoast_Extractor {
    
    /**
     * Check if Yoast is active
     */
    public function is_active(): bool {
        return defined('WPSEO_VERSION') || class_exists('WPSEO_Meta');
    }
    
    /**
     * Extract Yoast data for a post
     */
    public function extract(int $post_id): ?array {
        if (!$this->is_active()) {
            return null;
        }
        
        return [
            'seo_plugin' => 'yoast',
            'seo_plugin_version' => defined('WPSEO_VERSION') ? WPSEO_VERSION : null,
            
            // Meta
            'seo_title' => get_post_meta($post_id, '_yoast_wpseo_title', true),
            'seo_description' => get_post_meta($post_id, '_yoast_wpseo_metadesc', true),
            
            // Focus keyword
            'focus_keyword' => get_post_meta($post_id, '_yoast_wpseo_focuskw', true),
            
            // Scores (0-100)
            'seo_score' => $this->normalize_score(
                get_post_meta($post_id, '_yoast_wpseo_linkdex', true)
            ),
            'readability_score' => $this->normalize_score(
                get_post_meta($post_id, '_yoast_wpseo_content_score', true)
            ),
            
            // Technical
            'canonical_url' => get_post_meta($post_id, '_yoast_wpseo_canonical', true),
            'is_noindex' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-noindex', true) === '1',
            'is_nofollow' => get_post_meta($post_id, '_yoast_wpseo_meta-robots-nofollow', true) === '1',
            
            // Schema
            'schema_article_type' => get_post_meta($post_id, '_yoast_wpseo_schema_article_type', true),
        ];
    }
    
    private function normalize_score($score): ?int {
        if (empty($score)) {
            return null;
        }
        return (int) $score;
    }
}
```

### RankMath Extractor

```php
<?php
/**
 * Extracts RankMath SEO data
 */
class Phynite_RankMath_Extractor {
    
    /**
     * Check if RankMath is active
     */
    public function is_active(): bool {
        return class_exists('RankMath') || defined('RANK_MATH_VERSION');
    }
    
    /**
     * Extract RankMath data for a post
     */
    public function extract(int $post_id): ?array {
        if (!$this->is_active()) {
            return null;
        }
        
        return [
            'seo_plugin' => 'rankmath',
            'seo_plugin_version' => defined('RANK_MATH_VERSION') ? RANK_MATH_VERSION : null,
            
            // Meta
            'seo_title' => get_post_meta($post_id, 'rank_math_title', true),
            'seo_description' => get_post_meta($post_id, 'rank_math_description', true),
            
            // Focus keyword (RankMath supports multiple)
            'focus_keyword' => get_post_meta($post_id, 'rank_math_focus_keyword', true),
            
            // Score (0-100)
            'seo_score' => (int) get_post_meta($post_id, 'rank_math_seo_score', true) ?: null,
            
            // Technical
            'canonical_url' => get_post_meta($post_id, 'rank_math_canonical_url', true),
            'robots' => get_post_meta($post_id, 'rank_math_robots', true),
            
            // Schema
            'schema_type' => get_post_meta($post_id, 'rank_math_rich_snippet', true),
        ];
    }
}
```

### AIOSEO Extractor

```php
<?php
/**
 * Extracts All in One SEO data
 */
class Phynite_AIOSEO_Extractor {
    
    /**
     * Check if AIOSEO is active
     */
    public function is_active(): bool {
        return defined('AIOSEO_VERSION') || class_exists('AIOSEO\\Plugin\\AIOSEO');
    }
    
    /**
     * Extract AIOSEO data for a post
     */
    public function extract(int $post_id): ?array {
        if (!$this->is_active()) {
            return null;
        }
        
        // AIOSEO stores data in its own table, but also has post meta
        global $wpdb;
        $table = $wpdb->prefix . 'aioseo_posts';
        
        $aioseo_data = $wpdb->get_row(
            $wpdb->prepare("SELECT * FROM $table WHERE post_id = %d", $post_id),
            ARRAY_A
        );
        
        if (!$aioseo_data) {
            return null;
        }
        
        return [
            'seo_plugin' => 'aioseo',
            'seo_plugin_version' => defined('AIOSEO_VERSION') ? AIOSEO_VERSION : null,
            
            // Meta
            'seo_title' => $aioseo_data['title'] ?? null,
            'seo_description' => $aioseo_data['description'] ?? null,
            
            // Focus keyword
            'focus_keyword' => $aioseo_data['keyphrases'] 
                ? json_decode($aioseo_data['keyphrases'], true)['focus']['keyphrase'] ?? null 
                : null,
            
            // Score
            'seo_score' => isset($aioseo_data['seo_score']) ? (int) $aioseo_data['seo_score'] : null,
            
            // Technical
            'canonical_url' => $aioseo_data['canonical_url'] ?? null,
            'is_noindex' => ($aioseo_data['robots_noindex'] ?? false) === '1',
        ];
    }
}
```

### WP Recipe Maker Extractor

```php
<?php
/**
 * Extracts WP Recipe Maker data
 */
class Phynite_WPRM_Extractor {
    
    /**
     * Check if WPRM is active
     */
    public function is_active(): bool {
        return class_exists('WPRM_Recipe_Manager') || defined('WPRM_VERSION');
    }
    
    /**
     * Extract recipe data for a post
     */
    public function extract(int $post_id): ?array {
        if (!$this->is_active()) {
            return null;
        }
        
        // Get recipes associated with this post
        $recipe_ids = get_post_meta($post_id, 'wprm_recipe_id', false);
        
        if (empty($recipe_ids)) {
            // Check if post content contains recipe shortcode
            $post = get_post($post_id);
            if (preg_match('/\[wprm-recipe id="(\d+)"\]/', $post->post_content, $matches)) {
                $recipe_ids = [$matches[1]];
            }
        }
        
        if (empty($recipe_ids)) {
            return null;
        }
        
        $recipes = [];
        foreach ($recipe_ids as $recipe_id) {
            $recipe = WPRM_Recipe_Manager::get_recipe($recipe_id);
            if (!$recipe) {
                continue;
            }
            
            $recipes[] = [
                'recipe_id' => $recipe_id,
                'recipe_plugin' => 'wprm',
                'name' => $recipe->name(),
                'summary' => $recipe->summary(),
                
                // Times (in minutes)
                'prep_time' => $recipe->prep_time(),
                'cook_time' => $recipe->cook_time(),
                'total_time' => $recipe->total_time(),
                
                // Servings
                'servings' => $recipe->servings(),
                'servings_unit' => $recipe->servings_unit(),
                
                // Taxonomy
                'cuisine' => $recipe->cuisine(),
                'course' => $recipe->course(),
                'difficulty' => $recipe->difficulty(),
                
                // Ingredients (count and list)
                'ingredient_count' => count($recipe->ingredients_flat()),
                'ingredients' => array_map(function ($ing) {
                    return [
                        'name' => $ing['name'] ?? '',
                        'amount' => $ing['amount'] ?? '',
                        'unit' => $ing['unit'] ?? '',
                    ];
                }, $recipe->ingredients_flat()),
                
                // Nutrition
                'calories' => $recipe->nutrition()['calories'] ?? null,
                
                // Rating
                'rating' => $recipe->rating(),
                'rating_count' => $recipe->rating_count(),
            ];
        }
        
        return [
            'has_recipe' => true,
            'recipe_plugin' => 'wprm',
            'recipe_count' => count($recipes),
            'recipes' => $recipes,
        ];
    }
}
```

### Tasty Recipes Extractor

```php
<?php
/**
 * Extracts Tasty Recipes data
 */
class Phynite_Tasty_Extractor {
    
    /**
     * Check if Tasty Recipes is active
     */
    public function is_active(): bool {
        return class_exists('Tasty_Recipes') || defined('TASTY_RECIPES_PLUGIN_VERSION');
    }
    
    /**
     * Extract recipe data for a post
     */
    public function extract(int $post_id): ?array {
        if (!$this->is_active()) {
            return null;
        }
        
        // Tasty Recipes stores recipes as custom post type
        $args = [
            'post_type' => 'tasty_recipe',
            'meta_query' => [
                [
                    'key' => 'tasty_recipes_parent_post_id',
                    'value' => $post_id,
                ],
            ],
            'posts_per_page' => -1,
        ];
        
        $recipe_query = new WP_Query($args);
        
        if (!$recipe_query->have_posts()) {
            return null;
        }
        
        $recipes = [];
        foreach ($recipe_query->posts as $recipe_post) {
            $recipe_id = $recipe_post->ID;
            
            $recipes[] = [
                'recipe_id' => $recipe_id,
                'recipe_plugin' => 'tasty',
                'name' => get_the_title($recipe_post),
                'summary' => get_post_meta($recipe_id, 'description', true),
                
                // Times
                'prep_time' => get_post_meta($recipe_id, 'prep_time', true),
                'cook_time' => get_post_meta($recipe_id, 'cook_time', true),
                'total_time' => get_post_meta($recipe_id, 'total_time', true),
                
                // Servings
                'servings' => get_post_meta($recipe_id, 'yield', true),
                
                // Category
                'category' => get_post_meta($recipe_id, 'category', true),
                
                // Rating
                'rating' => get_post_meta($recipe_id, 'rating', true),
            ];
        }
        
        return [
            'has_recipe' => true,
            'recipe_plugin' => 'tasty',
            'recipe_count' => count($recipes),
            'recipes' => $recipes,
        ];
    }
}
```

### Mediavine Create Extractor

```php
<?php
/**
 * Extracts Mediavine Create recipe data
 */
class Phynite_Create_Extractor {
    
    /**
     * Check if Create is active
     */
    public function is_active(): bool {
        return class_exists('Mediavine\\Create\\Plugin') || defined('MV_CREATE_VERSION');
    }
    
    /**
     * Extract recipe data for a post
     */
    public function extract(int $post_id): ?array {
        if (!$this->is_active()) {
            return null;
        }
        
        global $wpdb;
        $table = $wpdb->prefix . 'mv_creations';
        
        // Create stores creations with associated post IDs
        $creations = $wpdb->get_results(
            $wpdb->prepare(
                "SELECT * FROM $table WHERE associated_posts LIKE %s AND type = 'recipe'",
                '%"' . $post_id . '"%'
            ),
            ARRAY_A
        );
        
        if (empty($creations)) {
            return null;
        }
        
        $recipes = [];
        foreach ($creations as $creation) {
            $recipes[] = [
                'recipe_id' => $creation['id'],
                'recipe_plugin' => 'create',
                'name' => $creation['title'],
                'summary' => $creation['description'] ?? null,
                
                // Times stored in JSON
                'prep_time' => $creation['prep_time'] ?? null,
                'cook_time' => $creation['active_time'] ?? null,
                'total_time' => $creation['total_time'] ?? null,
                
                // Servings
                'servings' => $creation['yield'] ?? null,
            ];
        }
        
        return [
            'has_recipe' => true,
            'recipe_plugin' => 'create',
            'recipe_count' => count($recipes),
            'recipes' => $recipes,
        ];
    }
}
```

### Internal Links Extractor

```php
<?php
/**
 * Extracts internal links from post content
 */
class Phynite_Links_Extractor {
    
    private string $site_url;
    
    public function __construct() {
        $this->site_url = home_url();
    }
    
    /**
     * Extract internal links from post content
     */
    public function extract(int $post_id): array {
        $post = get_post($post_id);
        if (!$post) {
            return [];
        }
        
        $content = $post->post_content;
        $links = [];
        
        // Parse HTML to find all links
        $dom = new DOMDocument();
        
        // Suppress warnings for malformed HTML
        libxml_use_internal_errors(true);
        $dom->loadHTML('<?xml encoding="utf-8" ?>' . $content);
        libxml_clear_errors();
        
        $anchors = $dom->getElementsByTagName('a');
        
        foreach ($anchors as $anchor) {
            $href = $anchor->getAttribute('href');
            
            if (empty($href)) {
                continue;
            }
            
            // Check if internal link
            if (!$this->is_internal_link($href)) {
                continue;
            }
            
            // Get anchor text
            $anchor_text = trim($anchor->textContent);
            
            // Get surrounding context (parent paragraph or nearby text)
            $context = $this->get_link_context($anchor);
            
            // Normalize URL
            $normalized_url = $this->normalize_url($href);
            
            $links[] = [
                'target_url' => $normalized_url,
                'anchor_text' => $anchor_text,
                'context' => $context,
                'is_image_link' => $anchor->getElementsByTagName('img')->length > 0,
                'rel' => $anchor->getAttribute('rel'),
                'title' => $anchor->getAttribute('title'),
            ];
        }
        
        return [
            'internal_links' => $links,
            'internal_link_count' => count($links),
            'unique_internal_links' => count(array_unique(array_column($links, 'target_url'))),
        ];
    }
    
    /**
     * Check if URL is internal
     */
    private function is_internal_link(string $url): bool {
        // Relative URLs are internal
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            return true;
        }
        
        // Check if URL starts with site URL
        if (strpos($url, $this->site_url) === 0) {
            return true;
        }
        
        // Check for www/non-www variations
        $site_host = parse_url($this->site_url, PHP_URL_HOST);
        $url_host = parse_url($url, PHP_URL_HOST);
        
        if ($url_host) {
            // Remove www. prefix for comparison
            $site_host = preg_replace('/^www\./', '', $site_host);
            $url_host = preg_replace('/^www\./', '', $url_host);
            
            return $site_host === $url_host;
        }
        
        return false;
    }
    
    /**
     * Normalize URL to consistent format
     */
    private function normalize_url(string $url): string {
        // Convert to absolute URL if relative
        if (strpos($url, '/') === 0 && strpos($url, '//') !== 0) {
            $url = $this->site_url . $url;
        }
        
        // Parse and rebuild
        $parts = parse_url($url);
        
        $normalized = ($parts['path'] ?? '/');
        
        // Ensure trailing slash for consistency (if no extension)
        if (!preg_match('/\.[a-z0-9]+$/i', $normalized)) {
            $normalized = rtrim($normalized, '/') . '/';
        }
        
        return $normalized;
    }
    
    /**
     * Get context around the link
     */
    private function get_link_context(DOMElement $anchor): string {
        // Try to get parent paragraph
        $parent = $anchor->parentNode;
        
        while ($parent && $parent->nodeName !== 'p' && $parent->nodeName !== 'body') {
            $parent = $parent->parentNode;
        }
        
        if ($parent && $parent->nodeName === 'p') {
            $text = trim($parent->textContent);
            // Truncate if too long
            if (strlen($text) > 200) {
                $text = substr($text, 0, 200) . '...';
            }
            return $text;
        }
        
        return '';
    }
}
```

### Sync Orchestration

```php
<?php
/**
 * Handles sync orchestration and WordPress hooks
 */
class Phynite_Sync {
    
    private Phynite_API_Client $api_client;
    private array $extractors;
    
    public function __construct() {
        $this->api_client = new Phynite_API_Client();
        $this->init_extractors();
        $this->register_hooks();
    }
    
    private function init_extractors(): void {
        $this->extractors = [
            'post' => new Phynite_Post_Extractor(),
            'yoast' => new Phynite_Yoast_Extractor(),
            'rankmath' => new Phynite_RankMath_Extractor(),
            'aioseo' => new Phynite_AIOSEO_Extractor(),
            'wprm' => new Phynite_WPRM_Extractor(),
            'tasty' => new Phynite_Tasty_Extractor(),
            'create' => new Phynite_Create_Extractor(),
            'links' => new Phynite_Links_Extractor(),
        ];
    }
    
    private function register_hooks(): void {
        // Only register if API key is configured
        if (!$this->api_client->is_configured()) {
            return;
        }
        
        // Post publish/update hooks
        add_action('publish_post', [$this, 'on_post_publish'], 10, 2);
        add_action('publish_page', [$this, 'on_post_publish'], 10, 2);
        
        // Post update (already published)
        add_action('post_updated', [$this, 'on_post_update'], 10, 3);
        
        // AJAX handlers for manual sync
        add_action('wp_ajax_phynite_sync_all', [$this, 'ajax_sync_all']);
        add_action('wp_ajax_phynite_sync_single', [$this, 'ajax_sync_single']);
    }
    
    /**
     * Handle post publish
     */
    public function on_post_publish(int $post_id, WP_Post $post): void {
        // Skip if not a supported post type
        if (!in_array($post->post_type, $this->get_supported_post_types())) {
            return;
        }
        
        // Skip revisions
        if (wp_is_post_revision($post_id)) {
            return;
        }
        
        // Extract and sync
        $data = $this->extract_post_data($post);
        $this->api_client->sync_single_post($data);
    }
    
    /**
     * Handle post update
     */
    public function on_post_update(int $post_id, WP_Post $post_after, WP_Post $post_before): void {
        // Only sync if already published
        if ($post_after->post_status !== 'publish') {
            return;
        }
        
        // Skip if not a supported post type
        if (!in_array($post_after->post_type, $this->get_supported_post_types())) {
            return;
        }
        
        // Skip revisions
        if (wp_is_post_revision($post_id)) {
            return;
        }
        
        // Extract and sync
        $data = $this->extract_post_data($post_after);
        $this->api_client->sync_single_post($data);
    }
    
    /**
     * Extract all data for a post
     */
    public function extract_post_data(WP_Post $post): array {
        $data = [];
        
        // Core post data
        $data = array_merge($data, $this->extractors['post']->extract($post));
        
        // SEO data (only one will return data)
        foreach (['yoast', 'rankmath', 'aioseo'] as $seo_extractor) {
            $seo_data = $this->extractors[$seo_extractor]->extract($post->ID);
            if ($seo_data) {
                $data['seo'] = $seo_data;
                break;
            }
        }
        
        // Recipe data (only one will return data)
        foreach (['wprm', 'tasty', 'create'] as $recipe_extractor) {
            $recipe_data = $this->extractors[$recipe_extractor]->extract($post->ID);
            if ($recipe_data) {
                $data['recipe'] = $recipe_data;
                break;
            }
        }
        
        // Internal links
        $links_data = $this->extractors['links']->extract($post->ID);
        $data = array_merge($data, $links_data);
        
        // Metadata
        $data['extracted_at'] = gmdate('Y-m-d\TH:i:s\Z');
        $data['extractor_version'] = PHYNITE_VERSION;
        
        return $data;
    }
    
    /**
     * Bulk sync all posts
     */
    public function sync_all(): array {
        $args = [
            'post_type' => $this->get_supported_post_types(),
            'post_status' => 'publish',
            'posts_per_page' => -1,
            'orderby' => 'date',
            'order' => 'DESC',
        ];
        
        $query = new WP_Query($args);
        $posts_data = [];
        
        foreach ($query->posts as $post) {
            $posts_data[] = $this->extract_post_data($post);
        }
        
        // Send in batches
        $batch_size = 50;
        $batches = array_chunk($posts_data, $batch_size);
        $results = [];
        
        foreach ($batches as $batch) {
            $result = $this->api_client->sync_posts($batch);
            $results[] = $result;
            
            if (!$result['success']) {
                break;
            }
        }
        
        return [
            'total_posts' => count($posts_data),
            'batches_sent' => count($results),
            'success' => !in_array(false, array_column($results, 'success')),
        ];
    }
    
    /**
     * AJAX handler for full sync
     */
    public function ajax_sync_all(): void {
        check_ajax_referer('phynite_sync_nonce', 'nonce');
        
        if (!current_user_can('manage_options')) {
            wp_send_json_error(['message' => 'Unauthorized']);
        }
        
        $result = $this->sync_all();
        
        // Update last sync time
        update_option('phynite_last_sync', time());
        update_option('phynite_last_sync_count', $result['total_posts']);
        
        wp_send_json_success($result);
    }
    
    /**
     * AJAX handler for single post sync
     */
    public function ajax_sync_single(): void {
        check_ajax_referer('phynite_sync_nonce', 'nonce');
        
        if (!current_user_can('edit_posts')) {
            wp_send_json_error(['message' => 'Unauthorized']);
        }
        
        $post_id = (int) ($_POST['post_id'] ?? 0);
        if (!$post_id) {
            wp_send_json_error(['message' => 'Invalid post ID']);
        }
        
        $post = get_post($post_id);
        if (!$post) {
            wp_send_json_error(['message' => 'Post not found']);
        }
        
        $data = $this->extract_post_data($post);
        $result = $this->api_client->sync_single_post($data);
        
        wp_send_json_success($result);
    }
    
    /**
     * Get supported post types
     */
    private function get_supported_post_types(): array {
        $types = ['post', 'page'];
        
        // Add recipe post types if plugins are active
        if (post_type_exists('wprm_recipe')) {
            $types[] = 'wprm_recipe';
        }
        
        // Allow filtering
        return apply_filters('phynite_supported_post_types', $types);
    }
}
```

### Admin Settings Page

```php
<?php
/**
 * Admin settings page
 */
class Phynite_Admin {
    
    private Phynite_API_Client $api_client;
    
    public function __construct() {
        $this->api_client = new Phynite_API_Client();
        
        add_action('admin_menu', [$this, 'add_menu_page']);
        add_action('admin_init', [$this, 'register_settings']);
        add_action('admin_enqueue_scripts', [$this, 'enqueue_assets']);
    }
    
    public function add_menu_page(): void {
        add_options_page(
            __('Phynite Analytics', 'phynite-analytics'),
            __('Phynite Analytics', 'phynite-analytics'),
            'manage_options',
            'phynite-analytics',
            [$this, 'render_settings_page']
        );
    }
    
    public function register_settings(): void {
        register_setting('phynite_settings', 'phynite_api_key', [
            'type' => 'string',
            'sanitize_callback' => 'sanitize_text_field',
        ]);
    }
    
    public function enqueue_assets(string $hook): void {
        if ($hook !== 'settings_page_phynite-analytics') {
            return;
        }
        
        wp_enqueue_style(
            'phynite-admin',
            PHYNITE_PLUGIN_URL . 'assets/css/admin.css',
            [],
            PHYNITE_VERSION
        );
        
        wp_enqueue_script(
            'phynite-admin',
            PHYNITE_PLUGIN_URL . 'assets/js/admin.js',
            ['jquery'],
            PHYNITE_VERSION,
            true
        );
        
        wp_localize_script('phynite-admin', 'phyniteAdmin', [
            'ajaxUrl' => admin_url('admin-ajax.php'),
            'nonce' => wp_create_nonce('phynite_sync_nonce'),
            'strings' => [
                'syncing' => __('Syncing...', 'phynite-analytics'),
                'syncComplete' => __('Sync complete!', 'phynite-analytics'),
                'syncError' => __('Sync failed. Please try again.', 'phynite-analytics'),
            ],
        ]);
    }
    
    public function render_settings_page(): void {
        $api_key = get_option('phynite_api_key', '');
        $is_connected = false;
        $connection_status = null;
        
        if (!empty($api_key)) {
            $status_response = $this->api_client->get_status();
            $is_connected = $status_response['success'] ?? false;
            $connection_status = $status_response['data'] ?? null;
        }
        
        $last_sync = get_option('phynite_last_sync');
        $last_sync_count = get_option('phynite_last_sync_count', 0);
        
        include PHYNITE_PLUGIN_DIR . 'templates/admin-page.php';
    }
}
```

### Admin Page Template

```php
<?php
// templates/admin-page.php
if (!defined('ABSPATH')) {
    exit;
}
?>

<div class="wrap phynite-settings">
    <h1>
        <img src="<?php echo esc_url(PHYNITE_PLUGIN_URL . 'assets/images/phynite-logo.svg'); ?>" 
             alt="Phynite" 
             class="phynite-logo">
        <?php esc_html_e('Phynite Analytics', 'phynite-analytics'); ?>
    </h1>
    
    <div class="phynite-settings-container">
        <!-- Connection Status -->
        <div class="phynite-card">
            <h2><?php esc_html_e('Connection Status', 'phynite-analytics'); ?></h2>
            
            <?php if ($is_connected): ?>
                <div class="phynite-status phynite-status-connected">
                    <span class="dashicons dashicons-yes-alt"></span>
                    <?php esc_html_e('Connected', 'phynite-analytics'); ?>
                </div>
                
                <?php if ($connection_status): ?>
                    <p class="description">
                        <?php 
                        printf(
                            esc_html__('Connected to %s', 'phynite-analytics'),
                            '<strong>' . esc_html($connection_status['site_name'] ?? 'your site') . '</strong>'
                        ); 
                        ?>
                    </p>
                <?php endif; ?>
                
                <?php if ($last_sync): ?>
                    <p class="description">
                        <?php 
                        printf(
                            esc_html__('Last sync: %1$s (%2$d posts)', 'phynite-analytics'),
                            human_time_diff($last_sync, time()) . ' ago',
                            $last_sync_count
                        ); 
                        ?>
                    </p>
                <?php endif; ?>
                
            <?php elseif (!empty($api_key)): ?>
                <div class="phynite-status phynite-status-error">
                    <span class="dashicons dashicons-warning"></span>
                    <?php esc_html_e('Connection failed', 'phynite-analytics'); ?>
                </div>
                <p class="description">
                    <?php esc_html_e('Please check your API key and try again.', 'phynite-analytics'); ?>
                </p>
            <?php else: ?>
                <div class="phynite-status phynite-status-disconnected">
                    <span class="dashicons dashicons-minus"></span>
                    <?php esc_html_e('Not connected', 'phynite-analytics'); ?>
                </div>
                <p class="description">
                    <?php esc_html_e('Enter your API key below to connect.', 'phynite-analytics'); ?>
                </p>
            <?php endif; ?>
        </div>
        
        <!-- API Key Settings -->
        <div class="phynite-card">
            <h2><?php esc_html_e('API Key', 'phynite-analytics'); ?></h2>
            
            <form method="post" action="options.php">
                <?php settings_fields('phynite_settings'); ?>
                
                <table class="form-table">
                    <tr>
                        <th scope="row">
                            <label for="phynite_api_key">
                                <?php esc_html_e('API Key', 'phynite-analytics'); ?>
                            </label>
                        </th>
                        <td>
                            <input type="password" 
                                   id="phynite_api_key" 
                                   name="phynite_api_key" 
                                   value="<?php echo esc_attr($api_key); ?>" 
                                   class="regular-text"
                                   autocomplete="off">
                            <button type="button" class="button phynite-toggle-key">
                                <span class="dashicons dashicons-visibility"></span>
                            </button>
                            <p class="description">
                                <?php 
                                printf(
                                    esc_html__('Get your API key from %s', 'phynite-analytics'),
                                    '<a href="https://app.phyniteanalytics.com/settings/wordpress" target="_blank">Phynite Analytics</a>'
                                ); 
                                ?>
                            </p>
                        </td>
                    </tr>
                </table>
                
                <?php submit_button(__('Save API Key', 'phynite-analytics')); ?>
            </form>
        </div>
        
        <!-- Sync Controls -->
        <?php if ($is_connected): ?>
        <div class="phynite-card">
            <h2><?php esc_html_e('Sync', 'phynite-analytics'); ?></h2>
            
            <p class="description">
                <?php esc_html_e('Your content syncs automatically when you publish or update posts. Use the button below to sync all existing content.', 'phynite-analytics'); ?>
            </p>
            
            <p>
                <button type="button" class="button button-primary" id="phynite-sync-all">
                    <span class="dashicons dashicons-update"></span>
                    <?php esc_html_e('Sync All Content', 'phynite-analytics'); ?>
                </button>
                <span class="phynite-sync-status"></span>
            </p>
        </div>
        <?php endif; ?>
        
        <!-- Detected Plugins -->
        <div class="phynite-card">
            <h2><?php esc_html_e('Detected Plugins', 'phynite-analytics'); ?></h2>
            
            <table class="widefat">
                <thead>
                    <tr>
                        <th><?php esc_html_e('Plugin', 'phynite-analytics'); ?></th>
                        <th><?php esc_html_e('Type', 'phynite-analytics'); ?></th>
                        <th><?php esc_html_e('Status', 'phynite-analytics'); ?></th>
                    </tr>
                </thead>
                <tbody>
                    <?php 
                    $plugins = [
                        ['Yoast SEO', 'SEO', defined('WPSEO_VERSION')],
                        ['RankMath', 'SEO', defined('RANK_MATH_VERSION')],
                        ['All in One SEO', 'SEO', defined('AIOSEO_VERSION')],
                        ['WP Recipe Maker', 'Recipe', defined('WPRM_VERSION')],
                        ['Tasty Recipes', 'Recipe', defined('TASTY_RECIPES_PLUGIN_VERSION')],
                        ['Mediavine Create', 'Recipe', defined('MV_CREATE_VERSION')],
                    ];
                    
                    foreach ($plugins as $plugin):
                        list($name, $type, $active) = $plugin;
                    ?>
                    <tr>
                        <td><?php echo esc_html($name); ?></td>
                        <td><?php echo esc_html($type); ?></td>
                        <td>
                            <?php if ($active): ?>
                                <span class="phynite-badge phynite-badge-success">
                                    <?php esc_html_e('Detected', 'phynite-analytics'); ?>
                                </span>
                            <?php else: ?>
                                <span class="phynite-badge phynite-badge-muted">
                                    <?php esc_html_e('Not installed', 'phynite-analytics'); ?>
                                </span>
                            <?php endif; ?>
                        </td>
                    </tr>
                    <?php endforeach; ?>
                </tbody>
            </table>
        </div>
    </div>
</div>
```

### Admin JavaScript

```javascript
// assets/js/admin.js
(function($) {
    'use strict';
    
    // Toggle API key visibility
    $('.phynite-toggle-key').on('click', function() {
        const input = $('#phynite_api_key');
        const icon = $(this).find('.dashicons');
        
        if (input.attr('type') === 'password') {
            input.attr('type', 'text');
            icon.removeClass('dashicons-visibility').addClass('dashicons-hidden');
        } else {
            input.attr('type', 'password');
            icon.removeClass('dashicons-hidden').addClass('dashicons-visibility');
        }
    });
    
    // Sync all content
    $('#phynite-sync-all').on('click', function() {
        const button = $(this);
        const status = $('.phynite-sync-status');
        
        button.prop('disabled', true);
        button.find('.dashicons').addClass('phynite-spin');
        status.text(phyniteAdmin.strings.syncing);
        
        $.ajax({
            url: phyniteAdmin.ajaxUrl,
            type: 'POST',
            data: {
                action: 'phynite_sync_all',
                nonce: phyniteAdmin.nonce
            },
            success: function(response) {
                if (response.success) {
                    status.html(
                        '<span class="dashicons dashicons-yes" style="color: green;"></span> ' +
                        phyniteAdmin.strings.syncComplete + ' ' +
                        '(' + response.data.total_posts + ' posts)'
                    );
                } else {
                    status.html(
                        '<span class="dashicons dashicons-no" style="color: red;"></span> ' +
                        (response.data.message || phyniteAdmin.strings.syncError)
                    );
                }
            },
            error: function() {
                status.html(
                    '<span class="dashicons dashicons-no" style="color: red;"></span> ' +
                    phyniteAdmin.strings.syncError
                );
            },
            complete: function() {
                button.prop('disabled', false);
                button.find('.dashicons').removeClass('phynite-spin');
            }
        });
    });
    
})(jQuery);
```

### Admin CSS

```css
/* assets/css/admin.css */

.phynite-settings {
    max-width: 800px;
}

.phynite-logo {
    height: 32px;
    vertical-align: middle;
    margin-right: 10px;
}

.phynite-settings-container {
    margin-top: 20px;
}

.phynite-card {
    background: #fff;
    border: 1px solid #ccd0d4;
    border-radius: 4px;
    padding: 20px;
    margin-bottom: 20px;
}

.phynite-card h2 {
    margin-top: 0;
    padding-bottom: 10px;
    border-bottom: 1px solid #eee;
}

.phynite-status {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 14px;
    font-weight: 500;
    margin-bottom: 10px;
}

.phynite-status-connected {
    color: #00a32a;
}

.phynite-status-disconnected {
    color: #757575;
}

.phynite-status-error {
    color: #d63638;
}

.phynite-toggle-key {
    vertical-align: middle;
    margin-left: 5px;
}

.phynite-badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 3px;
    font-size: 12px;
}

.phynite-badge-success {
    background: #d4edda;
    color: #155724;
}

.phynite-badge-muted {
    background: #f0f0f1;
    color: #757575;
}

.phynite-sync-status {
    margin-left: 10px;
    vertical-align: middle;
}

.phynite-spin {
    animation: phynite-spin 1s linear infinite;
}

@keyframes phynite-spin {
    from { transform: rotate(0deg); }
    to { transform: rotate(360deg); }
}

#phynite-sync-all .dashicons {
    vertical-align: middle;
    margin-right: 5px;
}
```

---

## Stewie Implementation

### Module Structure

```
stewie/
├── src/
│   ├── wp-connector/
│   │   ├── wp-connector.module.ts
│   │   ├── wp-connector.controller.ts
│   │   ├── wp-connector.service.ts
│   │   ├── dto/
│   │   │   ├── sync-posts.dto.ts
│   │   │   └── sync-single.dto.ts
│   │   └── entities/
│   │       └── wp-api-key.entity.ts
```

### API Key Entity

```typescript
// src/wp-connector/entities/wp-api-key.entity.ts

import { Entity, Column, PrimaryGeneratedColumn, CreateDateColumn, ManyToOne } from 'typeorm';
import { User } from '../../users/entities/user.entity';

@Entity('wp_api_keys')
export class WpApiKey {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ unique: true })
  key: string;  // The actual API key (hashed)

  @Column()
  keyPrefix: string;  // First 8 chars for identification

  @ManyToOne(() => User)
  user: User;

  @Column()
  userId: string;

  @Column()
  siteUrl: string;

  @Column({ nullable: true })
  siteName: string;

  @Column({ default: true })
  isActive: boolean;

  @Column({ nullable: true })
  lastUsedAt: Date;

  @Column({ default: 0 })
  syncCount: number;

  @CreateDateColumn()
  createdAt: Date;
}
```

### DTOs

```typescript
// src/wp-connector/dto/sync-posts.dto.ts

import { IsArray, IsString, IsOptional, ValidateNested, IsUrl } from 'class-validator';
import { Type } from 'class-transformer';

export class PostSeoDto {
  @IsString()
  seo_plugin: string;

  @IsOptional()
  @IsString()
  seo_title?: string;

  @IsOptional()
  @IsString()
  seo_description?: string;

  @IsOptional()
  @IsString()
  focus_keyword?: string;

  @IsOptional()
  seo_score?: number;

  @IsOptional()
  readability_score?: number;
}

export class PostRecipeDto {
  @IsString()
  recipe_plugin: string;

  @IsOptional()
  has_recipe?: boolean;

  @IsOptional()
  @IsArray()
  recipes?: any[];
}

export class InternalLinkDto {
  @IsString()
  target_url: string;

  @IsOptional()
  @IsString()
  anchor_text?: string;

  @IsOptional()
  @IsString()
  context?: string;
}

export class PostDataDto {
  @IsString()
  url: string;

  @IsString()
  title: string;

  @IsString()
  post_type: string;

  @IsString()
  post_status: string;

  @IsString()
  published_at: string;

  @IsString()
  modified_at: string;

  @IsOptional()
  @IsString()
  excerpt?: string;

  @IsOptional()
  word_count?: number;

  @IsOptional()
  @IsArray()
  categories?: any[];

  @IsOptional()
  @IsArray()
  tags?: any[];

  @IsOptional()
  @ValidateNested()
  @Type(() => PostSeoDto)
  seo?: PostSeoDto;

  @IsOptional()
  @ValidateNested()
  @Type(() => PostRecipeDto)
  recipe?: PostRecipeDto;

  @IsOptional()
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => InternalLinkDto)
  internal_links?: InternalLinkDto[];

  @IsOptional()
  internal_link_count?: number;
}

export class SyncPostsDto {
  @IsArray()
  @ValidateNested({ each: true })
  @Type(() => PostDataDto)
  posts: PostDataDto[];

  @IsUrl()
  site_url: string;

  @IsOptional()
  @IsString()
  site_name?: string;

  @IsOptional()
  @IsString()
  wp_version?: string;

  @IsOptional()
  @IsString()
  plugin_version?: string;
}

export class SyncSingleDto {
  @ValidateNested()
  @Type(() => PostDataDto)
  post: PostDataDto;

  @IsUrl()
  site_url: string;
}
```

### Controller

```typescript
// src/wp-connector/wp-connector.controller.ts

import { 
  Controller, 
  Post, 
  Get, 
  Body, 
  UseGuards, 
  Request,
  HttpCode 
} from '@nestjs/common';
import { WpConnectorService } from './wp-connector.service';
import { WpApiKeyGuard } from './guards/wp-api-key.guard';
import { SyncPostsDto, SyncSingleDto } from './dto/sync-posts.dto';

@Controller('wp-connector')
export class WpConnectorController {
  constructor(private readonly wpConnectorService: WpConnectorService) {}

  @Get('validate')
  @UseGuards(WpApiKeyGuard)
  async validateApiKey(@Request() req) {
    return {
      valid: true,
      user_id: req.wpApiKey.userId,
      site_url: req.wpApiKey.siteUrl,
    };
  }

  @Get('status')
  @UseGuards(WpApiKeyGuard)
  async getStatus(@Request() req) {
    return this.wpConnectorService.getStatus(req.wpApiKey);
  }

  @Post('sync')
  @UseGuards(WpApiKeyGuard)
  @HttpCode(200)
  async syncPosts(@Request() req, @Body() dto: SyncPostsDto) {
    return this.wpConnectorService.syncPosts(req.wpApiKey, dto);
  }

  @Post('sync-single')
  @UseGuards(WpApiKeyGuard)
  @HttpCode(200)
  async syncSinglePost(@Request() req, @Body() dto: SyncSingleDto) {
    return this.wpConnectorService.syncSinglePost(req.wpApiKey, dto);
  }
}
```

### Service

```typescript
// src/wp-connector/wp-connector.service.ts

import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { BigQuery } from '@google-cloud/bigquery';
import { WpApiKey } from './entities/wp-api-key.entity';
import { SyncPostsDto, SyncSingleDto, PostDataDto } from './dto/sync-posts.dto';

@Injectable()
export class WpConnectorService {
  private readonly logger = new Logger(WpConnectorService.name);
  private readonly bigquery: BigQuery;
  private readonly dataset: string;

  constructor(
    @InjectRepository(WpApiKey)
    private wpApiKeyRepository: Repository<WpApiKey>,
  ) {
    this.bigquery = new BigQuery();
    this.dataset = process.env.BIGQUERY_DATASET;
  }

  async getStatus(apiKey: WpApiKey) {
    const lastSync = await this.getLastSyncTime(apiKey.userId);
    
    return {
      connected: true,
      site_url: apiKey.siteUrl,
      site_name: apiKey.siteName,
      last_sync: lastSync,
      sync_count: apiKey.syncCount,
    };
  }

  async syncPosts(apiKey: WpApiKey, dto: SyncPostsDto) {
    const { posts, site_url, site_name } = dto;
    
    this.logger.log(`Syncing ${posts.length} posts for user ${apiKey.userId}`);
    
    try {
      // Transform and write to BigQuery
      const rows = posts.map(post => this.transformPost(apiKey.userId, site_url, post));
      
      await this.writeToBigQuery('content_posts_wp', rows);
      
      // Write internal links separately
      const linkRows = this.extractLinks(apiKey.userId, posts);
      if (linkRows.length > 0) {
        await this.writeToBigQuery('content_links_wp', linkRows);
      }
      
      // Update API key stats
      await this.wpApiKeyRepository.update(apiKey.id, {
        lastUsedAt: new Date(),
        siteName: site_name,
        syncCount: () => 'sync_count + 1',
      });
      
      return {
        success: true,
        synced: posts.length,
      };
    } catch (error) {
      this.logger.error(`Sync failed for user ${apiKey.userId}:`, error);
      throw error;
    }
  }

  async syncSinglePost(apiKey: WpApiKey, dto: SyncSingleDto) {
    const { post, site_url } = dto;
    
    this.logger.log(`Syncing single post ${post.url} for user ${apiKey.userId}`);
    
    try {
      const row = this.transformPost(apiKey.userId, site_url, post);
      
      // Upsert to BigQuery
      await this.upsertToBigQuery('content_posts_wp', row, ['user_id', 'url']);
      
      // Update links
      if (post.internal_links?.length > 0) {
        const linkRows = post.internal_links.map(link => ({
          user_id: apiKey.userId,
          source_url: post.url,
          target_url: link.target_url,
          anchor_text: link.anchor_text,
          context: link.context,
          synced_at: new Date().toISOString(),
        }));
        
        // Delete existing links for this post, then insert new
        await this.deleteLinks(apiKey.userId, post.url);
        await this.writeToBigQuery('content_links_wp', linkRows);
      }
      
      // Update API key stats
      await this.wpApiKeyRepository.update(apiKey.id, {
        lastUsedAt: new Date(),
      });
      
      return {
        success: true,
        url: post.url,
      };
    } catch (error) {
      this.logger.error(`Single sync failed for ${post.url}:`, error);
      throw error;
    }
  }

  private transformPost(userId: string, siteUrl: string, post: PostDataDto) {
    return {
      user_id: userId,
      site_url: siteUrl,
      url: post.url,
      title: post.title,
      post_type: post.post_type,
      post_status: post.post_status,
      published_at: post.published_at,
      modified_at: post.modified_at,
      excerpt: post.excerpt,
      word_count: post.word_count,
      
      // Taxonomy as JSON strings
      categories: JSON.stringify(post.categories || []),
      tags: JSON.stringify(post.tags || []),
      
      // SEO data
      seo_plugin: post.seo?.seo_plugin,
      seo_title: post.seo?.seo_title,
      seo_description: post.seo?.seo_description,
      focus_keyword: post.seo?.focus_keyword,
      seo_score: post.seo?.seo_score,
      readability_score: post.seo?.readability_score,
      
      // Recipe data
      has_recipe: post.recipe?.has_recipe || false,
      recipe_plugin: post.recipe?.recipe_plugin,
      recipe_data: post.recipe ? JSON.stringify(post.recipe.recipes || []) : null,
      
      // Link counts
      internal_link_count: post.internal_link_count || 0,
      
      // Metadata
      synced_at: new Date().toISOString(),
    };
  }

  private extractLinks(userId: string, posts: PostDataDto[]) {
    const links = [];
    
    for (const post of posts) {
      if (post.internal_links) {
        for (const link of post.internal_links) {
          links.push({
            user_id: userId,
            source_url: post.url,
            target_url: link.target_url,
            anchor_text: link.anchor_text,
            context: link.context,
            synced_at: new Date().toISOString(),
          });
        }
      }
    }
    
    return links;
  }

  private async writeToBigQuery(table: string, rows: any[]) {
    if (rows.length === 0) return;
    
    const tableRef = this.bigquery.dataset(this.dataset).table(table);
    await tableRef.insert(rows);
  }

  private async upsertToBigQuery(table: string, row: any, keyColumns: string[]) {
    // Use MERGE statement for upsert
    const columns = Object.keys(row);
    const values = Object.values(row);
    
    const query = `
      MERGE \`${this.dataset}.${table}\` T
      USING (SELECT @${columns.join(', @')} ) S
      ON ${keyColumns.map(k => `T.${k} = S.${k}`).join(' AND ')}
      WHEN MATCHED THEN
        UPDATE SET ${columns.filter(c => !keyColumns.includes(c)).map(c => `${c} = S.${c}`).join(', ')}
      WHEN NOT MATCHED THEN
        INSERT (${columns.join(', ')})
        VALUES (${columns.map(c => `S.${c}`).join(', ')})
    `;
    
    const options = {
      query,
      params: row,
    };
    
    await this.bigquery.query(options);
  }

  private async deleteLinks(userId: string, sourceUrl: string) {
    const query = `
      DELETE FROM \`${this.dataset}.content_links_wp\`
      WHERE user_id = @userId AND source_url = @sourceUrl
    `;
    
    await this.bigquery.query({
      query,
      params: { userId, sourceUrl },
    });
  }

  private async getLastSyncTime(userId: string): Promise<string | null> {
    const query = `
      SELECT MAX(synced_at) as last_sync
      FROM \`${this.dataset}.content_posts_wp\`
      WHERE user_id = @userId
    `;
    
    const [rows] = await this.bigquery.query({
      query,
      params: { userId },
    });
    
    return rows[0]?.last_sync || null;
  }
}
```

### API Key Guard

```typescript
// src/wp-connector/guards/wp-api-key.guard.ts

import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { WpApiKey } from '../entities/wp-api-key.entity';
import * as crypto from 'crypto';

@Injectable()
export class WpApiKeyGuard implements CanActivate {
  constructor(
    @InjectRepository(WpApiKey)
    private wpApiKeyRepository: Repository<WpApiKey>,
  ) {}

  async canActivate(context: ExecutionContext): Promise<boolean> {
    const request = context.switchToHttp().getRequest();
    const authHeader = request.headers.authorization;

    if (!authHeader || !authHeader.startsWith('Bearer ')) {
      throw new UnauthorizedException('Missing API key');
    }

    const apiKey = authHeader.substring(7);
    const keyHash = this.hashKey(apiKey);
    const keyPrefix = apiKey.substring(0, 8);

    const wpApiKey = await this.wpApiKeyRepository.findOne({
      where: {
        key: keyHash,
        keyPrefix: keyPrefix,
        isActive: true,
      },
      relations: ['user'],
    });

    if (!wpApiKey) {
      throw new UnauthorizedException('Invalid API key');
    }

    // Attach to request for use in controllers
    request.wpApiKey = wpApiKey;

    return true;
  }

  private hashKey(key: string): string {
    return crypto.createHash('sha256').update(key).digest('hex');
  }
}
```

---

## BigQuery Schemas

### content_posts_wp

```sql
CREATE TABLE IF NOT EXISTS `{project}.{dataset}.content_posts_wp` (
  user_id STRING NOT NULL,
  site_url STRING NOT NULL,
  url STRING NOT NULL,
  
  -- Core post data
  title STRING,
  post_type STRING,
  post_status STRING,
  published_at TIMESTAMP,
  modified_at TIMESTAMP,
  excerpt STRING,
  word_count INT64,
  
  -- Taxonomy (JSON arrays)
  categories STRING,  -- JSON array
  tags STRING,        -- JSON array
  
  -- SEO plugin data
  seo_plugin STRING,
  seo_title STRING,
  seo_description STRING,
  focus_keyword STRING,
  seo_score INT64,
  readability_score INT64,
  
  -- Recipe plugin data
  has_recipe BOOL,
  recipe_plugin STRING,
  recipe_data STRING,  -- JSON array of recipes
  
  -- Link counts
  internal_link_count INT64,
  
  -- Metadata
  synced_at TIMESTAMP
)
CLUSTER BY user_id;
```

### content_links_wp

```sql
CREATE TABLE IF NOT EXISTS `{project}.{dataset}.content_links_wp` (
  user_id STRING NOT NULL,
  
  -- Link relationship
  source_url STRING NOT NULL,  -- The post containing the link
  target_url STRING NOT NULL,  -- Where the link points to
  
  -- Link details
  anchor_text STRING,
  context STRING,              -- Surrounding text
  
  -- Metadata
  synced_at TIMESTAMP
)
CLUSTER BY user_id, target_url;
```

---

## Sidney Implementation

### API Key Generation UI

Location: Settings > WordPress Integration

```typescript
// components/settings/WordPressIntegration.tsx

'use client';

import { useState } from 'react';
import { Copy, RefreshCw, Check, ExternalLink } from 'lucide-react';

export function WordPressIntegration() {
  const [apiKey, setApiKey] = useState<string | null>(null);
  const [isGenerating, setIsGenerating] = useState(false);
  const [copied, setCopied] = useState(false);
  const [connectionStatus, setConnectionStatus] = useState<any>(null);

  const generateApiKey = async () => {
    setIsGenerating(true);
    try {
      const response = await fetch('/api/settings/wp-api-key', {
        method: 'POST',
      });
      const data = await response.json();
      setApiKey(data.key);
    } finally {
      setIsGenerating(false);
    }
  };

  const copyToClipboard = () => {
    if (apiKey) {
      navigator.clipboard.writeText(apiKey);
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    }
  };

  return (
    <div className="space-y-6">
      <div>
        <h2 className="text-lg font-semibold">WordPress Integration</h2>
        <p className="text-sm text-gray-600 mt-1">
          Connect your WordPress site to sync content data automatically.
        </p>
      </div>

      {/* Connection Status */}
      {connectionStatus && (
        <div className="bg-green-50 border border-green-200 rounded-lg p-4">
          <div className="flex items-center gap-2">
            <Check className="w-5 h-5 text-green-600" />
            <span className="font-medium text-green-800">Connected</span>
          </div>
          <p className="text-sm text-green-700 mt-1">
            {connectionStatus.site_name} • Last sync: {connectionStatus.last_sync}
          </p>
        </div>
      )}

      {/* API Key Section */}
      <div className="bg-white border rounded-lg p-4">
        <h3 className="font-medium mb-3">API Key</h3>
        
        {apiKey ? (
          <div className="space-y-3">
            <div className="flex items-center gap-2">
              <code className="flex-1 bg-gray-100 px-3 py-2 rounded font-mono text-sm">
                {apiKey}
              </code>
              <button
                onClick={copyToClipboard}
                className="btn btn-outline btn-sm"
              >
                {copied ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
              </button>
            </div>
            <p className="text-sm text-amber-600">
              ⚠️ Copy this key now — you won't be able to see it again.
            </p>
          </div>
        ) : (
          <button
            onClick={generateApiKey}
            disabled={isGenerating}
            className="btn btn-primary"
          >
            {isGenerating ? (
              <>
                <RefreshCw className="w-4 h-4 animate-spin mr-2" />
                Generating...
              </>
            ) : (
              'Generate API Key'
            )}
          </button>
        )}
      </div>

      {/* Installation Instructions */}
      <div className="bg-gray-50 border rounded-lg p-4">
        <h3 className="font-medium mb-3">Installation</h3>
        <ol className="list-decimal list-inside space-y-2 text-sm">
          <li>
            Download the plugin:{' '}
            <a 
              href="/downloads/phynite-analytics-for-wordpress.zip"
              className="text-blue-600 hover:underline"
            >
              phynite-analytics-for-wordpress.zip
            </a>
          </li>
          <li>
            In WordPress, go to <strong>Plugins → Add New → Upload Plugin</strong>
          </li>
          <li>Upload the zip file and click <strong>Install Now</strong></li>
          <li>Activate the plugin</li>
          <li>
            Go to <strong>Settings → Phynite Analytics</strong> and paste your API key
          </li>
          <li>Click <strong>Sync All Content</strong> for initial sync</li>
        </ol>
      </div>

      {/* Documentation Link */}
      <a
        href="https://docs.phyniteanalytics.com/wordpress"
        target="_blank"
        rel="noopener noreferrer"
        className="inline-flex items-center gap-1 text-sm text-blue-600 hover:underline"
      >
        View full documentation
        <ExternalLink className="w-4 h-4" />
      </a>
    </div>
  );
}
```

---

## Task Checklist

### Week 1

**BigQuery (Day 1)**
- [ ] Create `content_posts_wp` table
- [ ] Create `content_links_wp` table

**Stewie (Day 1-3)**
- [ ] Create `wp-connector` module
- [ ] Create `WpApiKey` entity
- [ ] Create migration for `wp_api_keys` table
- [ ] Implement `WpApiKeyGuard`
- [ ] Implement `POST /wp-connector/validate` endpoint
- [ ] Implement `GET /wp-connector/status` endpoint

**Stewie (Day 3-5)**
- [ ] Implement `POST /wp-connector/sync` endpoint
- [ ] Implement `POST /wp-connector/sync-single` endpoint
- [ ] BigQuery write logic for posts
- [ ] BigQuery write logic for links
- [ ] Add rate limiting

### Week 2

**Sidney (Day 1-2)**
- [ ] API key generation endpoint
- [ ] WordPress Integration settings page
- [ ] API key display/copy UI
- [ ] Connection status display
- [ ] Plugin download link

**WordPress Plugin (Day 2-5)**
- [ ] Plugin scaffold
- [ ] Main plugin file
- [ ] Admin settings page
- [ ] API client class
- [ ] Post extractor

### Week 3

**WordPress Plugin (Day 1-4)**
- [ ] Yoast extractor
- [ ] RankMath extractor
- [ ] AIOSEO extractor
- [ ] WPRM extractor
- [ ] Tasty Recipes extractor
- [ ] Mediavine Create extractor
- [ ] Links extractor

**WordPress Plugin (Day 4-5)**
- [ ] Sync orchestration
- [ ] Auto-sync on publish hook
- [ ] Auto-sync on update hook
- [ ] Manual sync button

### Week 4

**Testing & Polish (Day 1-3)**
- [ ] Test with Yoast
- [ ] Test with RankMath
- [ ] Test with WPRM
- [ ] Test with Tasty Recipes
- [ ] Test on different WP versions
- [ ] Error handling improvements

**Documentation & Release (Day 4-5)**
- [ ] readme.txt for WordPress.org
- [ ] User documentation
- [ ] Create plugin zip
- [ ] Deploy to beta testers

---

## Testing Matrix

| WordPress | PHP | SEO Plugin | Recipe Plugin | Test Status |
|-----------|-----|------------|---------------|-------------|
| 6.4 | 8.2 | Yoast | WPRM | ⬜ |
| 6.4 | 8.1 | RankMath | Tasty | ⬜ |
| 6.3 | 8.0 | AIOSEO | Create | ⬜ |
| 6.2 | 7.4 | None | None | ⬜ |

---

## Environment Variables

### Stewie
```bash
BIGQUERY_PROJECT=your-gcp-project
BIGQUERY_DATASET=phynite_analytics
```

### WordPress Plugin
```php
// Set via wp-config.php for development/debugging
define('PHYNITE_API_URL', 'https://api.phyniteanalytics.com');
define('PHYNITE_DEBUG', true);
```

---

## Notes for Claude Code

1. **Start with Stewie endpoints** — The plugin needs somewhere to POST data
2. **BigQuery schemas must exist first** — Run CREATE TABLE before testing
3. **Test extractors individually** — Each SEO/recipe plugin is different
4. **Links extractor is critical** — This is the source of truth for internal linking features
5. **Batch syncs carefully** — 50 posts at a time to avoid timeouts
6. **API key security** — Hash keys before storing, never log full keys
7. **WordPress hooks are tricky** — Test publish vs update vs save_post carefully