feat: Sprint 1 - Deep discovery, lead persistence, and dashboard nav
- Add BacklinkOutreachScraper (Exa + DuckDuckGo deep scraping) - Extend DB and Pydantic models for lead enrichment columns - Add StorageService methods for lead CRUD with auto-migration - Add backend endpoints: deep discover, campaign detail, lead management - Extend frontend API client and store with discovery + lead actions - Create BacklinkOutreachDashboard component with campaigns/discover/leads tabs - Register route at /backlink-outreach under SEO feature flag - Add nav entry under Enterprise & Advanced in tool categories
This commit is contained in:
@@ -1,8 +1,12 @@
|
||||
"""Backlink outreach router."""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
|
||||
from services.backlink_outreach_models import BacklinkDiscoveryResponse, BacklinkKeywordInput, PolicyValidationRequest, PolicyValidationResponse
|
||||
from services.backlink_outreach_models import (
|
||||
BacklinkDiscoveryResponse, BacklinkKeywordInput, DeepKeywordInput,
|
||||
LeadCreateRequest, LeadStatusUpdateRequest,
|
||||
PolicyValidationRequest, PolicyValidationResponse,
|
||||
)
|
||||
from services.backlink_outreach_service import backlink_outreach_service
|
||||
from services.backlink_outreach_storage import BacklinkOutreachStorageService
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -31,6 +35,31 @@ async def discover_backlink_opportunities(payload: BacklinkKeywordInput):
|
||||
return backlink_outreach_service.discover_opportunities(payload.keyword, payload.max_results)
|
||||
|
||||
|
||||
@router.post("/discover/deep")
|
||||
async def discover_deep_backlink_opportunities(payload: DeepKeywordInput):
|
||||
"""Enhanced discovery using Exa neural search + DuckDuckGo with full-page scraping."""
|
||||
result = await backlink_outreach_service.deep_discover(payload.keyword, payload.max_results)
|
||||
if payload.campaign_id:
|
||||
storage = BacklinkOutreachStorageService()
|
||||
user_id = "default"
|
||||
for opp in result.get("opportunities", []):
|
||||
try:
|
||||
storage.add_lead(
|
||||
campaign_id=payload.campaign_id,
|
||||
user_id=user_id,
|
||||
url=opp["url"],
|
||||
domain=opp["domain"],
|
||||
page_title=opp.get("page_title", ""),
|
||||
snippet=opp.get("snippet", ""),
|
||||
email=opp.get("email"),
|
||||
confidence_score=opp.get("confidence_score", 0.0),
|
||||
discovery_source=opp.get("discovery_source", "duckduckgo"),
|
||||
)
|
||||
except Exception:
|
||||
continue
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/campaigns")
|
||||
async def create_backlink_campaign(payload: BacklinkCampaignCreateRequest):
|
||||
storage = BacklinkOutreachStorageService()
|
||||
@@ -43,6 +72,57 @@ async def list_backlink_campaigns(user_id: str, workspace_id: str, limit: int =
|
||||
return {"campaigns": storage.list_campaigns(user_id, workspace_id, limit)}
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}")
|
||||
async def get_backlink_campaign(campaign_id: str, user_id: str = Query(...)):
|
||||
"""Get campaign detail with leads."""
|
||||
storage = BacklinkOutreachStorageService()
|
||||
campaign = storage.get_campaign(campaign_id, user_id)
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="Campaign not found")
|
||||
return campaign
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}/leads")
|
||||
async def list_campaign_leads(
|
||||
campaign_id: str, user_id: str = Query(...), status: str = Query(None)
|
||||
):
|
||||
"""List leads for a campaign, optionally filtered by status."""
|
||||
storage = BacklinkOutreachStorageService()
|
||||
leads = storage.list_leads(campaign_id, user_id, status=status or None)
|
||||
return {"leads": leads, "total": len(leads)}
|
||||
|
||||
|
||||
@router.post("/campaigns/{campaign_id}/leads")
|
||||
async def add_campaign_lead(campaign_id: str, payload: LeadCreateRequest):
|
||||
"""Add a single lead to a campaign."""
|
||||
storage = BacklinkOutreachStorageService()
|
||||
try:
|
||||
lead = storage.add_lead(
|
||||
campaign_id=payload.campaign_id,
|
||||
user_id="default",
|
||||
url=payload.url,
|
||||
domain=payload.domain,
|
||||
page_title=payload.page_title or "",
|
||||
snippet=payload.snippet or "",
|
||||
email=payload.email,
|
||||
confidence_score=payload.confidence_score,
|
||||
notes=payload.notes,
|
||||
)
|
||||
return lead
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.patch("/leads/{lead_id}/status")
|
||||
async def update_lead_status(lead_id: str, payload: LeadStatusUpdateRequest):
|
||||
"""Update lead status (discovered -> contacted -> replied -> placed)."""
|
||||
storage = BacklinkOutreachStorageService()
|
||||
lead = storage.update_lead_status(lead_id, "default", payload.status, payload.notes)
|
||||
if not lead:
|
||||
raise HTTPException(status_code=404, detail="Lead not found")
|
||||
return lead
|
||||
|
||||
|
||||
@router.post("/policy-validate", response_model=PolicyValidationResponse)
|
||||
async def validate_outreach_policy(payload: PolicyValidationRequest):
|
||||
return backlink_outreach_service.validate_send_policy(payload)
|
||||
|
||||
Reference in New Issue
Block a user