# Marketplace Hunter Implementation Plan

> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a two-sided scraper + matchmaker system that hunts construction workers and contractors from Gumtree/Seek/Indeed/Facebook, matches them by city+trade, and facilitates introductions through RateRight's existing platform.

**Architecture:** Four Node.js scripts (job-aggregator, worker-hunter, contractor-hunter, matchmaker) run on VPS via PM2 cron. An outreach-sender and reply-monitor run as persistent PM2 processes. Facebook scraping/posting runs on Rocky's PC via headed Playwright. All lead data flows through Supabase with a central `contact_registry` for dedup and anti-spam. Four CLI skills (/hunt, /matchmaker, /pulse, /post) give Rocky control.

**Tech Stack:** Node.js, Playwright (Seek/Indeed/Facebook), Cheerio+Axios (Gumtree), Supabase (PostgreSQL), Gmail API (email), Twilio (SMS), Notion MCP (summaries), PM2 (process management).

**Spec:** `docs/superpowers/specs/2026-04-28-marketplace-hunter-design.md`

---

## Chunk 1: Foundation — Database + Config + Shared Utilities

### Task 1: Database Migration

**Files:**
- Create: `supabase/migrations/20260428000000_marketplace_hunter.sql`

- [ ] **Step 1: Write the migration SQL file**

```sql
-- Marketplace Hunter tables
-- Run via Supabase API: POST /v1/projects/{ref}/database/query

-- 1. Aggregated jobs (scraped from Seek/Indeed/Gumtree)
CREATE TABLE IF NOT EXISTS aggregated_jobs (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  title TEXT NOT NULL,
  company_name TEXT,
  location TEXT,
  suburb TEXT,
  pay_rate TEXT,
  trade_type TEXT,
  description_snippet TEXT,
  source_url TEXT UNIQUE NOT NULL,
  source TEXT NOT NULL CHECK (source IN ('seek', 'indeed', 'gumtree')),
  date_posted DATE,
  scraped_at TIMESTAMPTZ DEFAULT NOW(),
  expired BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX IF NOT EXISTS idx_aggregated_jobs_trade ON aggregated_jobs(trade_type);
CREATE INDEX IF NOT EXISTS idx_aggregated_jobs_location ON aggregated_jobs(location);
CREATE INDEX IF NOT EXISTS idx_aggregated_jobs_source ON aggregated_jobs(source);

-- 2. Contact registry (central dedup + anti-spam)
CREATE TABLE IF NOT EXISTS contact_registry (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  email TEXT,
  phone TEXT,
  name TEXT,
  city TEXT,
  trade TEXT,
  identity_key TEXT GENERATED ALWAYS AS (
    LOWER(COALESCE(email, '') || '|' || COALESCE(phone, '') || '|' || COALESCE(name, '') || '|' || COALESCE(city, ''))
  ) STORED UNIQUE,
  side TEXT CHECK (side IN ('worker', 'contractor', 'both')),
  source TEXT CHECK (source IN ('gumtree', 'facebook', 'seek', 'indeed', 'manual', 'signup', 'match')),
  first_seen_at TIMESTAMPTZ DEFAULT NOW(),
  last_contacted_at TIMESTAMPTZ,
  contact_count INTEGER DEFAULT 0,
  max_contacts INTEGER DEFAULT 3,
  status TEXT DEFAULT 'new' CHECK (status IN ('new', 'contacted', 'replied', 'converted', 'unsubscribed', 'bounced')),
  unsubscribed_at TIMESTAMPTZ,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  updated_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_registry_email_unique ON contact_registry(email) WHERE email IS NOT NULL;
CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_registry_phone_unique ON contact_registry(phone) WHERE phone IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_contact_registry_city_trade ON contact_registry(city, trade);
CREATE INDEX IF NOT EXISTS idx_contact_registry_status ON contact_registry(status);

-- 3. Scraped leads (raw leads before outreach)
CREATE TABLE IF NOT EXISTS scraped_leads (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  source TEXT NOT NULL CHECK (source IN ('gumtree', 'facebook', 'seek', 'indeed', 'manual')),
  source_url TEXT,
  side TEXT NOT NULL CHECK (side IN ('worker', 'contractor')),
  name TEXT,
  email TEXT,
  phone TEXT,
  trade TEXT,
  city TEXT,
  suburb TEXT,
  description TEXT,
  pay_rate TEXT,
  post_date DATE,
  status TEXT DEFAULT 'new' CHECK (status IN ('new', 'queued', 'approved', 'sent', 'replied', 'converted', 'rejected', 'skipped')),
  contact_registry_id UUID REFERENCES contact_registry(id),
  outreach_channel TEXT CHECK (outreach_channel IN ('email', 'sms', 'facebook_dm', 'facebook_reply', 'gumtree_message')),
  outreach_sent_at TIMESTAMPTZ,
  outreach_message_id TEXT,
  scraped_at TIMESTAMPTZ DEFAULT NOW(),
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(source, source_url)
);

CREATE UNIQUE INDEX IF NOT EXISTS idx_scraped_leads_facebook_dedup
  ON scraped_leads(source, name, city, post_date)
  WHERE source = 'facebook';

CREATE INDEX IF NOT EXISTS idx_scraped_leads_side ON scraped_leads(side);
CREATE INDEX IF NOT EXISTS idx_scraped_leads_city_trade ON scraped_leads(city, trade);
CREATE INDEX IF NOT EXISTS idx_scraped_leads_status ON scraped_leads(status);
CREATE INDEX IF NOT EXISTS idx_scraped_leads_source ON scraped_leads(source);

-- 4. Match introductions
CREATE TABLE IF NOT EXISTS match_introductions (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  worker_lead_id UUID REFERENCES scraped_leads(id),
  contractor_lead_id UUID REFERENCES scraped_leads(id),
  trade TEXT,
  city TEXT,
  score INTEGER,
  worker_contacted_at TIMESTAMPTZ,
  contractor_contacted_at TIMESTAMPTZ,
  worker_signed_up BOOLEAN DEFAULT FALSE,
  contractor_signed_up BOOLEAN DEFAULT FALSE,
  hire_completed BOOLEAN DEFAULT FALSE,
  created_at TIMESTAMPTZ DEFAULT NOW(),
  UNIQUE(worker_lead_id, contractor_lead_id)
);

-- 5. Facebook post log
CREATE TABLE IF NOT EXISTS facebook_post_log (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  group_name TEXT NOT NULL,
  group_url TEXT,
  post_type TEXT CHECK (post_type IN ('question', 'tip', 'story', 'job_link')),
  post_content TEXT,
  city TEXT,
  posted_at TIMESTAMPTZ DEFAULT NOW(),
  engagement_likes INTEGER DEFAULT 0,
  engagement_comments INTEGER DEFAULT 0,
  removed_by_admin BOOLEAN DEFAULT FALSE,
  notes TEXT
);

CREATE INDEX IF NOT EXISTS idx_facebook_post_log_group ON facebook_post_log(group_name);
CREATE INDEX IF NOT EXISTS idx_facebook_post_log_date ON facebook_post_log(posted_at);
```

- [ ] **Step 2: Apply migration to Supabase**

Use the Supabase migration API per the SOP in `docs/sop/supabase-migrations.md`:
```bash
# From VPS — use Node for JSON encoding:
node -e "
const fs = require('fs');
const https = require('https');
const sql = fs.readFileSync('supabase/migrations/20260428000000_marketplace_hunter.sql', 'utf8');
const body = JSON.stringify({ query: sql });
const req = https.request({
  hostname: 'api.supabase.com',
  path: '/v1/projects/memscjotxrzqnhrvnnkc/database/query',
  method: 'POST',
  headers: {
    'Authorization': 'Bearer ' + require('/root/.clawdbot/secrets.json').supabase_access_token,
    'Content-Type': 'application/json',
    'Content-Length': Buffer.byteLength(body),
  },
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(res.statusCode, d)); });
req.write(body); req.end();
"
```

Expected: 201 success or 400 with "already exists" (idempotent).

- [ ] **Step 3: Verify tables exist**

Run a quick query to confirm:
```bash
node -e "
require('dotenv').config();
const {createClient}=require('@supabase/supabase-js');
const sb=createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
async function check() {
  const tables = ['aggregated_jobs','contact_registry','scraped_leads','match_introductions','facebook_post_log'];
  for (const t of tables) {
    const {count,error} = await sb.from(t).select('*',{count:'exact',head:true});
    console.log(t, error ? 'MISSING: '+error.message : 'OK ('+count+' rows)');
  }
}
check();
"
```

Expected: All 5 tables show "OK (0 rows)".

- [ ] **Step 4: Commit**

```bash
git add supabase/migrations/20260428000000_marketplace_hunter.sql
git commit -m "feat: add marketplace hunter database tables"
```

---

### Task 2: Shared Config Module

**Files:**
- Create: `scripts/hunt/config.js`

- [ ] **Step 1: Create the config file**

```javascript
// Marketplace Hunter — shared config
// Cities, trade mappings, rate limits, outreach templates

const CITIES = ['sydney', 'melbourne', 'brisbane'];

const TRADES = [
  'steel fixer', 'formworker', 'labourer', 'concreter',
  'carpenter', 'scaffolder', 'other'
];

// Map scraped job titles to canonical trade names
const TRADE_ALIASES = {
  'steelfixer': 'steel fixer', 'steel-fixer': 'steel fixer', 'reo': 'steel fixer',
  'formwork': 'formworker', 'form worker': 'formworker', 'shuttering': 'formworker',
  'labour': 'labourer', 'laborer': 'labourer', 'general labourer': 'labourer',
  'concrete': 'concreter', 'concrete finisher': 'concreter',
  'chippy': 'carpenter', 'joiner': 'carpenter',
  'scaff': 'scaffolder', 'scaffold': 'scaffolder',
};

function normalizeTrade(raw) {
  if (!raw) return 'other';
  const lower = raw.toLowerCase().trim();
  if (TRADES.includes(lower)) return lower;
  return TRADE_ALIASES[lower] || 'other';
}

// Rate limits (per day)
const RATE_LIMITS = {
  email: 20,
  sms: 15,
  facebook_dm: 10,
  facebook_reply: 10,
};

// Cooldown between contacts (days)
const CONTACT_COOLDOWN_DAYS = 7;

// Max messages per contact ever
const MAX_CONTACTS = 3;

// Delay between outreach sends (ms)
const SEND_DELAY_MS = 30 * 60 * 1000; // 30 minutes

// Gumtree base URLs
const GUMTREE_URLS = {
  looking_for_work: {
    sydney: 'https://www.gumtree.com.au/s-resumes/sydney/c9302l3003435',
    melbourne: 'https://www.gumtree.com.au/s-resumes/melbourne/c9302l3001317',
    brisbane: 'https://www.gumtree.com.au/s-resumes/brisbane/c9302l3005721',
  },
  jobs: {
    sydney: 'https://www.gumtree.com.au/s-jobs/sydney/construction-trades/c9079l3003435',
    melbourne: 'https://www.gumtree.com.au/s-jobs/melbourne/construction-trades/c9079l3001317',
    brisbane: 'https://www.gumtree.com.au/s-jobs/brisbane/construction-trades/c9079l3005721',
  },
};

// Outreach templates
const TEMPLATES = {
  worker_email: {
    subject: 'Construction work in {city}?',
    body: `Hey {name},

Saw you're looking for {trade} work in {city}.

RateRight is free for workers — contractors post jobs and you apply direct. No fees, no agency cut, no middleman.

Check out current jobs: https://rateright.com.au/go?utm_source={source}&utm_medium=email&utm_campaign=hunt&city={city_slug}

Cheers,
Michael
RateRight

Unsubscribe: {unsubscribe_url}`,
  },
  worker_sms: `Hey {name}, looking for {trade} work in {city}? RateRight is free for workers — contractors post jobs, you apply direct. No fees ever. rateright.com.au/go Reply STOP to opt out`,
  contractor_email: {
    subject: 'Hiring {trade} in {city}? $50 flat fee',
    body: `Hey {name},

Saw you're hiring {trade} in {city}.

RateRight charges $50 flat per hire — no percentages, no agency markup, no ongoing fees. Post your job and we match you with workers.

Post a job: https://rateright.com.au/hire?utm_source={source}&utm_medium=email&utm_campaign=hunt&city={city_slug}

Happy to chat if you want to know more.

Cheers,
Michael
RateRight
0468 087 171

Unsubscribe: {unsubscribe_url}`,
  },
  match_worker_email: {
    subject: '{trade} job available in {city}',
    body: `Hey {name},

A contractor in {suburb} is looking for a {trade} this week.

Sign up on RateRight to get connected — it's free: https://rateright.com.au/go?utm_source=match&utm_medium=email&utm_campaign=hunt&city={city_slug}

Cheers,
Michael
RateRight

Unsubscribe: {unsubscribe_url}`,
  },
  match_contractor_email: {
    subject: '{trade} available in {city}',
    body: `Hey {name},

We've got a {trade} available in {city} right now.

Post your job on RateRight ($50 flat fee) and we'll connect you: https://rateright.com.au/hire?utm_source=match&utm_medium=email&utm_campaign=hunt&city={city_slug}

Cheers,
Michael
RateRight
0468 087 171

Unsubscribe: {unsubscribe_url}`,
  },
};

function fillTemplate(template, vars) {
  let result = typeof template === 'string' ? template : JSON.stringify(template);
  for (const [key, value] of Object.entries(vars)) {
    result = result.replace(new RegExp(`\\{${key}\\}`, 'g'), value || '');
  }
  return typeof template === 'string' ? result : JSON.parse(result);
}

module.exports = {
  CITIES, TRADES, TRADE_ALIASES, normalizeTrade,
  RATE_LIMITS, CONTACT_COOLDOWN_DAYS, MAX_CONTACTS, SEND_DELAY_MS,
  GUMTREE_URLS, TEMPLATES, fillTemplate,
};
```

- [ ] **Step 2: Commit**

```bash
git add scripts/hunt/config.js
git commit -m "feat: add marketplace hunter shared config"
```

---

### Task 3: Supabase Helper Module

**Files:**
- Create: `scripts/hunt/db.js`

This module wraps all database operations for the hunter system — contact registry lookups, lead insertion, dedup checks, daily send counts.

- [ ] **Step 1: Create the database helper**

```javascript
// Marketplace Hunter — Supabase database operations
require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '.env') });
const { createClient } = require('@supabase/supabase-js');
const { CONTACT_COOLDOWN_DAYS, MAX_CONTACTS, RATE_LIMITS } = require('./config');

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY
);

// --- Contact Registry ---

async function findContact({ email, phone, name, city }) {
  // Check by email first, then phone, then identity_key pattern
  if (email) {
    const { data } = await supabase
      .from('contact_registry')
      .select('*')
      .eq('email', email.toLowerCase())
      .maybeSingle();
    if (data) return data;
  }
  if (phone) {
    const { data } = await supabase
      .from('contact_registry')
      .select('*')
      .eq('phone', phone)
      .maybeSingle();
    if (data) return data;
  }
  if (name && city) {
    // Fallback: match by identity_key pattern
    const key = ['', '', name, city].join('|').toLowerCase();
    const { data } = await supabase
      .from('contact_registry')
      .select('*')
      .ilike('identity_key', `%${name.toLowerCase()}%${city.toLowerCase()}%`)
      .maybeSingle();
    if (data) return data;
  }
  return null;
}

async function upsertContact({ email, phone, name, city, trade, side, source }) {
  const existing = await findContact({ email, phone, name, city });
  if (existing) return { contact: existing, isNew: false };

  const { data, error } = await supabase
    .from('contact_registry')
    .insert({
      email: email?.toLowerCase() || null,
      phone: phone || null,
      name: name || null,
      city: city?.toLowerCase() || null,
      trade: trade?.toLowerCase() || null,
      side,
      source,
    })
    .select()
    .single();

  if (error) {
    // identity_key collision — already exists
    if (error.code === '23505') {
      const found = await findContact({ email, phone, name, city });
      return { contact: found, isNew: false };
    }
    throw error;
  }
  return { contact: data, isNew: true };
}

async function canContact(contactId) {
  const { data: contact } = await supabase
    .from('contact_registry')
    .select('*')
    .eq('id', contactId)
    .single();

  if (!contact) return { allowed: false, reason: 'not_found' };
  if (contact.unsubscribed_at) return { allowed: false, reason: 'unsubscribed' };
  if (contact.status === 'bounced') return { allowed: false, reason: 'bounced' };
  if (contact.contact_count >= (contact.max_contacts || MAX_CONTACTS))
    return { allowed: false, reason: 'max_contacts_reached' };

  if (contact.last_contacted_at) {
    const cooldownMs = CONTACT_COOLDOWN_DAYS * 24 * 60 * 60 * 1000;
    const elapsed = Date.now() - new Date(contact.last_contacted_at).getTime();
    if (elapsed < cooldownMs)
      return { allowed: false, reason: 'cooldown', resumeAt: new Date(Date.now() + cooldownMs - elapsed) };
  }

  return { allowed: true };
}

async function recordContact(contactId) {
  const { data } = await supabase
    .from('contact_registry')
    .select('contact_count')
    .eq('id', contactId)
    .single();

  await supabase
    .from('contact_registry')
    .update({
      contact_count: (data?.contact_count || 0) + 1,
      last_contacted_at: new Date().toISOString(),
      status: 'contacted',
      updated_at: new Date().toISOString(),
    })
    .eq('id', contactId);
}

async function markUnsubscribed(contactId) {
  await supabase
    .from('contact_registry')
    .update({
      unsubscribed_at: new Date().toISOString(),
      status: 'unsubscribed',
      updated_at: new Date().toISOString(),
    })
    .eq('id', contactId);
}

async function markBounced(contactId) {
  await supabase
    .from('contact_registry')
    .update({
      status: 'bounced',
      updated_at: new Date().toISOString(),
    })
    .eq('id', contactId);
}

// --- Scraped Leads ---

async function insertLead(lead) {
  const { data, error } = await supabase
    .from('scraped_leads')
    .upsert(lead, { onConflict: 'source,source_url', ignoreDuplicates: true })
    .select()
    .single();

  if (error && error.code !== '23505') throw error;
  return data;
}

async function getLeadQueue({ side, status = 'approved', limit = 20 }) {
  const { data, error } = await supabase
    .from('scraped_leads')
    .select('*, contact_registry(*)')
    .eq('side', side)
    .eq('status', status)
    .order('created_at', { ascending: true })
    .limit(limit);

  if (error) throw error;
  return data || [];
}

async function updateLeadStatus(leadId, status, extra = {}) {
  await supabase
    .from('scraped_leads')
    .update({ status, ...extra })
    .eq('id', leadId);
}

// --- Aggregated Jobs ---

async function upsertJob(job) {
  const { data, error } = await supabase
    .from('aggregated_jobs')
    .upsert(job, { onConflict: 'source_url', ignoreDuplicates: true })
    .select()
    .single();

  if (error && error.code !== '23505') throw error;
  return data;
}

async function expireStaleJobs(daysOld = 14) {
  const cutoff = new Date(Date.now() - daysOld * 24 * 60 * 60 * 1000).toISOString();
  const { count } = await supabase
    .from('aggregated_jobs')
    .update({ expired: true })
    .lt('scraped_at', cutoff)
    .eq('expired', false);
  return count || 0;
}

// --- Daily Send Count ---

async function getDailySendCount(channel) {
  const today = new Date().toISOString().split('T')[0]; // YYYY-MM-DD
  const { count } = await supabase
    .from('scraped_leads')
    .select('*', { count: 'exact', head: true })
    .eq('outreach_channel', channel)
    .gte('outreach_sent_at', today + 'T00:00:00Z');

  return count || 0;
}

async function canSendToday(channel) {
  const count = await getDailySendCount(channel);
  const limit = RATE_LIMITS[channel] || 20;
  return { allowed: count < limit, sent: count, limit };
}

// --- Match Introductions ---

async function insertMatch(match) {
  const { data, error } = await supabase
    .from('match_introductions')
    .upsert(match, { onConflict: 'worker_lead_id,contractor_lead_id', ignoreDuplicates: true })
    .select()
    .single();

  if (error && error.code !== '23505') throw error;
  return data;
}

async function findMatches() {
  // Workers who replied/converted + contractors in same city/trade
  const { data, error } = await supabase.rpc('find_hunt_matches');
  if (error) {
    // Fallback: manual query if RPC doesn't exist
    const { data: workers } = await supabase
      .from('scraped_leads')
      .select('*')
      .eq('side', 'worker')
      .in('status', ['replied', 'converted'])
      .gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());

    const { data: contractors } = await supabase
      .from('scraped_leads')
      .select('*')
      .eq('side', 'contractor')
      .in('status', ['new', 'contacted', 'replied', 'converted'])
      .gte('created_at', new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString());

    const matches = [];
    for (const w of workers || []) {
      for (const c of contractors || []) {
        if (w.city === c.city && (w.trade === c.trade || w.trade === 'labourer' || c.trade === 'labourer')) {
          matches.push({ worker: w, contractor: c, trade: w.trade, city: w.city });
        }
      }
    }
    return matches;
  }
  return data || [];
}

// --- Stats ---

async function getStats() {
  const today = new Date().toISOString().split('T')[0] + 'T00:00:00Z';

  const [workers, contractors, jobs, matches, sent, replied, converted] = await Promise.all([
    supabase.from('scraped_leads').select('*', { count: 'exact', head: true }).eq('side', 'worker'),
    supabase.from('scraped_leads').select('*', { count: 'exact', head: true }).eq('side', 'contractor'),
    supabase.from('aggregated_jobs').select('*', { count: 'exact', head: true }).eq('expired', false),
    supabase.from('match_introductions').select('*', { count: 'exact', head: true }),
    supabase.from('scraped_leads').select('*', { count: 'exact', head: true }).eq('status', 'sent'),
    supabase.from('scraped_leads').select('*', { count: 'exact', head: true }).eq('status', 'replied'),
    supabase.from('scraped_leads').select('*', { count: 'exact', head: true }).eq('status', 'converted'),
  ]);

  return {
    workers: workers.count || 0,
    contractors: contractors.count || 0,
    activeJobs: jobs.count || 0,
    matches: matches.count || 0,
    sent: sent.count || 0,
    replied: replied.count || 0,
    converted: converted.count || 0,
  };
}

module.exports = {
  supabase,
  findContact, upsertContact, canContact, recordContact, markUnsubscribed, markBounced,
  insertLead, getLeadQueue, updateLeadStatus,
  upsertJob, expireStaleJobs,
  getDailySendCount, canSendToday,
  insertMatch, findMatches,
  getStats,
};
```

- [ ] **Step 2: Test database connection**

```bash
node -e "
require('dotenv').config();
const db = require('./scripts/hunt/db');
db.getStats().then(s => console.log('Stats:', s)).catch(e => console.error('Error:', e));
"
```

Expected: `Stats: { workers: 0, contractors: 0, activeJobs: 0, ... }`

- [ ] **Step 3: Commit**

```bash
git add scripts/hunt/db.js
git commit -m "feat: add marketplace hunter database helper"
```

---

### Task 4: Import Existing 304 Leads

**Files:**
- Create: `scripts/hunt/import-existing-leads.js`

The 304 existing contractor leads must go into `contact_registry` before any scraping starts, to prevent duplicate outreach.

- [ ] **Step 1: Write the import script**

```javascript
#!/usr/bin/env node
// Import existing contractor leads into contact_registry
// Prevents marketplace hunter from re-contacting known leads
require('dotenv').config({ path: require('path').join(__dirname, '..', '..', '.env') });
const { createClient } = require('@supabase/supabase-js');

const supabase = createClient(
  process.env.SUPABASE_URL,
  process.env.SUPABASE_SERVICE_ROLE_KEY
);

async function importLeads() {
  // Fetch existing leads from the Growth Engine leads table
  const { data: existingLeads, error } = await supabase
    .from('leads')
    .select('name, email, phone, metadata')
    .is('deleted_at', null);

  if (error) { console.error('Error fetching leads:', error.message); return; }
  console.log(`Found ${existingLeads.length} existing leads to import`);

  let imported = 0, skipped = 0, errors = 0;

  for (const lead of existingLeads) {
    if (!lead.email && !lead.phone && !lead.name) { skipped++; continue; }

    const city = lead.metadata?.city || lead.metadata?.location || null;
    const { error: insertError } = await supabase
      .from('contact_registry')
      .upsert({
        email: lead.email?.toLowerCase() || null,
        phone: lead.phone || null,
        name: lead.name || null,
        city: city?.toLowerCase() || null,
        side: 'contractor',
        source: 'manual',
        status: 'contacted',
      }, { onConflict: 'identity_key', ignoreDuplicates: true });

    if (insertError && insertError.code !== '23505') {
      console.error(`  Error: ${lead.name}: ${insertError.message}`);
      errors++;
    } else {
      imported++;
    }
  }

  console.log(`Done. Imported: ${imported}, Skipped: ${skipped}, Errors: ${errors}`);
}

importLeads();
```

- [ ] **Step 2: Run the import**

```bash
node scripts/hunt/import-existing-leads.js
```

Expected: "Found 304 existing leads to import" → "Done. Imported: ~300+, Skipped: ~few, Errors: 0"

- [ ] **Step 3: Verify**

```bash
node -e "
require('dotenv').config();
const {createClient}=require('@supabase/supabase-js');
const sb=createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
sb.from('contact_registry').select('*',{count:'exact',head:true}).eq('source','manual').then(r=>console.log('Imported contacts:', r.count));
"
```

- [ ] **Step 4: Commit**

```bash
git add scripts/hunt/import-existing-leads.js
git commit -m "feat: import 304 existing leads into contact registry"
```

---

## Chunk 2: Job Aggregator — Gumtree Scraper

### Task 5: Gumtree Job Scraper

**Files:**
- Create: `scripts/hunt/scrapers/gumtree-jobs.js`

Scrapes construction job listings from Gumtree (server-rendered HTML, Cheerio+Axios).

- [ ] **Step 1: Install Cheerio dependency**

```bash
npm install cheerio axios
```

- [ ] **Step 2: Write the Gumtree jobs scraper**

```javascript
#!/usr/bin/env node
// Gumtree construction job scraper
// Scrapes job listings from Gumtree Trades & Services
const axios = require('axios');
const cheerio = require('cheerio');
const { GUMTREE_URLS, normalizeTrade } = require('../config');

const USER_AGENTS = [
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
  'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
];

function randomAgent() {
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}

async function scrapeGumtreeJobs(city, pages = 3) {
  const baseUrl = GUMTREE_URLS.jobs[city];
  if (!baseUrl) throw new Error(`No Gumtree jobs URL for city: ${city}`);

  const results = [];

  for (let page = 1; page <= pages; page++) {
    const url = page === 1 ? baseUrl : `${baseUrl}/page-${page}`;
    console.log(`  Scraping: ${url}`);

    try {
      const { data: html } = await axios.get(url, {
        headers: { 'User-Agent': randomAgent() },
        timeout: 15000,
      });

      const $ = cheerio.load(html);

      // Gumtree listing cards — selector may need updating if site changes
      $('[class*="user-ad-row"]').each((_, el) => {
        const $el = $(el);
        const title = $el.find('[class*="user-ad-row__title"]').text().trim();
        const link = $el.find('a[href*="/s-ad/"]').attr('href');
        const location = $el.find('[class*="user-ad-row__location"]').text().trim();
        const price = $el.find('[class*="user-ad-row__price"]').text().trim();
        const description = $el.find('[class*="user-ad-row__description"]').text().trim();
        const dateText = $el.find('[class*="user-ad-row__age"]').text().trim();

        if (!title || !link) return;

        const sourceUrl = link.startsWith('http') ? link : `https://www.gumtree.com.au${link}`;

        results.push({
          title,
          company_name: null, // Gumtree doesn't always show company
          location: location || city,
          suburb: location?.split(',')[0]?.trim() || null,
          pay_rate: price || null,
          trade_type: normalizeTrade(title),
          description_snippet: (description || '').slice(0, 200),
          source_url: sourceUrl.split('?')[0], // Remove query params for dedup
          source: 'gumtree',
          date_posted: null, // Would need parsing dateText
          scraped_at: new Date().toISOString(),
        });
      });

      // Rate limit between pages
      if (page < pages) await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));

    } catch (err) {
      console.error(`  Error scraping page ${page} for ${city}:`, err.message);
    }
  }

  console.log(`  Found ${results.length} listings for ${city}`);
  return results;
}

module.exports = { scrapeGumtreeJobs };

// CLI mode
if (require.main === module) {
  const city = process.argv[2] || 'sydney';
  scrapeGumtreeJobs(city).then(results => {
    console.log(JSON.stringify(results.slice(0, 3), null, 2));
    console.log(`Total: ${results.length}`);
  });
}
```

- [ ] **Step 3: Test the scraper**

```bash
node scripts/hunt/scrapers/gumtree-jobs.js sydney
```

Expected: JSON output of job listings from Gumtree Sydney. If Gumtree's HTML structure has changed, adjust the CSS selectors in the scraper.

- [ ] **Step 4: Commit**

```bash
git add scripts/hunt/scrapers/gumtree-jobs.js package.json package-lock.json
git commit -m "feat: add gumtree job scraper"
```

---

### Task 6: Gumtree "Looking for Work" Scraper

**Files:**
- Create: `scripts/hunt/scrapers/gumtree-workers.js`

Same pattern as Task 5 but targets the "Resumes" / "Looking for Work" section.

- [ ] **Step 1: Write the worker scraper**

```javascript
#!/usr/bin/env node
// Gumtree "Looking for Work" scraper
// Finds people actively seeking construction work
const axios = require('axios');
const cheerio = require('cheerio');
const { GUMTREE_URLS, normalizeTrade } = require('../config');

const USER_AGENTS = [
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
];

function randomAgent() {
  return USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)];
}

// Extract contact info from ad text
function extractContacts(text) {
  const emailMatch = text.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
  const phoneMatch = text.match(/(?:04\d{2}[\s.-]?\d{3}[\s.-]?\d{3}|\+?61\s?\d[\s.-]?\d{4}[\s.-]?\d{4})/);
  return {
    email: emailMatch ? emailMatch[0] : null,
    phone: phoneMatch ? phoneMatch[0].replace(/[\s.-]/g, '') : null,
  };
}

async function scrapeGumtreeWorkers(city, pages = 3) {
  const baseUrl = GUMTREE_URLS.looking_for_work[city];
  if (!baseUrl) throw new Error(`No Gumtree looking-for-work URL for city: ${city}`);

  const results = [];

  for (let page = 1; page <= pages; page++) {
    const url = page === 1 ? baseUrl : `${baseUrl}/page-${page}`;
    console.log(`  Scraping workers: ${url}`);

    try {
      const { data: html } = await axios.get(url, {
        headers: { 'User-Agent': randomAgent() },
        timeout: 15000,
      });

      const $ = cheerio.load(html);

      $('[class*="user-ad-row"]').each((_, el) => {
        const $el = $(el);
        const title = $el.find('[class*="user-ad-row__title"]').text().trim();
        const link = $el.find('a[href*="/s-ad/"]').attr('href');
        const location = $el.find('[class*="user-ad-row__location"]').text().trim();
        const description = $el.find('[class*="user-ad-row__description"]').text().trim();

        if (!title || !link) return;

        const sourceUrl = link.startsWith('http') ? link : `https://www.gumtree.com.au${link}`;
        const fullText = `${title} ${description}`;
        const contacts = extractContacts(fullText);

        // Try to extract name from title (e.g., "Experienced Labourer - John")
        const nameMatch = title.match(/[-–—]\s*(\w+(?:\s\w+)?)\s*$/);
        const name = nameMatch ? nameMatch[1] : null;

        results.push({
          source: 'gumtree',
          source_url: sourceUrl.split('?')[0],
          side: 'worker',
          name,
          email: contacts.email,
          phone: contacts.phone,
          trade: normalizeTrade(title),
          city,
          suburb: location?.split(',')[0]?.trim() || null,
          description: (description || title).slice(0, 500),
          post_date: null,
        });
      });

      if (page < pages) await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));

    } catch (err) {
      console.error(`  Error scraping workers page ${page} for ${city}:`, err.message);
    }
  }

  console.log(`  Found ${results.length} worker leads for ${city}`);
  return results;
}

module.exports = { scrapeGumtreeWorkers };

if (require.main === module) {
  const city = process.argv[2] || 'sydney';
  scrapeGumtreeWorkers(city).then(results => {
    console.log(JSON.stringify(results.slice(0, 3), null, 2));
    console.log(`Total: ${results.length}`);
  });
}
```

- [ ] **Step 2: Test**

```bash
node scripts/hunt/scrapers/gumtree-workers.js sydney
```

Expected: JSON output of worker leads. Some may have extracted email/phone.

- [ ] **Step 3: Commit**

```bash
git add scripts/hunt/scrapers/gumtree-workers.js
git commit -m "feat: add gumtree worker scraper"
```

---

### Task 7: Job Aggregator Main Script

**Files:**
- Create: `scripts/hunt/job-aggregator.js`

Orchestrates all job scrapers, stores results in Supabase, expires old listings.

- [ ] **Step 1: Write the aggregator**

```javascript
#!/usr/bin/env node
// Job Aggregator — scrapes construction jobs from Gumtree (+ Seek/Indeed later)
// Stores in aggregated_jobs table, expires stale listings
// Usage: node scripts/hunt/job-aggregator.js [--city CITY]
const { scrapeGumtreeJobs } = require('./scrapers/gumtree-jobs');
const { upsertJob, expireStaleJobs } = require('./db');
const { CITIES } = require('./config');

async function run() {
  const cityArg = process.argv.find((a, i) => process.argv[i - 1] === '--city');
  const cities = cityArg ? [cityArg] : CITIES;
  const dryRun = process.argv.includes('--dry-run');

  console.log(`[Job Aggregator] ${new Date().toISOString()}`);
  console.log(`Cities: ${cities.join(', ')}${dryRun ? ' (DRY RUN)' : ''}`);

  let totalNew = 0, totalSkipped = 0;

  for (const city of cities) {
    console.log(`\n--- ${city.toUpperCase()} ---`);

    // Gumtree jobs
    const gumtreeJobs = await scrapeGumtreeJobs(city);

    if (dryRun) {
      console.log(`  [DRY RUN] Would insert ${gumtreeJobs.length} Gumtree jobs`);
      totalNew += gumtreeJobs.length;
      continue;
    }

    for (const job of gumtreeJobs) {
      const result = await upsertJob(job);
      if (result) totalNew++;
      else totalSkipped++;
    }

    // TODO: Add Seek scraper (Playwright) — Phase 2
    // TODO: Add Indeed scraper (Playwright) — Phase 2

    // Rate limit between cities
    await new Promise(r => setTimeout(r, 5000));
  }

  // Expire old listings
  if (!dryRun) {
    const expired = await expireStaleJobs(14);
    console.log(`\nExpired ${expired} stale listings (>14 days old)`);
  }

  console.log(`\n[Job Aggregator] Done. New: ${totalNew}, Skipped: ${totalSkipped}`);
}

run().catch(err => {
  console.error('[Job Aggregator] Fatal error:', err);
  process.exit(1);
});
```

- [ ] **Step 2: Test with dry run**

```bash
node scripts/hunt/job-aggregator.js --dry-run
```

Expected: Scrapes Gumtree, shows listings found, doesn't insert into DB.

- [ ] **Step 3: Test real run**

```bash
node scripts/hunt/job-aggregator.js --city sydney
```

Expected: Inserts jobs into `aggregated_jobs` table.

- [ ] **Step 4: Verify in database**

```bash
node -e "
require('dotenv').config();
const db = require('./scripts/hunt/db');
db.supabase.from('aggregated_jobs').select('title,location,source').limit(5).then(r => console.log(r.data));
"
```

- [ ] **Step 5: Commit**

```bash
git add scripts/hunt/job-aggregator.js
git commit -m "feat: add job aggregator (gumtree)"
```

---

## Chunk 3: Worker & Contractor Hunters + Outreach

### Task 8: Worker Hunter

**Files:**
- Create: `scripts/hunt/worker-hunter.js`

Orchestrates worker scraping, dedup via contact registry, queues for outreach.

- [ ] **Step 1: Write the worker hunter**

```javascript
#!/usr/bin/env node
// Worker Hunter — finds people looking for construction work
// Scrapes Gumtree looking-for-work, checks dedup, queues outreach
// Usage: node scripts/hunt/worker-hunter.js [--city CITY] [--dry-run]
const { scrapeGumtreeWorkers } = require('./scrapers/gumtree-workers');
const { upsertContact, insertLead } = require('./db');
const { CITIES } = require('./config');

async function run() {
  const cityArg = process.argv.find((a, i) => process.argv[i - 1] === '--city');
  const cities = cityArg ? [cityArg] : CITIES;
  const dryRun = process.argv.includes('--dry-run');

  console.log(`[Worker Hunter] ${new Date().toISOString()}`);
  console.log(`Cities: ${cities.join(', ')}${dryRun ? ' (DRY RUN)' : ''}`);

  let totalNew = 0, totalDupes = 0;

  for (const city of cities) {
    console.log(`\n--- ${city.toUpperCase()} ---`);
    const workers = await scrapeGumtreeWorkers(city);

    for (const worker of workers) {
      if (dryRun) {
        console.log(`  [DRY RUN] ${worker.name || 'Unknown'} — ${worker.trade} — ${worker.email || worker.phone || 'no contact'}`);
        totalNew++;
        continue;
      }

      // Check/create in contact registry
      const { contact, isNew } = await upsertContact({
        email: worker.email,
        phone: worker.phone,
        name: worker.name,
        city: worker.city,
        trade: worker.trade,
        side: 'worker',
        source: 'gumtree',
      });

      if (!isNew) {
        totalDupes++;
        continue;
      }

      // Insert lead and link to contact
      await insertLead({
        ...worker,
        contact_registry_id: contact.id,
        status: 'new',
        scraped_at: new Date().toISOString(),
      });

      totalNew++;
    }

    await new Promise(r => setTimeout(r, 5000));
  }

  console.log(`\n[Worker Hunter] Done. New leads: ${totalNew}, Duplicates skipped: ${totalDupes}`);
}

run().catch(err => {
  console.error('[Worker Hunter] Fatal error:', err);
  process.exit(1);
});
```

- [ ] **Step 2: Test with dry run**

```bash
node scripts/hunt/worker-hunter.js --dry-run --city sydney
```

- [ ] **Step 3: Commit**

```bash
git add scripts/hunt/worker-hunter.js
git commit -m "feat: add worker hunter"
```

---

### Task 9: Contractor Hunter

**Files:**
- Create: `scripts/hunt/contractor-hunter.js`

Same pattern as worker hunter but scrapes Gumtree job posters (contractor side).

- [ ] **Step 1: Write the Gumtree contractor lead scraper**

```javascript
#!/usr/bin/env node
// Contractor Hunter — finds companies hiring construction workers
// Scrapes Gumtree job ads for poster contact info
const axios = require('axios');
const cheerio = require('cheerio');
const { upsertContact, insertLead } = require('./db');
const { CITIES, GUMTREE_URLS, normalizeTrade } = require('./config');

const USER_AGENTS = [
  'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
  'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36',
];

function extractContacts(text) {
  const emailMatch = text.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
  const phoneMatch = text.match(/(?:04\d{2}[\s.-]?\d{3}[\s.-]?\d{3}|\+?61\s?\d[\s.-]?\d{4}[\s.-]?\d{4})/);
  return {
    email: emailMatch ? emailMatch[0] : null,
    phone: phoneMatch ? phoneMatch[0].replace(/[\s.-]/g, '') : null,
  };
}

async function scrapeGumtreeContractors(city, pages = 3) {
  const baseUrl = GUMTREE_URLS.jobs[city];
  if (!baseUrl) return [];

  const results = [];

  for (let page = 1; page <= pages; page++) {
    const url = page === 1 ? baseUrl : `${baseUrl}/page-${page}`;
    console.log(`  Scraping contractor leads: ${url}`);

    try {
      const { data: html } = await axios.get(url, {
        headers: { 'User-Agent': USER_AGENTS[Math.floor(Math.random() * USER_AGENTS.length)] },
        timeout: 15000,
      });

      const $ = cheerio.load(html);

      $('[class*="user-ad-row"]').each((_, el) => {
        const $el = $(el);
        const title = $el.find('[class*="user-ad-row__title"]').text().trim();
        const link = $el.find('a[href*="/s-ad/"]').attr('href');
        const location = $el.find('[class*="user-ad-row__location"]').text().trim();
        const description = $el.find('[class*="user-ad-row__description"]').text().trim();
        const price = $el.find('[class*="user-ad-row__price"]').text().trim();

        if (!title || !link) return;

        const sourceUrl = link.startsWith('http') ? link : `https://www.gumtree.com.au${link}`;
        const contacts = extractContacts(`${title} ${description}`);

        // Only capture leads with contact info (can actually outreach to them)
        if (!contacts.email && !contacts.phone) return;

        results.push({
          source: 'gumtree',
          source_url: sourceUrl.split('?')[0],
          side: 'contractor',
          name: null, // Company name usually in title
          email: contacts.email,
          phone: contacts.phone,
          trade: normalizeTrade(title),
          city,
          suburb: location?.split(',')[0]?.trim() || null,
          description: title.slice(0, 500),
          pay_rate: price || null,
          post_date: null,
        });
      });

      if (page < pages) await new Promise(r => setTimeout(r, 2000 + Math.random() * 3000));
    } catch (err) {
      console.error(`  Error scraping page ${page}:`, err.message);
    }
  }

  console.log(`  Found ${results.length} contractor leads for ${city}`);
  return results;
}

async function run() {
  const cityArg = process.argv.find((a, i) => process.argv[i - 1] === '--city');
  const cities = cityArg ? [cityArg] : CITIES;
  const dryRun = process.argv.includes('--dry-run');

  console.log(`[Contractor Hunter] ${new Date().toISOString()}`);

  let totalNew = 0, totalDupes = 0;

  for (const city of cities) {
    console.log(`\n--- ${city.toUpperCase()} ---`);
    const contractors = await scrapeGumtreeContractors(city);

    for (const lead of contractors) {
      if (dryRun) {
        console.log(`  [DRY RUN] ${lead.email || lead.phone} — ${lead.trade}`);
        totalNew++;
        continue;
      }

      const { contact, isNew } = await upsertContact({
        email: lead.email,
        phone: lead.phone,
        name: lead.name,
        city: lead.city,
        trade: lead.trade,
        side: 'contractor',
        source: 'gumtree',
      });

      if (!isNew) { totalDupes++; continue; }

      await insertLead({
        ...lead,
        contact_registry_id: contact.id,
        status: 'new',
        scraped_at: new Date().toISOString(),
      });
      totalNew++;
    }

    await new Promise(r => setTimeout(r, 5000));
  }

  console.log(`\n[Contractor Hunter] Done. New: ${totalNew}, Dupes: ${totalDupes}`);
}

run().catch(err => {
  console.error('[Contractor Hunter] Fatal error:', err);
  process.exit(1);
});

module.exports = { scrapeGumtreeContractors };
```

- [ ] **Step 2: Test with dry run**

```bash
node scripts/hunt/contractor-hunter.js --dry-run --city sydney
```

- [ ] **Step 3: Commit**

```bash
git add scripts/hunt/contractor-hunter.js
git commit -m "feat: add contractor hunter"
```

---

### Task 10: Outreach Sender

**Files:**
- Create: `scripts/hunt/outreach-sender.js`

Processes the approved outreach queue. Sends email (Gmail API) or SMS (Twilio). Runs as PM2 process, checks queue every 30 minutes.

- [ ] **Step 1: Write the outreach sender**

```javascript
#!/usr/bin/env node
// Outreach Sender — sends email/SMS from approved queue
// Runs as PM2 process, checks every 30 min
// Reuses Gmail API from hostel campaign
const path = require('path');
const { sendPlainText } = require('../campaign/gmail-api');
const { getLeadQueue, updateLeadStatus, canContact, recordContact, canSendToday } = require('./db');
const { TEMPLATES, fillTemplate, SEND_DELAY_MS } = require('./config');

const FROM = 'Michael McLoughlin <michael@rateright.com.au>';
const CHECK_INTERVAL = 30 * 60 * 1000; // 30 minutes
const UNSUBSCRIBE_BASE = 'https://rateright.com.au/api/unsubscribe?token=';

function makeUnsubscribeUrl(contactId) {
  // Simple base64 encoding — replace with JWT in production
  return UNSUBSCRIBE_BASE + Buffer.from(contactId).toString('base64');
}

async function processQueue() {
  console.log(`[Outreach] Checking queue at ${new Date().toISOString()}`);

  // Check daily limits
  const { allowed: emailAllowed, sent: emailSent, limit: emailLimit } = await canSendToday('email');
  console.log(`  Email budget: ${emailSent}/${emailLimit}`);
  if (!emailAllowed) {
    console.log('  Email daily limit reached. Skipping.');
    return;
  }

  // Get approved leads (workers first, then contractors)
  const workers = await getLeadQueue({ side: 'worker', status: 'approved', limit: 10 });
  const contractors = await getLeadQueue({ side: 'contractor', status: 'approved', limit: 10 });
  const queue = [...workers, ...contractors];

  if (queue.length === 0) {
    console.log('  Queue empty.');
    return;
  }

  console.log(`  ${queue.length} leads in queue`);

  let sent = 0;
  for (const lead of queue) {
    // Re-check daily limit
    const { allowed } = await canSendToday('email');
    if (!allowed) { console.log('  Daily limit hit mid-batch. Stopping.'); break; }

    // Check contact registry
    if (lead.contact_registry_id) {
      const check = await canContact(lead.contact_registry_id);
      if (!check.allowed) {
        console.log(`  Skipping ${lead.name || lead.email}: ${check.reason}`);
        await updateLeadStatus(lead.id, 'skipped');
        continue;
      }
    }

    // Pick template based on side
    const template = lead.side === 'worker'
      ? TEMPLATES.worker_email
      : TEMPLATES.contractor_email;

    if (!lead.email) {
      console.log(`  Skipping ${lead.name}: no email (SMS support TODO)`);
      await updateLeadStatus(lead.id, 'skipped');
      continue;
    }

    const vars = {
      name: lead.name || 'there',
      trade: lead.trade || 'construction',
      city: (lead.city || 'Sydney').charAt(0).toUpperCase() + (lead.city || 'sydney').slice(1),
      city_slug: (lead.city || 'sydney').toLowerCase(),
      source: lead.source || 'gumtree',
      suburb: lead.suburb || lead.city || 'your area',
      unsubscribe_url: lead.contact_registry_id ? makeUnsubscribeUrl(lead.contact_registry_id) : '',
    };

    const subject = fillTemplate(template.subject, vars);
    const body = fillTemplate(template.body, vars);

    try {
      console.log(`  Sending to ${lead.email} (${lead.side})...`);
      const result = await sendPlainText(lead.email, FROM, subject, body);

      await updateLeadStatus(lead.id, 'sent', {
        outreach_channel: 'email',
        outreach_sent_at: new Date().toISOString(),
        outreach_message_id: result?.messageId || null,
      });

      if (lead.contact_registry_id) {
        await recordContact(lead.contact_registry_id);
      }

      sent++;
      console.log(`  Sent! (${sent} total this batch)`);

      // Delay between sends
      if (sent < queue.length) {
        const delay = SEND_DELAY_MS + Math.random() * 60000; // 30-31 min
        console.log(`  Waiting ${Math.round(delay / 60000)} min before next send...`);
        await new Promise(r => setTimeout(r, delay));
      }

    } catch (err) {
      console.error(`  Error sending to ${lead.email}:`, err.message);
      // Don't update status — will retry next cycle
    }
  }

  console.log(`[Outreach] Batch done. Sent: ${sent}`);
}

// PM2 persistent loop
async function loop() {
  while (true) {
    try {
      await processQueue();
    } catch (err) {
      console.error('[Outreach] Error in cycle:', err.message);
    }
    console.log(`[Outreach] Sleeping ${CHECK_INTERVAL / 60000} min...`);
    await new Promise(r => setTimeout(r, CHECK_INTERVAL));
  }
}

if (process.argv.includes('--once')) {
  processQueue().catch(console.error);
} else {
  loop();
}
```

- [ ] **Step 2: Test with --once (requires approved leads in DB)**

```bash
# First, approve a test lead manually:
node -e "
require('dotenv').config();
const db = require('./scripts/hunt/db');
db.supabase.from('scraped_leads').update({status:'approved'}).eq('status','new').limit(1).then(r=>console.log('Approved:', r));
"
# Then run sender:
node scripts/hunt/outreach-sender.js --once
```

- [ ] **Step 3: Commit**

```bash
git add scripts/hunt/outreach-sender.js
git commit -m "feat: add outreach sender with rate limiting"
```

---

### Task 11: Reply Monitor

**Files:**
- Create: `scripts/hunt/reply-monitor.js`

Monitors Gmail for replies to outreach emails. Classifies as positive/negative/bounce. Updates contact registry. Adapted from hostel campaign reply monitor.

- [ ] **Step 1: Write the reply monitor**

```javascript
#!/usr/bin/env node
// Reply Monitor — watches for replies to outreach emails
// Classifies: positive, negative, bounce, question
// Updates contact_registry and scraped_leads status
const { listMessages, getThread } = require('../campaign/gmail-api');
const { supabase } = require('./db');

const CHECK_INTERVAL = 30 * 60 * 1000; // 30 min
const BOUNCE_SENDERS = ['mailer-daemon@', 'postmaster@'];
const NEGATIVE_KEYWORDS = ['not interested', 'remove me', 'stop', 'unsubscribe', 'no thanks', 'don\'t contact'];
const POSITIVE_KEYWORDS = ['interested', 'tell me more', 'how does it work', 'sign me up', 'sounds good', 'yes'];

function classifyReply(text, from) {
  const lower = (text || '').toLowerCase();
  const fromLower = (from || '').toLowerCase();

  if (BOUNCE_SENDERS.some(b => fromLower.includes(b))) return 'bounce';
  if (NEGATIVE_KEYWORDS.some(k => lower.includes(k))) return 'negative';
  if (POSITIVE_KEYWORDS.some(k => lower.includes(k))) return 'positive';
  return 'question'; // Ambiguous — may need human review
}

async function checkReplies() {
  console.log(`[Reply Monitor] Checking at ${new Date().toISOString()}`);

  // Get all sent leads with message IDs
  const { data: sentLeads } = await supabase
    .from('scraped_leads')
    .select('id, outreach_message_id, contact_registry_id, email, name, side')
    .eq('status', 'sent')
    .not('outreach_message_id', 'is', null);

  if (!sentLeads || sentLeads.length === 0) {
    console.log('  No sent leads to monitor.');
    return;
  }

  console.log(`  Monitoring ${sentLeads.length} sent messages`);
  let newReplies = 0;

  for (const lead of sentLeads) {
    try {
      // Search for replies to this thread
      const query = `to:michael@rateright.com.au from:${lead.email} newer_than:7d`;
      const messages = await listMessages(query, 5);

      if (!messages || messages.length === 0) continue;

      // Check if any message is a reply (not our original send)
      for (const msg of messages) {
        if (msg.id === lead.outreach_message_id) continue; // Skip our own message

        const thread = await getThread(msg.threadId);
        if (!thread || !thread.messages) continue;

        // Find the reply message
        const reply = thread.messages.find(m => m.id !== lead.outreach_message_id);
        if (!reply) continue;

        const replyText = reply.snippet || '';
        const replyFrom = reply.payload?.headers?.find(h => h.name === 'From')?.value || '';
        const classification = classifyReply(replyText, replyFrom);

        console.log(`  Reply from ${lead.email}: "${replyText.slice(0, 80)}..." → ${classification}`);

        // Update lead status
        await supabase
          .from('scraped_leads')
          .update({ status: 'replied' })
          .eq('id', lead.id);

        // Update contact registry
        if (lead.contact_registry_id) {
          const statusMap = {
            bounce: 'bounced',
            negative: 'unsubscribed',
            positive: 'replied',
            question: 'replied',
          };
          await supabase
            .from('contact_registry')
            .update({
              status: statusMap[classification],
              ...(classification === 'negative' ? { unsubscribed_at: new Date().toISOString() } : {}),
              updated_at: new Date().toISOString(),
            })
            .eq('id', lead.contact_registry_id);
        }

        newReplies++;
        break; // Only process first reply per lead
      }
    } catch (err) {
      console.error(`  Error checking ${lead.email}:`, err.message);
    }
  }

  console.log(`[Reply Monitor] Done. New replies: ${newReplies}`);
}

async function loop() {
  while (true) {
    try { await checkReplies(); } catch (err) { console.error('[Reply Monitor] Error:', err.message); }
    await new Promise(r => setTimeout(r, CHECK_INTERVAL));
  }
}

if (process.argv.includes('--once')) {
  checkReplies().catch(console.error);
} else {
  loop();
}
```

- [ ] **Step 2: Commit**

```bash
git add scripts/hunt/reply-monitor.js
git commit -m "feat: add reply monitor with classification"
```

---

## Chunk 4: Matchmaker + Notion + Skills

### Task 12: Matchmaker

**Files:**
- Create: `scripts/hunt/matchmaker.js`

Detects worker+contractor matches by city and trade. Only matches workers who have engaged (replied/converted). Creates introduction records.

- [ ] **Step 1: Write the matchmaker**

```javascript
#!/usr/bin/env node
// Matchmaker — detects city+trade matches between workers and contractors
// Only introduces workers who have replied/converted (privacy safeguard)
const { findMatches, insertMatch, canContact, getLeadQueue } = require('./db');

async function run() {
  console.log(`[Matchmaker] ${new Date().toISOString()}`);

  const matches = await findMatches();
  console.log(`  Found ${matches.length} potential matches`);

  let newMatches = 0;
  for (const { worker, contractor, trade, city } of matches) {
    const result = await insertMatch({
      worker_lead_id: worker.id,
      contractor_lead_id: contractor.id,
      trade,
      city,
      score: (worker.trade === contractor.trade ? 3 : 1) +
             (new Date(worker.created_at) > new Date(Date.now() - 3 * 86400000) ? 2 : 1),
    });

    if (result) {
      newMatches++;
      console.log(`  Match: ${worker.name || worker.email || 'worker'} ↔ ${contractor.name || contractor.email || 'contractor'} (${trade}, ${city})`);
    }
  }

  console.log(`[Matchmaker] Done. New matches: ${newMatches}`);
}

run().catch(err => {
  console.error('[Matchmaker] Fatal error:', err);
  process.exit(1);
});
```

- [ ] **Step 2: Commit**

```bash
git add scripts/hunt/matchmaker.js
git commit -m "feat: add matchmaker engine"
```

---

### Task 13: Notion Summary Generator

**Files:**
- Create: `scripts/hunt/notion-summary.js`

Generates daily summary and posts to Notion via MCP. Falls back to console output if Notion is unavailable.

- [ ] **Step 1: Write the summary generator**

```javascript
#!/usr/bin/env node
// Notion Summary Generator — daily hunt summary
// Posts to Notion via MCP integration
const { getStats, supabase } = require('./db');

async function generateSummary() {
  const stats = await getStats();
  const today = new Date().toISOString().split('T')[0];

  // Get today's specific numbers
  const { count: todayWorkers } = await supabase
    .from('scraped_leads').select('*', { count: 'exact', head: true })
    .eq('side', 'worker').gte('created_at', today + 'T00:00:00Z');

  const { count: todayContractors } = await supabase
    .from('scraped_leads').select('*', { count: 'exact', head: true })
    .eq('side', 'contractor').gte('created_at', today + 'T00:00:00Z');

  const { count: todayJobs } = await supabase
    .from('aggregated_jobs').select('*', { count: 'exact', head: true })
    .gte('scraped_at', today + 'T00:00:00Z');

  const { count: todayMatches } = await supabase
    .from('match_introductions').select('*', { count: 'exact', head: true })
    .gte('created_at', today + 'T00:00:00Z');

  const { count: pendingApproval } = await supabase
    .from('scraped_leads').select('*', { count: 'exact', head: true })
    .eq('status', 'new');

  const summary = `# RateRight Daily Hunt — ${today}

## Scrape Results (today)
- Workers found: ${todayWorkers || 0}
- Contractors found: ${todayContractors || 0}
- Jobs aggregated: ${todayJobs || 0}

## Matches
- New matches today: ${todayMatches || 0}

## Outreach
- Pending approval: ${pendingApproval || 0}
- Total sent (all time): ${stats.sent}
- Total replied: ${stats.replied}
- Total converted: ${stats.converted}

## All Time Totals
- Workers scraped: ${stats.workers}
- Contractors scraped: ${stats.contractors}
- Active job listings: ${stats.activeJobs}
- Total matches: ${stats.matches}

## Funnel
Scraped → Contacted → Replied → Converted
${stats.workers + stats.contractors} → ${stats.sent} → ${stats.replied} → ${stats.converted}
`;

  console.log(summary);
  return summary;
}

generateSummary().catch(console.error);

module.exports = { generateSummary };
```

- [ ] **Step 2: Commit**

```bash
git add scripts/hunt/notion-summary.js
git commit -m "feat: add notion summary generator"
```

---

### Task 14: Create /pulse Skill

**Files:**
- Create: `C:\Users\mclou\.claude\skills\pulse.md`

- [ ] **Step 1: Write the skill file**

```markdown
---
name: pulse
description: One-command health check of RateRight marketplace hunter system — platform signups, scraper output, outreach stats, agent health
user_invocable: true
---

# /pulse — System Health Check

Run the marketplace hunter stats and display a compact status dashboard.

## What to do

1. Run: `node scripts/hunt/notion-summary.js` from `C:\Users\mclou\rateright-growth-deploy`
2. Also query Supabase directly for platform stats:
   - `profiles` table: count workers and contractors registered on the actual platform
   - `jobs` table: count active job postings
   - `matches` table: count hires
   - `payments` table: count completed payments
3. If the user passes an argument:
   - `workers` — show worker breakdown by city and trade
   - `contractors` — show contractor breakdown
   - `funnel` — show full conversion funnel with percentages
   - `agents` — SSH to VPS and run `pm2 status` to check agent health
   - `today` — show only today's activity
4. Format output as compact tables

## Arguments

- `/pulse` — full status
- `/pulse workers` — worker detail
- `/pulse contractors` — contractor detail
- `/pulse funnel` — conversion funnel
- `/pulse agents` — VPS agent status
- `/pulse today` — today only
```

- [ ] **Step 2: Commit**

```bash
git add ~/.claude/skills/pulse.md
git commit -m "feat: add /pulse skill"
```

---

### Task 15: Create /hunt Skill

**Files:**
- Create: `C:\Users\mclou\.claude\skills\hunt.md`

- [ ] **Step 1: Write the skill file**

```markdown
---
name: hunt
description: Trigger marketplace hunter scrape cycles — find construction workers and contractors from Gumtree, Seek, Indeed, Facebook
user_invocable: true
---

# /hunt — Marketplace Lead Hunter

Trigger scraper runs to find construction workers and contractors.

## What to do

Parse the user's arguments and run the appropriate scraper scripts from `C:\Users\mclou\rateright-growth-deploy`:

| Command | Action |
|---|---|
| `/hunt` | Run all scrapers (job-aggregator + worker-hunter + contractor-hunter) for all cities |
| `/hunt workers` | Run worker-hunter only, all cities |
| `/hunt contractors` | Run contractor-hunter only, all cities |
| `/hunt workers sydney` | Run worker-hunter, Sydney only |
| `/hunt contractors melbourne` | Run contractor-hunter, Melbourne only |
| `/hunt jobs` | Run job-aggregator only |
| `/hunt --dry-run` | Run all scrapers in dry-run mode (show results, don't insert) |
| `/hunt --status` | Show today's scrape stats (same as /pulse today) |

## Scripts to run

```bash
# Job aggregator
node scripts/hunt/job-aggregator.js [--city CITY] [--dry-run]

# Worker hunter
node scripts/hunt/worker-hunter.js [--city CITY] [--dry-run]

# Contractor hunter
node scripts/hunt/contractor-hunter.js [--city CITY] [--dry-run]
```

## After running

Show a summary of what was found: new leads, duplicates skipped, any errors. If not dry-run, mention how many leads are now in the queue waiting for approval.
```

- [ ] **Step 2: Commit**

```bash
git add ~/.claude/skills/hunt.md
git commit -m "feat: add /hunt skill"
```

---

### Task 16: Create /matchmaker Skill

**Files:**
- Create: `C:\Users\mclou\.claude\skills\matchmaker.md`

- [ ] **Step 1: Write the skill file**

```markdown
---
name: matchmaker
description: Run matching engine and manage the outreach queue — approve leads, check conversion stats, pause/resume automated sending
user_invocable: true
---

# /matchmaker — Match Engine & Outreach Control

Run the matching engine and manage outreach queue.

## What to do

Parse arguments and execute the appropriate action:

| Command | Action |
|---|---|
| `/matchmaker` | Run matchmaker.js, show today's matches |
| `/matchmaker approve` | Set all `status='new'` leads to `status='approved'` in scraped_leads |
| `/matchmaker approve 5` | Approve first 5 pending leads only |
| `/matchmaker reject {id}` | Set specific lead to `status='rejected'` |
| `/matchmaker stats` | Show full funnel: scraped → contacted → replied → signed up → hired |
| `/matchmaker pause` | Pause automated outreach (set flag in Supabase) |
| `/matchmaker resume` | Resume automated outreach |
| `/matchmaker auto-enable` | Switch from manual approval to auto-send mode |
| `/matchmaker auto-disable` | Switch back to manual approval mode |
| `/matchmaker queue` | Show current outreach queue with lead details |

## Implementation

For `approve`, run:
```javascript
// Approve all pending
const { createClient } = require('@supabase/supabase-js');
const sb = createClient(process.env.SUPABASE_URL, process.env.SUPABASE_SERVICE_ROLE_KEY);
const { data, count } = await sb.from('scraped_leads').update({ status: 'approved' }).eq('status', 'new').select('*', { count: 'exact' });
console.log(`Approved ${count} leads for outreach`);
```

For `stats`, query across tables and show percentages at each funnel step.

For `queue`, show: name, email/phone, trade, city, side, source, created_at — as a table.

For `matchmaker` (no args), run: `node scripts/hunt/matchmaker.js`
```

- [ ] **Step 2: Commit**

```bash
git add ~/.claude/skills/matchmaker.md
git commit -m "feat: add /matchmaker skill"
```

---

### Task 17: Create /post Skill

**Files:**
- Create: `C:\Users\mclou\.claude\skills\post.md`

- [ ] **Step 1: Write the skill file**

```markdown
---
name: post
description: Facebook group posting assistant — compose individualized posts, execute via headed Playwright browser, track posting history
user_invocable: true
---

# /post — Facebook Group Posting

Manage Facebook group posting via headed Playwright browser on Rocky's PC.

## What to do

| Command | Action |
|---|---|
| `/post` | Show today's posting schedule from groups.json |
| `/post compose` | Generate individualized posts for today's target groups |
| `/post go` | Execute posts via headed Playwright browser |
| `/post groups` | List all groups with last posted date and engagement |
| `/post add "Group Name"` | Add a group to groups.json |
| `/post remove "Group Name"` | Remove a group from groups.json |
| `/post history` | Show posting history from Supabase facebook_post_log |
| `/post schedule` | Show/edit weekly posting schedule |

## Groups Config

File: `C:\Users\mclou\rateright-growth-deploy\scripts\facebook\groups.json`

## Content Rules

- Each post MUST be unique per group (different opening, city reference, tone)
- Max 1/3 of posts should contain a RateRight link
- Follow the posting schedule from `RateRight-HQ/02-Business/Marketing/Facebook-Strategy.md`
- Timing: ±5 min randomization from scheduled time
- Between-group gap: 15-20 minutes (randomized)
- Max 5-10 groups per day, max 1-2 posts per group per week

## For /post compose

Read templates from `scripts/facebook/templates/` and generate varied content:
- `question.json` — Discussion starter ("What's the cheapest decent hostel you've stayed at?")
- `tip.json` — Value post ("5 things I wish I knew about finding construction work in AU")
- `story.json` — Personal story ("I overpaid agencies for years before...")
- `job_link.json` — Job post with RateRight link

Each template should be customized with the group's city, audience type, and any group-specific rules.

## For /post go

Use headed Playwright to:
1. Open Chrome (not headless)
2. Navigate to Facebook group
3. Compose the pre-approved post
4. Submit
5. Wait 15-20 min before next group
6. Log to Supabase facebook_post_log

Rocky should be able to watch the browser doing its thing.
```

- [ ] **Step 2: Commit**

```bash
git add ~/.claude/skills/post.md
git commit -m "feat: add /post skill"
```

---

## Chunk 5: Unsubscribe Endpoint + Facebook Scraper (from spec review)

### Task 18: Unsubscribe Endpoint

**Files:**
- Create: `C:\Users\mclou\the-50-dollar-app\src\app\api\unsubscribe\route.ts`

Required by Spam Act compliance — every outreach email links to this endpoint.

- [ ] **Step 1: Write the unsubscribe API route**

```typescript
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const token = searchParams.get('token');

  if (!token) {
    return new NextResponse('<html><body><h1>Invalid link</h1></body></html>', {
      headers: { 'Content-Type': 'text/html' },
    });
  }

  try {
    const contactId = Buffer.from(token, 'base64').toString('utf8');

    const { error } = await supabase
      .from('contact_registry')
      .update({
        unsubscribed_at: new Date().toISOString(),
        status: 'unsubscribed',
        updated_at: new Date().toISOString(),
      })
      .eq('id', contactId);

    if (error) throw error;

    return new NextResponse(
      '<html><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>Unsubscribed</h1><p>You have been removed from our mailing list. You will not receive any further emails from RateRight.</p></body></html>',
      { headers: { 'Content-Type': 'text/html' } }
    );
  } catch (err) {
    return new NextResponse(
      '<html><body style="font-family:sans-serif;text-align:center;padding:60px"><h1>Something went wrong</h1><p>Please email michael@rateright.com.au to be removed.</p></body></html>',
      { headers: { 'Content-Type': 'text/html' }, status: 500 }
    );
  }
}
```

- [ ] **Step 2: Commit in platform repo**

```bash
cd C:/Users/mclou/the-50-dollar-app
git add src/app/api/unsubscribe/route.ts
git commit -m "feat: add unsubscribe endpoint for outreach emails"
```

---

### Task 19: Facebook Group Scraper (headed browser)

**Files:**
- Create: `scripts/facebook/group-scraper.js`

Scrapes Facebook groups for "looking for work" posts. Runs on Rocky's PC with headed browser.

- [ ] **Step 1: Write the Facebook group scraper**

```javascript
#!/usr/bin/env node
// Facebook Group Scraper — headed Playwright browser
// Scans groups for "looking for work" posts
// Usage: node scripts/facebook/group-scraper.js [--dry-run]
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
const { upsertContact, insertLead } = require('../hunt/db');
const { normalizeTrade } = require('../hunt/config');

const GROUPS_FILE = path.join(__dirname, 'groups.json');
const WORK_KEYWORDS = [
  'looking for work', 'looking for construction', 'labourer available',
  'need shifts', 'white card', 'available for work', 'seeking construction',
  'available immediately', 'looking for labouring', 'need work',
];

function loadGroups() {
  return JSON.parse(fs.readFileSync(GROUPS_FILE, 'utf8'));
}

function matchesKeywords(text) {
  const lower = (text || '').toLowerCase();
  return WORK_KEYWORDS.some(kw => lower.includes(kw));
}

async function scrapeGroups(dryRun = false) {
  const config = loadGroups();
  if (!config.groups || config.groups.length === 0) {
    console.log('No groups configured.');
    return;
  }

  const browser = await chromium.launch({
    headless: false,
    channel: 'chrome',
  });

  const sessionPath = path.join(__dirname, 'fb-session.json');
  const context = fs.existsSync(sessionPath)
    ? await browser.newContext({ storageState: sessionPath })
    : await browser.newContext();
  const page = await context.newPage();

  let totalFound = 0;

  for (const group of config.groups) {
    console.log(`\nScanning: ${group.name}`);

    try {
      await page.goto(group.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
      await page.waitForTimeout(3000 + Math.random() * 2000);

      // Scroll to load posts
      for (let i = 0; i < 3; i++) {
        await page.evaluate(() => window.scrollBy(0, window.innerHeight));
        await page.waitForTimeout(2000);
      }

      // Extract post content — Facebook DOM changes frequently, adjust selectors as needed
      const posts = await page.evaluate(() => {
        const elements = document.querySelectorAll('[data-ad-preview="message"]');
        return Array.from(elements).map(el => ({
          text: el.textContent || '',
          // Try to get poster name from nearby elements
          name: el.closest('[role="article"]')?.querySelector('strong')?.textContent || null,
        }));
      });

      for (const post of posts) {
        if (!matchesKeywords(post.text)) continue;

        console.log(`  Found: "${post.text.slice(0, 80)}..." by ${post.name || 'unknown'}`);
        totalFound++;

        if (dryRun) continue;

        const trade = normalizeTrade(post.text);
        const city = group.city || 'sydney';

        const { contact, isNew } = await upsertContact({
          name: post.name,
          city,
          trade,
          side: 'worker',
          source: 'facebook',
        });

        if (isNew) {
          await insertLead({
            source: 'facebook',
            source_url: group.url,
            side: 'worker',
            name: post.name,
            trade,
            city,
            description: post.text.slice(0, 500),
            post_date: new Date().toISOString().split('T')[0],
            contact_registry_id: contact.id,
            status: 'new',
            scraped_at: new Date().toISOString(),
          });
        }
      }

      await page.waitForTimeout(5000 + Math.random() * 5000);
    } catch (err) {
      console.error(`  Error scanning ${group.name}:`, err.message);
    }
  }

  // Save session for reuse
  await context.storageState({ path: sessionPath });
  await browser.close();

  console.log(`\n[FB Scraper] Done. Found ${totalFound} worker leads.`);
}

const dryRun = process.argv.includes('--dry-run');
scrapeGroups(dryRun).catch(console.error);
```

- [ ] **Step 2: Commit**

```bash
git add scripts/facebook/group-scraper.js
git commit -m "feat: add facebook group scraper for worker leads"
```

---

## Chunk 6: Facebook Posting + Final Integration

### Task 20: Facebook Group Poster (Headed Browser)

**Files:**
- Create: `scripts/facebook/group-poster.js`
- Create: `scripts/facebook/groups.json`
- Create: `scripts/facebook/templates/question.json`
- Create: `scripts/facebook/templates/tip.json`
- Create: `scripts/facebook/templates/story.json`
- Create: `scripts/facebook/templates/job-link.json`

- [ ] **Step 1: Create groups.json config**

```json
{
  "groups": [],
  "_instructions": "Add groups after joining and lurking 1-2 weeks. Use /post add to add groups."
}
```

- [ ] **Step 2: Create post templates**

`templates/question.json`:
```json
{
  "type": "question",
  "variants": [
    "Anyone here done construction work in {city}? Looking for tips on finding reliable gigs without going through an agency.",
    "What's the going rate for labourers in {city} right now? Hearing everything from $32 to $45/hr.",
    "Best way to find construction work as a backpacker in {city}? Tried Gumtree but it's hit and miss."
  ]
}
```

`templates/tip.json`:
```json
{
  "type": "tip",
  "variants": [
    "Tip for anyone looking for construction work in {city} — most agencies take 20-30% of your pay. There are a few platforms now that charge the contractor instead, so you keep the full rate. Worth checking out.",
    "If you've got a White Card and you're in {city}, construction labourers are pulling $35-45/hr right now. Best money I've seen for casual work.",
    "3 things I wish I knew before my first construction job in Australia: 1) Get your White Card first ($60, one day), 2) Skip the agencies if you can — they eat your margin, 3) Morning starts are early, like 6am early."
  ]
}
```

`templates/story.json`:
```json
{
  "type": "story",
  "variants": [
    "Was paying 25% to a labour hire agency for months before I figured out you can go direct to contractors. Now I'm keeping the full rate. {city} has heaps of work if you know where to look.",
    "Came to Australia on a WHV, got my White Card, started labouring in {city}. Three months in and it's the best money I've made travelling. If anyone's thinking about it — do it."
  ]
}
```

`templates/job-link.json`:
```json
{
  "type": "job_link",
  "variants": [
    "Saw a few construction jobs going in {city} this week — labourers, formworkers, steel fixers. There's a free platform that lists them without agency fees: rateright.com.au/go — no cost for workers.",
    "If anyone in {city} is looking for construction work, there's a new site called RateRight that's free for workers. Contractors post jobs, you apply direct. No middleman fees. rateright.com.au/go"
  ]
}
```

- [ ] **Step 3: Write the group poster script**

```javascript
#!/usr/bin/env node
// Facebook Group Poster — headed Playwright browser
// Usage: node scripts/facebook/group-poster.js [--dry-run]
const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');

const GROUPS_FILE = path.join(__dirname, 'groups.json');
const TEMPLATES_DIR = path.join(__dirname, 'templates');

// Load config
function loadGroups() {
  return JSON.parse(fs.readFileSync(GROUPS_FILE, 'utf8'));
}

function loadTemplates() {
  const templates = {};
  for (const file of fs.readdirSync(TEMPLATES_DIR)) {
    if (!file.endsWith('.json')) continue;
    const name = path.basename(file, '.json');
    templates[name] = JSON.parse(fs.readFileSync(path.join(TEMPLATES_DIR, file), 'utf8'));
  }
  return templates;
}

function pickTemplate(templates, type, city) {
  const t = templates[type];
  if (!t || !t.variants || t.variants.length === 0) return null;
  const variant = t.variants[Math.floor(Math.random() * t.variants.length)];
  return variant.replace(/\{city\}/g, city || 'Australia');
}

// Randomize timing ±5 minutes
function randomDelay(baseMs) {
  const jitter = (Math.random() - 0.5) * 10 * 60 * 1000; // ±5 min
  return Math.max(1000, baseMs + jitter);
}

async function postToGroups(dryRun = false) {
  const config = loadGroups();
  const templates = loadTemplates();

  if (!config.groups || config.groups.length === 0) {
    console.log('No groups configured. Use /post add to add groups.');
    return;
  }

  // Determine today's schedule
  const today = new Date();
  const dayOfWeek = today.getDay(); // 0=Sun, 1=Mon, etc.
  const postTypes = ['question', 'tip', 'story', 'job-link'];

  // Filter groups eligible for posting today (max 1-2 per week per group)
  const eligible = config.groups.filter(g => {
    if (!g.last_posted) return true;
    const daysSince = (Date.now() - new Date(g.last_posted).getTime()) / 86400000;
    return daysSince >= 3; // At least 3 days between posts to same group
  });

  const toPost = eligible.slice(0, 8); // Max 8 groups per day
  console.log(`[Post] ${toPost.length} groups eligible today (${eligible.length} total eligible)`);

  if (dryRun) {
    for (const group of toPost) {
      const type = postTypes[Math.floor(Math.random() * postTypes.length)];
      const content = pickTemplate(templates, type, group.city);
      console.log(`\n[DRY RUN] Group: ${group.name}`);
      console.log(`Type: ${type}`);
      console.log(`Content: ${content}`);
    }
    return;
  }

  // Launch headed browser
  const browser = await chromium.launch({
    headless: false,
    channel: 'chrome', // Use installed Chrome
  });

  const context = await browser.newContext({
    storageState: path.join(__dirname, 'fb-session.json'), // Saved login session
  });
  const page = await context.newPage();

  for (const group of toPost) {
    const type = postTypes[Math.floor(Math.random() * postTypes.length)];
    const content = pickTemplate(templates, type, group.city);

    console.log(`\n[Post] ${group.name} — ${type}`);
    console.log(`Content: ${content.slice(0, 100)}...`);

    try {
      await page.goto(group.url, { waitUntil: 'domcontentloaded', timeout: 30000 });
      await page.waitForTimeout(3000 + Math.random() * 2000);

      // Click "Write something..." to open composer
      // NOTE: Facebook's DOM changes frequently — these selectors may need updating
      await page.click('[role="button"]:has-text("Write something")');
      await page.waitForTimeout(2000);

      // Type the post content
      await page.keyboard.type(content, { delay: 50 + Math.random() * 50 });
      await page.waitForTimeout(1000);

      // Click Post button
      await page.click('[aria-label="Post"]');
      await page.waitForTimeout(3000);

      console.log(`  Posted successfully!`);

      // Update last_posted
      group.last_posted = new Date().toISOString();
      group.posts_this_week = (group.posts_this_week || 0) + 1;

      // Log to Supabase
      // TODO: Insert into facebook_post_log table

    } catch (err) {
      console.error(`  Error posting to ${group.name}:`, err.message);
    }

    // Wait 15-20 min between groups
    if (toPost.indexOf(group) < toPost.length - 1) {
      const delay = randomDelay(17 * 60 * 1000); // ~17 min ±5
      console.log(`  Waiting ${Math.round(delay / 60000)} min before next group...`);
      await page.waitForTimeout(delay);
    }
  }

  // Save updated config
  fs.writeFileSync(GROUPS_FILE, JSON.stringify(config, null, 2));

  await browser.close();
  console.log('\n[Post] Done.');
}

const dryRun = process.argv.includes('--dry-run');
postToGroups(dryRun).catch(console.error);
```

- [ ] **Step 4: Commit**

```bash
git add scripts/facebook/
git commit -m "feat: add facebook group poster with templates"
```

---

### Task 21: PM2 Ecosystem Config

**Files:**
- Create: `scripts/hunt/ecosystem.config.js`

PM2 config for deploying all hunt scripts on VPS.

- [ ] **Step 1: Write the ecosystem config**

```javascript
// PM2 ecosystem for Marketplace Hunter
// Deploy: pm2 start scripts/hunt/ecosystem.config.js
module.exports = {
  apps: [
    {
      name: 'hunt-aggregator',
      script: 'scripts/hunt/job-aggregator.js',
      cron_restart: '0 20 * * *', // 06:00 AEST = 20:00 UTC
      autorestart: false,
      watch: false,
    },
    {
      name: 'hunt-workers',
      script: 'scripts/hunt/worker-hunter.js',
      cron_restart: '30 20 * * *', // 06:30 AEST
      autorestart: false,
      watch: false,
    },
    {
      name: 'hunt-contractors',
      script: 'scripts/hunt/contractor-hunter.js',
      cron_restart: '0 21 * * *', // 07:00 AEST
      autorestart: false,
      watch: false,
    },
    {
      name: 'hunt-matchmaker',
      script: 'scripts/hunt/matchmaker.js',
      cron_restart: '30 21 * * *', // 07:30 AEST
      autorestart: false,
      watch: false,
    },
    {
      name: 'hunt-outreach',
      script: 'scripts/hunt/outreach-sender.js',
      autorestart: true, // Persistent process
      watch: false,
    },
    {
      name: 'hunt-monitor',
      script: 'scripts/hunt/reply-monitor.js',
      autorestart: true, // Persistent process
      watch: false,
    },
  ],
};
```

- [ ] **Step 2: Commit**

```bash
git add scripts/hunt/ecosystem.config.js
git commit -m "feat: add PM2 ecosystem config for hunt scripts"
```

---

### Task 22: Final Integration — Wire /go Page to Aggregated Jobs

**Files:**
- Modify: Files in `C:\Users\mclou\the-50-dollar-app\src\app\go\page.tsx` (the platform repo)

This task connects the aggregated_jobs table to the /go landing page so workers see real jobs.

- [ ] **Step 1: Read the current /go page**

Read: `C:\Users\mclou\the-50-dollar-app\src\app\go\page.tsx`

Understand the current layout and where to add the aggregated jobs section.

- [ ] **Step 2: Add an API route to fetch aggregated jobs**

Create: `C:\Users\mclou\the-50-dollar-app\src\app\api\jobs\aggregated\route.ts`

```typescript
import { createClient } from '@supabase/supabase-js';
import { NextResponse } from 'next/server';

const supabase = createClient(
  process.env.NEXT_PUBLIC_SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);
  const city = searchParams.get('city');
  const trade = searchParams.get('trade');
  const limit = parseInt(searchParams.get('limit') || '20');

  let query = supabase
    .from('aggregated_jobs')
    .select('*')
    .eq('expired', false)
    .order('scraped_at', { ascending: false })
    .limit(limit);

  if (city) query = query.ilike('location', `%${city}%`);
  if (trade) query = query.eq('trade_type', trade);

  const { data, error } = await query;

  if (error) return NextResponse.json({ error: error.message }, { status: 500 });
  return NextResponse.json(data);
}
```

- [ ] **Step 3: Add aggregated jobs section to /go page**

Add a section below the hero that fetches and displays aggregated jobs. Each job card shows: title, location, pay rate, source badge ("Found on Seek" etc.), and a "View Job →" button that links to the source_url.

- [ ] **Step 4: Test the /go page locally**

```bash
cd C:\Users\mclou\the-50-dollar-app && npm run dev
# Visit http://localhost:3000/go — verify aggregated jobs appear
```

- [ ] **Step 5: Commit in the platform repo**

```bash
cd C:\Users\mclou\the-50-dollar-app
git add src/app/api/jobs/aggregated/route.ts src/app/go/page.tsx
git commit -m "feat: display aggregated jobs on /go landing page"
```

---

### Task 23: End-to-End Verification

- [ ] **Step 1: Run full hunt cycle**

```bash
cd C:\Users\mclou\rateright-growth-deploy
node scripts/hunt/job-aggregator.js --city sydney
node scripts/hunt/worker-hunter.js --city sydney
node scripts/hunt/contractor-hunter.js --city sydney
```

- [ ] **Step 2: Check data in Supabase**

```bash
node -e "
require('dotenv').config();
const db = require('./scripts/hunt/db');
db.getStats().then(s => {
  console.log('=== MARKETPLACE HUNTER STATUS ===');
  console.log('Workers scraped:', s.workers);
  console.log('Contractors scraped:', s.contractors);
  console.log('Active job listings:', s.activeJobs);
  console.log('Matches:', s.matches);
  console.log('Outreach sent:', s.sent);
  console.log('Replies:', s.replied);
  console.log('Converted:', s.converted);
});
"
```

- [ ] **Step 3: Test /pulse skill**

Run `/pulse` in Claude Code — verify it shows stats.

- [ ] **Step 4: Test /hunt skill**

Run `/hunt --dry-run` — verify it scrapes and shows results without inserting.

- [ ] **Step 5: Test approval flow**

Run `/matchmaker queue` — verify pending leads appear.
Run `/matchmaker approve 1` — verify one lead gets approved.

- [ ] **Step 6: Test outreach sender**

```bash
node scripts/hunt/outreach-sender.js --once
```

Verify it picks up the approved lead and sends (or fails gracefully if no email).

- [ ] **Step 7: Final commit**

```bash
git add -A
git commit -m "feat: marketplace hunter v1 complete — end-to-end verified"
```
