Merge branch 'pr-486'
This commit is contained in:
@@ -350,4 +350,28 @@ If you encounter issues:
|
||||
|
||||
---
|
||||
|
||||
**Happy coding! 🎉**
|
||||
**Happy coding! 🎉**
|
||||
|
||||
## Backlink Outreach Migration Map
|
||||
|
||||
Canonical migrated backlinking module paths:
|
||||
|
||||
- Router: `backend/routers/backlink_outreach.py`
|
||||
- Service: `backend/services/backlink_outreach_service.py`
|
||||
- Frontend API client: `frontend/src/api/backlinkOutreachApi.ts`
|
||||
- Frontend store: `frontend/src/stores/backlinkOutreachStore.ts`
|
||||
- Frontend UI integration: `frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx`
|
||||
|
||||
Invoke from backend:
|
||||
|
||||
- `GET /api/backlink-outreach/modules`
|
||||
- `GET /api/backlink-outreach/query-templates?keyword=<keyword>`
|
||||
- `GET /api/backlink-outreach/migration-coverage`
|
||||
- `POST /api/backlink-outreach/discover` with JSON body: `{ "keyword": "...", "max_results": 10 }`
|
||||
- `POST /api/backlink-outreach/policy-validate` to enforce compliance/suppression/throttles before send
|
||||
- `GET /api/backlink-outreach/reporting` for send-volume and conversion snapshot
|
||||
- `POST /api/backlink-outreach/campaigns` and `GET /api/backlink-outreach/campaigns` for persisted campaign records (campaign-creator style storage flow)
|
||||
|
||||
The modules endpoint returns migration identifiers: `backlink`, `outreach`, and `guest_post`.
|
||||
The query-template endpoint mirrors legacy `generate_search_queries(...)` behavior from `ToBeMigrated/ai_marketing_tools/ai_backlinker/ai_backlinking.py`.
|
||||
The migration-coverage endpoint summarizes what is already implemented vs planned from the legacy prototype roadmap.
|
||||
|
||||
@@ -138,6 +138,7 @@ if _is_full_mode():
|
||||
from routers.image_studio import router as image_studio_router
|
||||
from routers.product_marketing import router as product_marketing_router
|
||||
from routers.campaign_creator import router as campaign_creator_router
|
||||
from routers.backlink_outreach import router as backlink_outreach_router
|
||||
else:
|
||||
# In feature-only modes, only load essential assets router
|
||||
from api.assets_serving import router as assets_serving_router
|
||||
@@ -146,6 +147,7 @@ else:
|
||||
image_studio_router = None
|
||||
product_marketing_router = None
|
||||
campaign_creator_router = None
|
||||
backlink_outreach_router = None
|
||||
|
||||
# Import hallucination detector router
|
||||
try:
|
||||
@@ -678,6 +680,8 @@ if _is_full_mode():
|
||||
app.include_router(product_marketing_router)
|
||||
if campaign_creator_router:
|
||||
app.include_router(campaign_creator_router)
|
||||
if backlink_outreach_router:
|
||||
app.include_router(backlink_outreach_router)
|
||||
|
||||
router_group_status["platform_extensions"] = {
|
||||
"mounted": True,
|
||||
|
||||
31
backend/docs/backlink_migration_audit.md
Normal file
31
backend/docs/backlink_migration_audit.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Backlink Migration Audit (Legacy vs Current)
|
||||
|
||||
Legacy prototype reference:
|
||||
- `ToBeMigrated/ai_marketing_tools/ai_backlinker/ai_backlinking.py`
|
||||
- `ToBeMigrated/ai_marketing_tools/ai_backlinker/backlinking_ui_streamlit.py`
|
||||
|
||||
## Implemented in current branch
|
||||
|
||||
- Canonical backend entrypoint with backlink-specific naming:
|
||||
- `backend/routers/backlink_outreach.py`
|
||||
- `backend/services/backlink_outreach_service.py`
|
||||
- Legacy-style guest-post query template generation exposed over API:
|
||||
- `GET /api/backlink-outreach/query-templates?keyword=<keyword>`
|
||||
- Migration traceability metadata endpoints:
|
||||
- `GET /api/backlink-outreach/modules`
|
||||
- `GET /api/backlink-outreach/migration-coverage`
|
||||
- Frontend integration points with backlink-specific naming:
|
||||
- `frontend/src/api/backlinkOutreachApi.ts`
|
||||
- `frontend/src/stores/backlinkOutreachStore.ts`
|
||||
- `frontend/src/components/SEODashboard/BacklinkOutreachModuleList.tsx`
|
||||
|
||||
## Not yet migrated (planned)
|
||||
|
||||
- Live web prospect discovery / scraping execution loop (`find_backlink_opportunities`).
|
||||
- Outreach email sending + reply monitoring loop (`send_email`, IMAP checks).
|
||||
- End-to-end campaign orchestration from keyword batch -> outreach -> follow-up.
|
||||
|
||||
## Notes
|
||||
|
||||
This branch intentionally provides a clean migration seam and auditable entrypoints first.
|
||||
Feature-complete parity can now be implemented incrementally behind these stable backend and frontend contracts.
|
||||
@@ -77,6 +77,7 @@ from api.images import router as images_router
|
||||
from routers.image_studio import router as image_studio_router
|
||||
from routers.product_marketing import router as product_marketing_router
|
||||
from routers.campaign_creator import router as campaign_creator_router
|
||||
from routers.backlink_outreach import router as backlink_outreach_router
|
||||
|
||||
# Import hallucination detector router
|
||||
from api.hallucination_detector import router as hallucination_detector_router
|
||||
@@ -402,6 +403,7 @@ app.include_router(images_router)
|
||||
app.include_router(image_studio_router)
|
||||
app.include_router(product_marketing_router)
|
||||
app.include_router(campaign_creator_router)
|
||||
app.include_router(backlink_outreach_router)
|
||||
|
||||
# Include content assets router
|
||||
from api.content_assets.router import router as content_assets_router
|
||||
|
||||
59
backend/models/backlink_outreach_models.py
Normal file
59
backend/models/backlink_outreach_models.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""DB models for production backlink outreach tracking."""
|
||||
|
||||
from datetime import datetime
|
||||
from sqlalchemy import Column, String, Integer, DateTime, Text, ForeignKey, Index, Boolean
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class BacklinkCampaign(Base):
|
||||
__tablename__ = "backlink_campaigns"
|
||||
id = Column(String(64), primary_key=True)
|
||||
user_id = Column(String(255), nullable=False, index=True)
|
||||
workspace_id = Column(String(255), nullable=False, index=True)
|
||||
name = Column(String(255), nullable=False)
|
||||
status = Column(String(32), nullable=False, default="drafted", index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class BacklinkLead(Base):
|
||||
__tablename__ = "backlink_leads"
|
||||
id = Column(String(64), primary_key=True)
|
||||
campaign_id = Column(String(64), ForeignKey("backlink_campaigns.id"), nullable=False, index=True)
|
||||
domain = Column(String(255), nullable=False, index=True)
|
||||
email = Column(String(255), nullable=True, index=True)
|
||||
status = Column(String(32), nullable=False, default="drafted", index=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class OutreachAttempt(Base):
|
||||
__tablename__ = "backlink_outreach_attempts"
|
||||
id = Column(String(64), primary_key=True)
|
||||
lead_id = Column(String(64), ForeignKey("backlink_leads.id"), nullable=False, index=True)
|
||||
campaign_id = Column(String(64), ForeignKey("backlink_campaigns.id"), nullable=False, index=True)
|
||||
idempotency_key = Column(String(128), nullable=False, unique=True, index=True)
|
||||
status = Column(String(32), nullable=False, default="queued", index=True)
|
||||
decision_reason = Column(Text, nullable=True)
|
||||
created_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
|
||||
|
||||
class OutreachReply(Base):
|
||||
__tablename__ = "backlink_replies"
|
||||
id = Column(String(64), primary_key=True)
|
||||
attempt_id = Column(String(64), ForeignKey("backlink_outreach_attempts.id"), nullable=False, index=True)
|
||||
received_at = Column(DateTime, default=datetime.utcnow, index=True)
|
||||
classification = Column(String(32), nullable=False, default="replied")
|
||||
body = Column(Text, nullable=True)
|
||||
|
||||
|
||||
class FollowUpSchedule(Base):
|
||||
__tablename__ = "backlink_followup_schedules"
|
||||
id = Column(String(64), primary_key=True)
|
||||
attempt_id = Column(String(64), ForeignKey("backlink_outreach_attempts.id"), nullable=False, index=True)
|
||||
scheduled_for = Column(DateTime, nullable=False, index=True)
|
||||
sent = Column(Boolean, default=False, index=True)
|
||||
|
||||
|
||||
Index("idx_backlink_campaign_user_date", BacklinkCampaign.user_id, BacklinkCampaign.created_at)
|
||||
Index("idx_backlink_attempt_campaign_date", OutreachAttempt.campaign_id, OutreachAttempt.created_at)
|
||||
58
backend/routers/backlink_outreach.py
Normal file
58
backend/routers/backlink_outreach.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Backlink outreach router."""
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from services.backlink_outreach_models import BacklinkDiscoveryResponse, BacklinkKeywordInput, PolicyValidationRequest, PolicyValidationResponse
|
||||
from services.backlink_outreach_service import backlink_outreach_service
|
||||
from services.backlink_outreach_storage import BacklinkOutreachStorageService
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
router = APIRouter(prefix="/api/backlink-outreach", tags=["backlink-outreach"])
|
||||
|
||||
|
||||
class BacklinkCampaignCreateRequest(BaseModel):
|
||||
user_id: str = Field(..., min_length=1)
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
name: str = Field(..., min_length=3)
|
||||
|
||||
|
||||
@router.get("/modules")
|
||||
async def get_backlink_module_registry():
|
||||
return {"feature": "backlink_outreach", "modules": backlink_outreach_service.list_backlink_modules()}
|
||||
|
||||
|
||||
@router.get("/query-templates")
|
||||
async def get_backlink_query_templates(keyword: str = Query(..., min_length=1)):
|
||||
return {"keyword": keyword, "queries": backlink_outreach_service.generate_guest_post_queries(keyword)}
|
||||
|
||||
|
||||
@router.post("/discover", response_model=BacklinkDiscoveryResponse)
|
||||
async def discover_backlink_opportunities(payload: BacklinkKeywordInput):
|
||||
return backlink_outreach_service.discover_opportunities(payload.keyword, payload.max_results)
|
||||
|
||||
|
||||
@router.post("/campaigns")
|
||||
async def create_backlink_campaign(payload: BacklinkCampaignCreateRequest):
|
||||
storage = BacklinkOutreachStorageService()
|
||||
return storage.create_campaign(payload.user_id, payload.workspace_id, payload.name)
|
||||
|
||||
|
||||
@router.get("/campaigns")
|
||||
async def list_backlink_campaigns(user_id: str, workspace_id: str, limit: int = 50):
|
||||
storage = BacklinkOutreachStorageService()
|
||||
return {"campaigns": storage.list_campaigns(user_id, workspace_id, limit)}
|
||||
|
||||
|
||||
@router.post("/policy-validate", response_model=PolicyValidationResponse)
|
||||
async def validate_outreach_policy(payload: PolicyValidationRequest):
|
||||
return backlink_outreach_service.validate_send_policy(payload)
|
||||
|
||||
|
||||
@router.get("/reporting")
|
||||
async def get_backlink_reporting_snapshot():
|
||||
return backlink_outreach_service.get_reporting_snapshot()
|
||||
|
||||
|
||||
@router.get("/migration-coverage")
|
||||
async def get_backlink_migration_coverage():
|
||||
return backlink_outreach_service.get_migration_coverage()
|
||||
60
backend/services/backlink_outreach_models.py
Normal file
60
backend/services/backlink_outreach_models.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import BaseModel, Field, HttpUrl, EmailStr
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
|
||||
class BacklinkKeywordInput(BaseModel):
|
||||
keyword: str = Field(..., min_length=2, max_length=120)
|
||||
max_results: int = Field(default=10, ge=1, le=50)
|
||||
|
||||
|
||||
class OpportunityContactInfo(BaseModel):
|
||||
email: Optional[EmailStr] = None
|
||||
contact_page: Optional[HttpUrl] = None
|
||||
|
||||
|
||||
class OpportunityRecord(BaseModel):
|
||||
url: HttpUrl
|
||||
title: str
|
||||
snippet: str
|
||||
metadata: Dict[str, str] = Field(default_factory=dict)
|
||||
contact_info: OpportunityContactInfo = Field(default_factory=OpportunityContactInfo)
|
||||
confidence_score: float = Field(..., ge=0.0, le=1.0)
|
||||
|
||||
|
||||
class BacklinkDiscoveryResponse(BaseModel):
|
||||
keyword: str
|
||||
queries: List[str]
|
||||
opportunities: List[OpportunityRecord]
|
||||
|
||||
|
||||
class GeneratedEmailResponse(BaseModel):
|
||||
subject: str
|
||||
body: str
|
||||
|
||||
|
||||
class OutreachStatusRecord(BaseModel):
|
||||
opportunity_url: HttpUrl
|
||||
status: str
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PolicyValidationRequest(BaseModel):
|
||||
user_id: str = Field(..., min_length=1)
|
||||
workspace_id: str = Field(..., min_length=1)
|
||||
campaign_id: str = Field(..., min_length=1)
|
||||
recipient_email: EmailStr
|
||||
recipient_domain: str
|
||||
recipient_region: str = Field(default="unknown")
|
||||
legal_basis: str = Field(..., min_length=2)
|
||||
approved_by_human: bool = False
|
||||
unsubscribe_url: Optional[HttpUrl] = None
|
||||
sender_identity: str = Field(..., min_length=3)
|
||||
idempotency_key: str = Field(..., min_length=8)
|
||||
|
||||
|
||||
class PolicyValidationResponse(BaseModel):
|
||||
allowed: bool
|
||||
reasons: List[str] = Field(default_factory=list)
|
||||
final_status: str
|
||||
222
backend/services/backlink_outreach_service.py
Normal file
222
backend/services/backlink_outreach_service.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Canonical backlink outreach service entrypoint."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List
|
||||
import re
|
||||
import time
|
||||
|
||||
import requests
|
||||
from bs4 import BeautifulSoup
|
||||
|
||||
from services.backlink_outreach_models import OpportunityContactInfo, OpportunityRecord, PolicyValidationRequest, PolicyValidationResponse
|
||||
|
||||
|
||||
|
||||
|
||||
# Temporary in-memory control plane until DB wiring is complete
|
||||
SUPPRESSION_LIST = set()
|
||||
SENT_IDEMPOTENCY_KEYS = set()
|
||||
AUDIT_LOGS: list[dict] = []
|
||||
SEND_COUNTERS_BY_USER: dict[str, int] = {}
|
||||
SEND_COUNTERS_BY_DOMAIN: dict[str, int] = {}
|
||||
DEFAULT_USER_DAILY_CAP = 100
|
||||
DEFAULT_DOMAIN_DAILY_CAP = 20
|
||||
|
||||
@dataclass
|
||||
class SearchResult:
|
||||
url: str
|
||||
title: str
|
||||
snippet: str
|
||||
|
||||
|
||||
class BacklinkOutreachService:
|
||||
def list_backlink_modules(self) -> List[Dict[str, Any]]:
|
||||
return [
|
||||
{"identifier": "backlink", "module_path": "backend/services/backlink_outreach_service.py", "purpose": "Canonical backlink service facade"},
|
||||
{"identifier": "outreach", "module_path": "backend/routers/backlink_outreach.py", "purpose": "HTTP API entrypoint for backlink outreach"},
|
||||
{"identifier": "guest_post", "module_path": "frontend/src/api/backlinkOutreachApi.ts", "purpose": "Frontend API integration for guest-post workflows"},
|
||||
]
|
||||
|
||||
def generate_guest_post_queries(self, keyword: str) -> List[str]:
|
||||
normalized = (keyword or "").strip()
|
||||
if not normalized:
|
||||
return []
|
||||
return [
|
||||
f"{normalized} + 'Guest Contributor'",
|
||||
f"{normalized} + 'Add Guest Post'",
|
||||
f"{normalized} + 'Guest Bloggers Wanted'",
|
||||
f"{normalized} + 'Write for Us'",
|
||||
f"{normalized} + 'Submit Guest Post'",
|
||||
f"{normalized} + 'Become a Guest Blogger'",
|
||||
f"{normalized} + 'guest post opportunities'",
|
||||
f"{normalized} + 'Submit article'",
|
||||
]
|
||||
|
||||
def search_for_urls(self, query: str, timeout_seconds: int = 12, retries: int = 2) -> List[SearchResult]:
|
||||
encoded_query = requests.utils.quote(query)
|
||||
url = f"https://duckduckgo.com/html/?q={encoded_query}"
|
||||
headers = {"User-Agent": "Mozilla/5.0 ALwrityBacklinkBot/1.0"}
|
||||
|
||||
for attempt in range(retries + 1):
|
||||
try:
|
||||
response = requests.get(url, headers=headers, timeout=timeout_seconds)
|
||||
response.raise_for_status()
|
||||
soup = BeautifulSoup(response.text, "html.parser")
|
||||
rows: List[SearchResult] = []
|
||||
for result in soup.select("div.result")[:10]:
|
||||
anchor = result.select_one("a.result__a")
|
||||
snippet = result.select_one("a.result__snippet") or result.select_one("div.result__snippet")
|
||||
if not anchor or not anchor.get("href"):
|
||||
continue
|
||||
rows.append(
|
||||
SearchResult(
|
||||
url=anchor.get("href"),
|
||||
title=anchor.get_text(strip=True),
|
||||
snippet=snippet.get_text(" ", strip=True) if snippet else "",
|
||||
)
|
||||
)
|
||||
return rows
|
||||
except Exception:
|
||||
if attempt == retries:
|
||||
return []
|
||||
time.sleep(0.6 * (attempt + 1))
|
||||
return []
|
||||
|
||||
def discover_opportunities(self, keyword: str, max_results: int = 10) -> Dict[str, Any]:
|
||||
queries = self.generate_guest_post_queries(keyword)[:4]
|
||||
dedup: Dict[str, SearchResult] = {}
|
||||
|
||||
for query in queries:
|
||||
for result in self.search_for_urls(query):
|
||||
normalized_url = self._normalize_url(result.url)
|
||||
if not normalized_url or normalized_url in dedup:
|
||||
continue
|
||||
dedup[normalized_url] = result
|
||||
if len(dedup) >= max_results:
|
||||
break
|
||||
if len(dedup) >= max_results:
|
||||
break
|
||||
time.sleep(0.4)
|
||||
|
||||
opportunities: List[OpportunityRecord] = []
|
||||
for normalized_url, row in dedup.items():
|
||||
contact = self._extract_contact_info(row.snippet)
|
||||
score = self._score_confidence(row.title, row.snippet)
|
||||
opportunities.append(
|
||||
OpportunityRecord(
|
||||
url=normalized_url,
|
||||
title=row.title or "Untitled",
|
||||
snippet=row.snippet,
|
||||
metadata={"source": "duckduckgo_html", "query_keyword": keyword},
|
||||
contact_info=contact,
|
||||
confidence_score=score,
|
||||
)
|
||||
)
|
||||
|
||||
return {"keyword": keyword, "queries": queries, "opportunities": opportunities}
|
||||
|
||||
def _normalize_url(self, url: str) -> str:
|
||||
u = (url or "").strip()
|
||||
if not u:
|
||||
return ""
|
||||
if u.startswith("//"):
|
||||
u = f"https:{u}"
|
||||
if not re.match(r"^https?://", u):
|
||||
return ""
|
||||
return u.split("#")[0].rstrip("/")
|
||||
|
||||
def _extract_contact_info(self, text: str) -> OpportunityContactInfo:
|
||||
if not text:
|
||||
return OpportunityContactInfo()
|
||||
email_match = re.search(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", text)
|
||||
return OpportunityContactInfo(email=email_match.group(0) if email_match else None)
|
||||
|
||||
def _score_confidence(self, title: str, snippet: str) -> float:
|
||||
hay = f"{title} {snippet}".lower()
|
||||
cues = ["write for us", "guest post", "submit", "contributor", "guest blogger"]
|
||||
hits = sum(1 for cue in cues if cue in hay)
|
||||
return min(1.0, 0.35 + (0.13 * hits))
|
||||
|
||||
|
||||
def validate_send_policy(self, payload: PolicyValidationRequest) -> PolicyValidationResponse:
|
||||
reasons: List[str] = []
|
||||
|
||||
if payload.workspace_id.startswith("new-") and not payload.approved_by_human:
|
||||
reasons.append("human_review_required_for_new_workspace")
|
||||
if payload.legal_basis.lower() not in {"legitimate_interest", "consent", "contract"}:
|
||||
reasons.append("invalid_legal_basis")
|
||||
if payload.recipient_region.lower() in {"eu", "eea"} and payload.legal_basis.lower() != "consent":
|
||||
reasons.append("region_requires_explicit_consent")
|
||||
if not payload.unsubscribe_url:
|
||||
reasons.append("unsubscribe_url_required")
|
||||
if len(payload.sender_identity.strip()) < 3:
|
||||
reasons.append("sender_identity_required")
|
||||
|
||||
recipient_key = f"{payload.recipient_email.lower()}::{payload.recipient_domain.lower()}"
|
||||
if recipient_key in SUPPRESSION_LIST:
|
||||
reasons.append("recipient_suppressed")
|
||||
if payload.idempotency_key in SENT_IDEMPOTENCY_KEYS:
|
||||
reasons.append("duplicate_idempotency_key")
|
||||
|
||||
user_count = SEND_COUNTERS_BY_USER.get(payload.user_id, 0)
|
||||
domain_count = SEND_COUNTERS_BY_DOMAIN.get(payload.recipient_domain.lower(), 0)
|
||||
if user_count >= DEFAULT_USER_DAILY_CAP:
|
||||
reasons.append("user_daily_cap_exceeded")
|
||||
if domain_count >= DEFAULT_DOMAIN_DAILY_CAP:
|
||||
reasons.append("domain_daily_cap_exceeded")
|
||||
|
||||
allowed = len(reasons) == 0
|
||||
final_status = "approved" if allowed else "blocked"
|
||||
|
||||
AUDIT_LOGS.append({
|
||||
"event": "policy_check",
|
||||
"user_id": payload.user_id,
|
||||
"campaign_id": payload.campaign_id,
|
||||
"recipient": str(payload.recipient_email),
|
||||
"allowed": allowed,
|
||||
"reasons": reasons,
|
||||
"override": payload.approved_by_human,
|
||||
})
|
||||
|
||||
if allowed:
|
||||
SENT_IDEMPOTENCY_KEYS.add(payload.idempotency_key)
|
||||
SEND_COUNTERS_BY_USER[payload.user_id] = user_count + 1
|
||||
SEND_COUNTERS_BY_DOMAIN[payload.recipient_domain.lower()] = domain_count + 1
|
||||
|
||||
return PolicyValidationResponse(allowed=allowed, reasons=reasons, final_status=final_status)
|
||||
|
||||
def get_reporting_snapshot(self) -> Dict[str, Any]:
|
||||
total_decisions = len(AUDIT_LOGS)
|
||||
approved = sum(1 for row in AUDIT_LOGS if row.get("allowed"))
|
||||
return {
|
||||
"send_volume": approved,
|
||||
"decision_events": total_decisions,
|
||||
"response_rate": 0.0,
|
||||
"placement_conversion": 0.0,
|
||||
}
|
||||
|
||||
def get_migration_coverage(self) -> Dict[str, Any]:
|
||||
implemented = [
|
||||
"discoverable backend router + service",
|
||||
"frontend API/store/UI integration point",
|
||||
"legacy guest-post search query generation templates",
|
||||
"provider-backed URL discovery + normalization + deduplication",
|
||||
"typed opportunity records and confidence score",
|
||||
]
|
||||
planned = [
|
||||
"deep webpage scraping + contact-page extraction",
|
||||
"email sending automation + response tracking",
|
||||
"follow-up orchestration and campaign analytics",
|
||||
]
|
||||
return {
|
||||
"legacy_reference": "ToBeMigrated/ai_marketing_tools/ai_backlinker/ai_backlinking.py",
|
||||
"implemented_count": len(implemented),
|
||||
"planned_count": len(planned),
|
||||
"implemented": implemented,
|
||||
"planned": planned,
|
||||
}
|
||||
|
||||
|
||||
backlink_outreach_service = BacklinkOutreachService()
|
||||
58
backend/services/backlink_outreach_storage.py
Normal file
58
backend/services/backlink_outreach_storage.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""Backlink outreach persistence service (campaign-creator style)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import uuid4
|
||||
from typing import List
|
||||
|
||||
from services.database import get_session_for_user
|
||||
from models.backlink_outreach_models import Base, BacklinkCampaign
|
||||
|
||||
|
||||
class BacklinkOutreachStorageService:
|
||||
def _ensure_tables(self, user_id: str) -> None:
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
return
|
||||
try:
|
||||
Base.metadata.create_all(bind=db.get_bind(), checkfirst=True)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def create_campaign(self, user_id: str, workspace_id: str, name: str) -> dict:
|
||||
self._ensure_tables(user_id)
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
raise RuntimeError("Database session unavailable")
|
||||
try:
|
||||
campaign = BacklinkCampaign(
|
||||
id=f"bl_{uuid4().hex[:16]}",
|
||||
user_id=user_id,
|
||||
workspace_id=workspace_id,
|
||||
name=name,
|
||||
status="drafted",
|
||||
created_at=datetime.utcnow(),
|
||||
)
|
||||
db.add(campaign)
|
||||
db.commit()
|
||||
return {"campaign_id": campaign.id, "name": campaign.name, "status": campaign.status}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def list_campaigns(self, user_id: str, workspace_id: str, limit: int = 50) -> List[dict]:
|
||||
self._ensure_tables(user_id)
|
||||
db = get_session_for_user(user_id)
|
||||
if not db:
|
||||
return []
|
||||
try:
|
||||
rows = (
|
||||
db.query(BacklinkCampaign)
|
||||
.filter(BacklinkCampaign.user_id == user_id, BacklinkCampaign.workspace_id == workspace_id)
|
||||
.order_by(BacklinkCampaign.created_at.desc())
|
||||
.limit(limit)
|
||||
.all()
|
||||
)
|
||||
return [{"campaign_id": r.id, "name": r.name, "status": r.status, "created_at": r.created_at.isoformat()} for r in rows]
|
||||
finally:
|
||||
db.close()
|
||||
106
frontend/src/api/backlinkOutreachApi.ts
Normal file
106
frontend/src/api/backlinkOutreachApi.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { apiClient } from './client';
|
||||
|
||||
export interface BacklinkModuleRecord {
|
||||
identifier: 'backlink' | 'outreach' | 'guest_post' | string;
|
||||
module_path: string;
|
||||
purpose: string;
|
||||
}
|
||||
|
||||
export interface BacklinkModuleRegistryResponse {
|
||||
feature: string;
|
||||
modules: BacklinkModuleRecord[];
|
||||
}
|
||||
|
||||
export interface BacklinkCoverageResponse {
|
||||
legacy_reference: string;
|
||||
implemented_count: number;
|
||||
planned_count: number;
|
||||
implemented: string[];
|
||||
planned: string[];
|
||||
}
|
||||
|
||||
export interface BacklinkQueryTemplatesResponse {
|
||||
keyword: string;
|
||||
queries: string[];
|
||||
}
|
||||
|
||||
export interface BacklinkDiscoveryRequest {
|
||||
keyword: string;
|
||||
max_results?: number;
|
||||
}
|
||||
|
||||
export interface BacklinkOpportunity {
|
||||
url: string;
|
||||
title: string;
|
||||
snippet: string;
|
||||
confidence_score: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
export interface BacklinkPolicyValidationRequest {
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
campaign_id: string;
|
||||
recipient_email: string;
|
||||
recipient_domain: string;
|
||||
recipient_region: string;
|
||||
legal_basis: string;
|
||||
approved_by_human: boolean;
|
||||
unsubscribe_url?: string;
|
||||
sender_identity: string;
|
||||
idempotency_key: string;
|
||||
}
|
||||
|
||||
export interface BacklinkPolicyValidationResponse {
|
||||
allowed: boolean;
|
||||
reasons: string[];
|
||||
final_status: string;
|
||||
}
|
||||
|
||||
export interface BacklinkReportingSnapshot {
|
||||
send_volume: number;
|
||||
decision_events: number;
|
||||
response_rate: number;
|
||||
placement_conversion: number;
|
||||
}
|
||||
|
||||
export interface BacklinkDiscoveryResponse {
|
||||
keyword: string;
|
||||
queries: string[];
|
||||
opportunities: BacklinkOpportunity[];
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignRecord {
|
||||
campaign_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
created_at?: string;
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignCreateRequest {
|
||||
user_id: string;
|
||||
workspace_id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignCreateResponse {
|
||||
campaign_id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface BacklinkCampaignListResponse {
|
||||
campaigns: BacklinkCampaignRecord[];
|
||||
}
|
||||
|
||||
export const fetchBacklinkModuleRegistry = async (): Promise<BacklinkModuleRegistryResponse> => (await apiClient.get('/api/backlink-outreach/modules')).data;
|
||||
export const fetchBacklinkMigrationCoverage = async (): Promise<BacklinkCoverageResponse> => (await apiClient.get('/api/backlink-outreach/migration-coverage')).data;
|
||||
export const fetchBacklinkQueryTemplates = async (keyword: string): Promise<BacklinkQueryTemplatesResponse> => (await apiClient.get('/api/backlink-outreach/query-templates', { params: { keyword } })).data;
|
||||
export const discoverBacklinkOpportunities = async (payload: BacklinkDiscoveryRequest): Promise<BacklinkDiscoveryResponse> => (await apiClient.post('/api/backlink-outreach/discover', payload)).data;
|
||||
|
||||
export const validateBacklinkPolicy = async (payload: BacklinkPolicyValidationRequest): Promise<BacklinkPolicyValidationResponse> => (await apiClient.post('/api/backlink-outreach/policy-validate', payload)).data;
|
||||
export const fetchBacklinkReportingSnapshot = async (): Promise<BacklinkReportingSnapshot> => (await apiClient.get('/api/backlink-outreach/reporting')).data;
|
||||
|
||||
export const createBacklinkCampaign = async (payload: BacklinkCampaignCreateRequest): Promise<BacklinkCampaignCreateResponse> => (await apiClient.post('/api/backlink-outreach/campaigns', payload)).data;
|
||||
export const listBacklinkCampaigns = async (user_id: string, workspace_id: string): Promise<BacklinkCampaignListResponse> => (await apiClient.get('/api/backlink-outreach/campaigns', { params: { user_id, workspace_id } })).data;
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
import { fetchBacklinkQueryTemplates } from '../../api/backlinkOutreachApi';
|
||||
import { useBacklinkOutreachStore } from '../../stores/backlinkOutreachStore';
|
||||
|
||||
const BacklinkOutreachModuleList: React.FC = () => {
|
||||
const { modules, coverage, campaigns, isLoading, error, refreshBacklinkRegistry, fetchCampaigns, createCampaign } = useBacklinkOutreachStore();
|
||||
const [queryPreview, setQueryPreview] = useState<string[]>([]);
|
||||
const [newCampaignName, setNewCampaignName] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
refreshBacklinkRegistry();
|
||||
}, [refreshBacklinkRegistry]);
|
||||
|
||||
useEffect(() => {
|
||||
const loadPreview = async () => {
|
||||
const response = await fetchBacklinkQueryTemplates('seo');
|
||||
setQueryPreview(response.queries.slice(0, 3));
|
||||
};
|
||||
loadPreview().catch(() => setQueryPreview([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCampaigns('default', 'default').catch(() => {});
|
||||
}, [fetchCampaigns]);
|
||||
|
||||
const handleCreateCampaign = useCallback(async () => {
|
||||
if (!newCampaignName.trim()) return;
|
||||
await createCampaign('default', 'default', newCampaignName.trim());
|
||||
setNewCampaignName('');
|
||||
}, [newCampaignName, createCampaign]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
<h3>Backlink Outreach Modules</h3>
|
||||
{isLoading && <p>Loading backlink module registry…</p>}
|
||||
{error && <p>{error}</p>}
|
||||
{!isLoading && !error && (
|
||||
<>
|
||||
<ul>
|
||||
{modules.map((module) => (
|
||||
<li key={`${module.identifier}:${module.module_path}`}>
|
||||
<strong>{module.identifier}</strong>: {module.module_path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{coverage && (
|
||||
<>
|
||||
<p>
|
||||
Legacy parity: {coverage.implemented_count} implemented / {coverage.planned_count} planned
|
||||
</p>
|
||||
<p>Legacy reference: {coverage.legacy_reference}</p>
|
||||
</>
|
||||
)}
|
||||
{queryPreview.length > 0 && (
|
||||
<>
|
||||
<h4>Legacy query template preview (keyword: seo)</h4>
|
||||
<ul>
|
||||
{queryPreview.map((query) => (
|
||||
<li key={query}>{query}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
<h4>Campaigns</h4>
|
||||
{campaigns.length === 0 ? (
|
||||
<p>No campaigns yet. Create one below.</p>
|
||||
) : (
|
||||
<ul>
|
||||
{campaigns.map((c) => (
|
||||
<li key={c.campaign_id}>
|
||||
<strong>{c.name}</strong> ({c.status})
|
||||
{c.created_at && <span> — {new Date(c.created_at).toLocaleDateString()}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
value={newCampaignName}
|
||||
onChange={(e) => setNewCampaignName(e.target.value)}
|
||||
placeholder="Campaign name"
|
||||
/>
|
||||
<button onClick={handleCreateCampaign} disabled={!newCampaignName.trim()}>
|
||||
Create Campaign
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BacklinkOutreachModuleList;
|
||||
74
frontend/src/stores/backlinkOutreachStore.ts
Normal file
74
frontend/src/stores/backlinkOutreachStore.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { create } from 'zustand';
|
||||
|
||||
import {
|
||||
BacklinkCampaignRecord,
|
||||
BacklinkCoverageResponse,
|
||||
BacklinkModuleRecord,
|
||||
createBacklinkCampaign,
|
||||
fetchBacklinkMigrationCoverage,
|
||||
fetchBacklinkModuleRegistry,
|
||||
listBacklinkCampaigns,
|
||||
} from '../api/backlinkOutreachApi';
|
||||
|
||||
interface BacklinkOutreachStore {
|
||||
modules: BacklinkModuleRecord[];
|
||||
coverage: BacklinkCoverageResponse | null;
|
||||
campaigns: BacklinkCampaignRecord[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
refreshBacklinkRegistry: () => Promise<void>;
|
||||
fetchCampaigns: (userId: string, workspaceId: string) => Promise<void>;
|
||||
createCampaign: (userId: string, workspaceId: string, name: string) => Promise<string | null>;
|
||||
}
|
||||
|
||||
export const useBacklinkOutreachStore = create<BacklinkOutreachStore>((set) => ({
|
||||
modules: [],
|
||||
coverage: null,
|
||||
campaigns: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refreshBacklinkRegistry: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const [registryPayload, coveragePayload] = await Promise.all([
|
||||
fetchBacklinkModuleRegistry(),
|
||||
fetchBacklinkMigrationCoverage(),
|
||||
]);
|
||||
set({ modules: registryPayload.modules, coverage: coveragePayload, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to load backlink module registry',
|
||||
});
|
||||
}
|
||||
},
|
||||
fetchCampaigns: async (userId: string, workspaceId: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const response = await listBacklinkCampaigns(userId, workspaceId);
|
||||
set({ campaigns: response.campaigns, isLoading: false });
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to load campaigns',
|
||||
});
|
||||
}
|
||||
},
|
||||
createCampaign: async (userId: string, workspaceId: string, name: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
try {
|
||||
const result = await createBacklinkCampaign({ user_id: userId, workspace_id: workspaceId, name });
|
||||
set((state) => ({
|
||||
campaigns: [...state.campaigns, { campaign_id: result.campaign_id, name: result.name, status: result.status }],
|
||||
isLoading: false,
|
||||
}));
|
||||
return result.campaign_id;
|
||||
} catch (error: any) {
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error?.message ?? 'Failed to create campaign',
|
||||
});
|
||||
return null;
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -8,4 +8,5 @@ export { useSemanticDashboardStore } from './semanticDashboardStore';
|
||||
export type { DashboardStore } from './dashboardStore';
|
||||
export type { SEODashboardStore } from './seoDashboardStore';
|
||||
export type { SharedDashboardState } from './sharedDashboardStore';
|
||||
export type { SemanticDashboardStore } from './semanticDashboardStore';
|
||||
export type { SemanticDashboardStore } from './semanticDashboardStore';
|
||||
export { useBacklinkOutreachStore } from './backlinkOutreachStore';
|
||||
|
||||
Reference in New Issue
Block a user