Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e9cd0374f4 | |||
| 8a04a23e86 | |||
| 8f5a9e7ede | |||
| 12e49f2811 |
+60
-1
@@ -3,7 +3,7 @@
|
||||
> 本文件为项目进度唯一真理源,所有进度信息以此为准。
|
||||
> 其他文档中的进度描述一律以本文为准。
|
||||
|
||||
**最后更新**:2026-06-17 (v23)
|
||||
**最后更新**:2026-06-21 (v24)
|
||||
|
||||
---
|
||||
|
||||
@@ -173,6 +173,65 @@
|
||||
| Landing 页 GitHub 链接修正 | 2026-06-18 | `your-org` → `github.com` |
|
||||
| v23 全部功能验收 | 2026-06-18 | 12 页 200 OK、org_id 隔离生效、RBAC(require_role)11路由、菜单7项正确、factory/insights 新页、dashboard 重设计 |
|
||||
|
||||
## 十一、外推营销系统(v24 · 2026-06-21)
|
||||
|
||||
### 从「内容生产」到「外部推广」
|
||||
|
||||
**背景**:平台此前只覆盖平台内生产的内容的 SEO/GEO 优化,无法对用户已有的外部网站、公众号、小程序等做 SEO 推广。v24 新增完整的外部推广模块。
|
||||
|
||||
### 新增数据模型(6 张表)
|
||||
|
||||
| 模型 | 表名 | 说明 |
|
||||
|------|------|------|
|
||||
| `ExternalProduct` | `external_products` | 外部推广产品(website/article/wechat_account/miniprogram)|
|
||||
| `PromotionCampaign` | `promotion_campaigns` | 推广活动,关联产品和关键词 |
|
||||
| `CampaignKeyword` | `campaign_keywords` | 关键词跟踪(搜索量/难度/排名)|
|
||||
| `SEOAudit` | `seo_audits` | SEO 审计报告(6 维度评分)|
|
||||
| `KeywordRanking` | `keyword_rankings` | 多引擎关键词排名记录 |
|
||||
| `OptimizationTask` | `optimization_tasks` | AI 生成的优化建议 |
|
||||
|
||||
### API 端点(20 个)
|
||||
|
||||
| 端点 | 功能 |
|
||||
|------|------|
|
||||
| `GET/POST/PUT/DELETE /api/external/products` | 产品 CRUD |
|
||||
| `GET/POST/PUT/DELETE /api/external/campaigns` | 活动 CRUD |
|
||||
| `GET/POST/PUT/DELETE /api/external/keywords` | 关键词 CRUD |
|
||||
| `GET /api/external/audits` | SEO 审计记录列表 |
|
||||
| `GET /api/external/audits/{id}` | 审计详情(含优化建议)|
|
||||
| `GET /api/external/rankings` | 关键词排名列表 |
|
||||
| `GET /api/external/rankings/overview` | 排名概览统计 |
|
||||
| `GET /api/external/optimization-tasks` | 优化建议列表 |
|
||||
| `PATCH /api/external/optimization-tasks/{id}` | 更新任务状态 |
|
||||
| `GET /api/external/overview` | 外推营销总览 |
|
||||
|
||||
### SEO 审计脚本
|
||||
|
||||
`scripts/seo_auditor.py` — 自动抓取目标网站并分析:
|
||||
|
||||
| 维度 | 权重 | 检测项 |
|
||||
|------|------|--------|
|
||||
| Meta 标签 | 20% | title/description/keywords/OG tags |
|
||||
| 标题结构 | 15% | H1/H2 存在性、层级连续性 |
|
||||
| 内容质量 | 25% | 字数、图片 alt、关键词密度 |
|
||||
| 性能 | 10% | 渲染阻塞资源、图片尺寸 |
|
||||
| 链接 | 10% | 内链/外链/断链/nofollow |
|
||||
| 移动端 | 20% | viewport、响应式、字号 |
|
||||
|
||||
审计结果自动保存到 DB,生成可追踪的 OptimizationTask。
|
||||
|
||||
### 关键文件变更
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `platform/backend/app/models.py` | 新增 6 个模型(ExternalProduct/PromotionCampaign/CampaignKeyword/SEOAudit/KeywordRanking/OptimizationTask)|
|
||||
| `platform/backend/app/schemas.py` | 新增 18 个 Pydantic schema 类 |
|
||||
| `platform/backend/app/api/external_promotion.py` | **新建** — 20 个 API 端点 |
|
||||
| `platform/backend/app/main.py` | 注册 external_promotion router |
|
||||
| `platform/frontend/campaigns.html` | **新建** — 外推营销 SPA 页面(6 Tab)|
|
||||
| `platform/frontend/uni-nav.js` | 导航增加「外推营销」入口 |
|
||||
| `scripts/seo_auditor.py` | **新建** — SEO 审计自动化脚本 |
|
||||
|
||||
---
|
||||
|
||||
## 六、归档日志
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.orm import Session
|
||||
from sqlalchemy import desc
|
||||
from typing import Optional
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from ..database import get_db
|
||||
from ..models import ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask, User
|
||||
from .auth import get_current_user, org_filter
|
||||
|
||||
router = APIRouter(prefix="/api/external", tags=["external_promotion"])
|
||||
|
||||
|
||||
def _org_filtered_query(db, model, current_user):
|
||||
query = db.query(model)
|
||||
of = org_filter(current_user, model)
|
||||
if of is not True:
|
||||
query = query.filter(of)
|
||||
return query
|
||||
|
||||
|
||||
# ===== External Products =====
|
||||
|
||||
@router.get("/products")
|
||||
def list_products(
|
||||
type: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = _org_filtered_query(db, ExternalProduct, current_user)
|
||||
if type:
|
||||
query = query.filter(ExternalProduct.type == type)
|
||||
products = query.order_by(desc(ExternalProduct.created_at)).limit(limit).all()
|
||||
return {"ok": True, "data": [p.to_dict() for p in products]}
|
||||
|
||||
|
||||
@router.get("/products/{product_id}")
|
||||
def get_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
product = _org_filtered_query(db, ExternalProduct, current_user).filter(
|
||||
ExternalProduct.id == product_id
|
||||
).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
return {"ok": True, "data": product.to_dict()}
|
||||
|
||||
|
||||
@router.post("/products")
|
||||
def create_product(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
product = ExternalProduct(
|
||||
name=body.get("name"),
|
||||
type=body.get("type", "website"),
|
||||
url=body.get("url"),
|
||||
domain=body.get("domain"),
|
||||
app_id=body.get("app_id"),
|
||||
account_id=body.get("account_id"),
|
||||
description=body.get("description"),
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
db.add(product)
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return {"ok": True, "data": product.to_dict()}
|
||||
|
||||
|
||||
@router.put("/products/{product_id}")
|
||||
def update_product(
|
||||
product_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
product = _org_filtered_query(db, ExternalProduct, current_user).filter(
|
||||
ExternalProduct.id == product_id
|
||||
).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
for field in ("name", "type", "url", "domain", "app_id", "account_id", "description"):
|
||||
if field in body:
|
||||
setattr(product, field, body[field])
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
return {"ok": True, "data": product.to_dict()}
|
||||
|
||||
|
||||
@router.delete("/products/{product_id}")
|
||||
def delete_product(
|
||||
product_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
product = _org_filtered_query(db, ExternalProduct, current_user).filter(
|
||||
ExternalProduct.id == product_id
|
||||
).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
return {"ok": True, "message": "已删除"}
|
||||
|
||||
|
||||
# ===== Promotion Campaigns =====
|
||||
|
||||
@router.get("/campaigns")
|
||||
def list_campaigns(
|
||||
product_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = _org_filtered_query(db, PromotionCampaign, current_user)
|
||||
if product_id:
|
||||
query = query.filter(PromotionCampaign.product_id == product_id)
|
||||
if status:
|
||||
query = query.filter(PromotionCampaign.status == status)
|
||||
campaigns = query.order_by(desc(PromotionCampaign.created_at)).limit(limit).all()
|
||||
result = []
|
||||
for c in campaigns:
|
||||
d = c.to_dict()
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == c.product_id).first()
|
||||
d["product_name"] = product.name if product else None
|
||||
result.append(d)
|
||||
return {"ok": True, "data": result}
|
||||
|
||||
|
||||
@router.get("/campaigns/{campaign_id}")
|
||||
def get_campaign(
|
||||
campaign_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
campaign = _org_filtered_query(db, PromotionCampaign, current_user).filter(
|
||||
PromotionCampaign.id == campaign_id
|
||||
).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
d = campaign.to_dict()
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == campaign.product_id).first()
|
||||
d["product_name"] = product.name if product else None
|
||||
return {"ok": True, "data": d}
|
||||
|
||||
|
||||
@router.post("/campaigns")
|
||||
def create_campaign(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == body.get("product_id")).first()
|
||||
if not product:
|
||||
raise HTTPException(status_code=404, detail="产品不存在")
|
||||
campaign = PromotionCampaign(
|
||||
name=body.get("name"),
|
||||
product_id=body.get("product_id"),
|
||||
status=body.get("status", "active"),
|
||||
keywords=body.get("keywords", []),
|
||||
target_engines=body.get("target_engines", []),
|
||||
notes=body.get("notes"),
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
db.add(campaign)
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
d = campaign.to_dict()
|
||||
d["product_name"] = product.name
|
||||
return {"ok": True, "data": d}
|
||||
|
||||
|
||||
@router.put("/campaigns/{campaign_id}")
|
||||
def update_campaign(
|
||||
campaign_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
campaign = _org_filtered_query(db, PromotionCampaign, current_user).filter(
|
||||
PromotionCampaign.id == campaign_id
|
||||
).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
for field in ("name", "status", "keywords", "target_engines", "notes"):
|
||||
if field in body:
|
||||
setattr(campaign, field, body[field])
|
||||
db.commit()
|
||||
db.refresh(campaign)
|
||||
d = campaign.to_dict()
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == campaign.product_id).first()
|
||||
d["product_name"] = product.name if product else None
|
||||
return {"ok": True, "data": d}
|
||||
|
||||
|
||||
@router.delete("/campaigns/{campaign_id}")
|
||||
def delete_campaign(
|
||||
campaign_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
campaign = _org_filtered_query(db, PromotionCampaign, current_user).filter(
|
||||
PromotionCampaign.id == campaign_id
|
||||
).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
db.delete(campaign)
|
||||
db.commit()
|
||||
return {"ok": True, "message": "已删除"}
|
||||
|
||||
|
||||
# ===== Campaign Keywords =====
|
||||
|
||||
@router.get("/keywords")
|
||||
def list_keywords(
|
||||
campaign_id: Optional[int] = None,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = _org_filtered_query(db, CampaignKeyword, current_user)
|
||||
if campaign_id:
|
||||
query = query.filter(CampaignKeyword.campaign_id == campaign_id)
|
||||
keywords = query.order_by(desc(CampaignKeyword.created_at)).limit(limit).all()
|
||||
return {"ok": True, "data": [k.to_dict() for k in keywords]}
|
||||
|
||||
|
||||
@router.post("/keywords")
|
||||
def create_keyword(
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
campaign = db.query(PromotionCampaign).filter(PromotionCampaign.id == body.get("campaign_id")).first()
|
||||
if not campaign:
|
||||
raise HTTPException(status_code=404, detail="活动不存在")
|
||||
kw = CampaignKeyword(
|
||||
campaign_id=body.get("campaign_id"),
|
||||
keyword=body.get("keyword"),
|
||||
search_volume=body.get("search_volume"),
|
||||
difficulty=body.get("difficulty"),
|
||||
current_rank=body.get("current_rank"),
|
||||
target_rank=body.get("target_rank"),
|
||||
org_id=current_user.org_id,
|
||||
)
|
||||
db.add(kw)
|
||||
db.commit()
|
||||
db.refresh(kw)
|
||||
return {"ok": True, "data": kw.to_dict()}
|
||||
|
||||
|
||||
@router.put("/keywords/{keyword_id}")
|
||||
def update_keyword(
|
||||
keyword_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
kw = _org_filtered_query(db, CampaignKeyword, current_user).filter(
|
||||
CampaignKeyword.id == keyword_id
|
||||
).first()
|
||||
if not kw:
|
||||
raise HTTPException(status_code=404, detail="关键词不存在")
|
||||
for field in ("search_volume", "difficulty", "current_rank", "target_rank"):
|
||||
if field in body:
|
||||
setattr(kw, field, body[field])
|
||||
if body.get("update_last_checked"):
|
||||
kw.last_checked = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
db.refresh(kw)
|
||||
return {"ok": True, "data": kw.to_dict()}
|
||||
|
||||
|
||||
@router.delete("/keywords/{keyword_id}")
|
||||
def delete_keyword(
|
||||
keyword_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
kw = _org_filtered_query(db, CampaignKeyword, current_user).filter(
|
||||
CampaignKeyword.id == keyword_id
|
||||
).first()
|
||||
if not kw:
|
||||
raise HTTPException(status_code=404, detail="关键词不存在")
|
||||
db.delete(kw)
|
||||
db.commit()
|
||||
return {"ok": True, "message": "已删除"}
|
||||
|
||||
|
||||
# ===== SEO Audits =====
|
||||
|
||||
@router.get("/audits")
|
||||
def list_audits(
|
||||
product_id: Optional[int] = None,
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = _org_filtered_query(db, SEOAudit, current_user)
|
||||
if product_id:
|
||||
query = query.filter(SEOAudit.product_id == product_id)
|
||||
audits = query.order_by(desc(SEOAudit.checked_at)).limit(limit).all()
|
||||
result = []
|
||||
for a in audits:
|
||||
d = a.to_dict()
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == a.product_id).first()
|
||||
d["product_name"] = product.name if product else None
|
||||
result.append(d)
|
||||
return {"ok": True, "data": result}
|
||||
|
||||
|
||||
@router.get("/audits/{audit_id}")
|
||||
def get_audit(
|
||||
audit_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
audit = _org_filtered_query(db, SEOAudit, current_user).filter(
|
||||
SEOAudit.id == audit_id
|
||||
).first()
|
||||
if not audit:
|
||||
raise HTTPException(status_code=404, detail="审计记录不存在")
|
||||
d = audit.to_dict()
|
||||
d["raw_data"] = audit.raw_data or {}
|
||||
tasks = db.query(OptimizationTask).filter(OptimizationTask.audit_id == audit_id).all()
|
||||
d["optimization_tasks"] = [t.to_dict() for t in tasks]
|
||||
return {"ok": True, "data": d}
|
||||
|
||||
|
||||
# ===== Keyword Rankings =====
|
||||
|
||||
@router.get("/rankings")
|
||||
def list_keyword_rankings(
|
||||
campaign_id: Optional[int] = None,
|
||||
keyword_id: Optional[int] = None,
|
||||
product_id: Optional[int] = None,
|
||||
search_engine: Optional[str] = None,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = _org_filtered_query(db, KeywordRanking, current_user)
|
||||
if campaign_id:
|
||||
query = query.filter(KeywordRanking.campaign_id == campaign_id)
|
||||
if keyword_id:
|
||||
query = query.filter(KeywordRanking.keyword_id == keyword_id)
|
||||
if product_id:
|
||||
query = query.filter(KeywordRanking.product_id == product_id)
|
||||
if search_engine:
|
||||
query = query.filter(KeywordRanking.search_engine == search_engine)
|
||||
rankings = query.order_by(desc(KeywordRanking.checked_at)).limit(limit).all()
|
||||
return {"ok": True, "data": [r.to_dict() for r in rankings]}
|
||||
|
||||
|
||||
@router.get("/rankings/overview")
|
||||
def rankings_overview(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
base = _org_filtered_query(db, KeywordRanking, current_user)
|
||||
total = base.count()
|
||||
with_rank = base.filter(KeywordRanking.rank.isnot(None)).count()
|
||||
engine_breakdown = {}
|
||||
for row in base.with_entities(KeywordRanking.search_engine,
|
||||
KeywordRanking.rank).all():
|
||||
eng = row.search_engine
|
||||
if eng not in engine_breakdown:
|
||||
engine_breakdown[eng] = {"total": 0, "with_rank": 0}
|
||||
engine_breakdown[eng]["total"] += 1
|
||||
if row.rank is not None:
|
||||
engine_breakdown[eng]["with_rank"] += 1
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"total": total,
|
||||
"with_rank": with_rank,
|
||||
"engine_breakdown": engine_breakdown,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ===== Optimization Tasks =====
|
||||
|
||||
@router.get("/optimization-tasks")
|
||||
def list_optimization_tasks(
|
||||
product_id: Optional[int] = None,
|
||||
audit_id: Optional[int] = None,
|
||||
status: Optional[str] = None,
|
||||
severity: Optional[str] = None,
|
||||
limit: int = Query(100, ge=1, le=500),
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
query = _org_filtered_query(db, OptimizationTask, current_user)
|
||||
if product_id:
|
||||
query = query.filter(OptimizationTask.product_id == product_id)
|
||||
if audit_id:
|
||||
query = query.filter(OptimizationTask.audit_id == audit_id)
|
||||
if status:
|
||||
query = query.filter(OptimizationTask.status == status)
|
||||
if severity:
|
||||
query = query.filter(OptimizationTask.severity == severity)
|
||||
tasks = query.order_by(desc(OptimizationTask.created_at)).limit(limit).all()
|
||||
return {"ok": True, "data": [t.to_dict() for t in tasks]}
|
||||
|
||||
|
||||
@router.patch("/optimization-tasks/{task_id}")
|
||||
def update_optimization_task(
|
||||
task_id: int,
|
||||
body: dict,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
task = _org_filtered_query(db, OptimizationTask, current_user).filter(
|
||||
OptimizationTask.id == task_id
|
||||
).first()
|
||||
if not task:
|
||||
raise HTTPException(status_code=404, detail="优化任务不存在")
|
||||
if "status" in body:
|
||||
task.status = body["status"]
|
||||
if "severity" in body:
|
||||
task.severity = body["severity"]
|
||||
if "recommendation" in body:
|
||||
task.recommendation = body["recommendation"]
|
||||
db.commit()
|
||||
db.refresh(task)
|
||||
return {"ok": True, "data": task.to_dict()}
|
||||
|
||||
|
||||
@router.get("/overview")
|
||||
def external_overview(
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
products = _org_filtered_query(db, ExternalProduct, current_user).count()
|
||||
campaigns = _org_filtered_query(db, PromotionCampaign, current_user).count()
|
||||
active_campaigns = _org_filtered_query(db, PromotionCampaign, current_user).filter(
|
||||
PromotionCampaign.status == "active"
|
||||
).count()
|
||||
audits = _org_filtered_query(db, SEOAudit, current_user).count()
|
||||
keywords = _org_filtered_query(db, CampaignKeyword, current_user).count()
|
||||
open_tasks = _org_filtered_query(db, OptimizationTask, current_user).filter(
|
||||
OptimizationTask.status == "open"
|
||||
).count()
|
||||
|
||||
type_breakdown = {}
|
||||
for row in _org_filtered_query(db, ExternalProduct, current_user).with_entities(
|
||||
ExternalProduct.type
|
||||
).all():
|
||||
t = row.type
|
||||
type_breakdown[t] = type_breakdown.get(t, 0) + 1
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"data": {
|
||||
"products": products,
|
||||
"campaigns": campaigns,
|
||||
"active_campaigns": active_campaigns,
|
||||
"audits": audits,
|
||||
"keywords": keywords,
|
||||
"open_tasks": open_tasks,
|
||||
"type_breakdown": type_breakdown,
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ from pathlib import Path
|
||||
|
||||
from .database import engine, get_db, init_db
|
||||
from .models import Base
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings
|
||||
from .api import topics, system, articles, publishing, auth, admin, audit, optimizer_logs, cases, task_logs, task_configs, prompt_configs, llm_configs, system_configs, topic_config, calendar, metrics, assets, tasks, platform_config, collector_mgmt, assistant, config_items, role_configs, menu_configs, search_providers, search_rankings, external_promotion
|
||||
from .initial_data import import_initial_data
|
||||
from .core.scheduler import scheduler
|
||||
|
||||
@@ -105,6 +105,7 @@ app.include_router(menu_configs.router)
|
||||
app.include_router(menu_configs.public_router)
|
||||
app.include_router(search_providers.router)
|
||||
app.include_router(search_rankings.router)
|
||||
app.include_router(external_promotion.router)
|
||||
|
||||
# 挂载自动生成的图片(必须先于前端根挂载)
|
||||
PROJECT_ROOT_DIR = Path(__file__).parent.parent.parent.parent
|
||||
|
||||
@@ -947,3 +947,208 @@ class GeoReadinessScore(Base):
|
||||
"heading_structure_score": self.heading_structure_score,
|
||||
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
|
||||
}
|
||||
|
||||
|
||||
class ExternalProduct(Base):
|
||||
"""外部推广产品"""
|
||||
__tablename__ = "external_products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String, nullable=False, comment="产品名称")
|
||||
type = Column(String, nullable=False, comment="类型: website/article/wechat_account/miniprogram")
|
||||
url = Column(String, nullable=True, comment="主URL")
|
||||
domain = Column(String, nullable=True, comment="域名")
|
||||
app_id = Column(String, nullable=True, comment="小程序AppID")
|
||||
account_id = Column(String, nullable=True, comment="公众号ID")
|
||||
description = Column(Text, nullable=True, comment="产品描述")
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"type": self.type,
|
||||
"url": self.url,
|
||||
"domain": self.domain,
|
||||
"app_id": self.app_id,
|
||||
"account_id": self.account_id,
|
||||
"description": self.description,
|
||||
"org_id": self.org_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class PromotionCampaign(Base):
|
||||
"""推广活动"""
|
||||
__tablename__ = "promotion_campaigns"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
name = Column(String, nullable=False, comment="活动名称")
|
||||
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=False)
|
||||
status = Column(String, default="active", comment="状态: active/paused/completed")
|
||||
keywords = Column(JSON, default=list, comment="目标关键词列表")
|
||||
target_engines = Column(JSON, default=list, comment="目标搜索引擎列表")
|
||||
notes = Column(Text, nullable=True, comment="备注")
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
product = relationship("ExternalProduct", backref="campaigns")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"name": self.name,
|
||||
"product_id": self.product_id,
|
||||
"status": self.status,
|
||||
"keywords": self.keywords or [],
|
||||
"target_engines": self.target_engines or [],
|
||||
"notes": self.notes,
|
||||
"org_id": self.org_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class CampaignKeyword(Base):
|
||||
"""推广关键词"""
|
||||
__tablename__ = "campaign_keywords"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
campaign_id = Column(Integer, ForeignKey("promotion_campaigns.id"), nullable=False)
|
||||
keyword = Column(String, nullable=False, comment="关键词")
|
||||
search_volume = Column(Integer, nullable=True, comment="搜索量")
|
||||
difficulty = Column(Float, nullable=True, comment="竞争难度 0-1")
|
||||
current_rank = Column(Integer, nullable=True, comment="当前排名")
|
||||
target_rank = Column(Integer, nullable=True, comment="目标排名")
|
||||
best_rank = Column(Integer, nullable=True, comment="历史最佳排名")
|
||||
last_checked = Column(DateTime(timezone=True), nullable=True, comment="最后检查时间")
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
campaign = relationship("PromotionCampaign", backref="campaign_keywords")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"campaign_id": self.campaign_id,
|
||||
"keyword": self.keyword,
|
||||
"search_volume": self.search_volume,
|
||||
"difficulty": self.difficulty,
|
||||
"current_rank": self.current_rank,
|
||||
"target_rank": self.target_rank,
|
||||
"best_rank": self.best_rank,
|
||||
"last_checked": self.last_checked.isoformat() if self.last_checked else None,
|
||||
"org_id": self.org_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
class SEOAudit(Base):
|
||||
"""SEO审计报告"""
|
||||
__tablename__ = "seo_audits"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=False)
|
||||
audit_type = Column(String, default="full", comment="审计类型: full/quick")
|
||||
overall_score = Column(Float, nullable=True, comment="总分 0-100")
|
||||
meta_score = Column(Float, nullable=True, comment="Meta标签评分")
|
||||
heading_score = Column(Float, nullable=True, comment="标题结构评分")
|
||||
content_score = Column(Float, nullable=True, comment="内容评分")
|
||||
perf_score = Column(Float, nullable=True, comment="性能评分")
|
||||
links_score = Column(Float, nullable=True, comment="链接评分")
|
||||
mobile_score = Column(Float, nullable=True, comment="移动端评分")
|
||||
raw_data = Column(JSON, default=dict, comment="审计详情JSON")
|
||||
page_count = Column(Integer, nullable=True, comment="审计页面数")
|
||||
issues_found = Column(Integer, nullable=True, comment="发现问题数")
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
checked_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
product = relationship("ExternalProduct", backref="audits")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"product_id": self.product_id,
|
||||
"audit_type": self.audit_type,
|
||||
"overall_score": self.overall_score,
|
||||
"meta_score": self.meta_score,
|
||||
"heading_score": self.heading_score,
|
||||
"content_score": self.content_score,
|
||||
"perf_score": self.perf_score,
|
||||
"links_score": self.links_score,
|
||||
"mobile_score": self.mobile_score,
|
||||
"page_count": self.page_count,
|
||||
"issues_found": self.issues_found,
|
||||
"org_id": self.org_id,
|
||||
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
|
||||
}
|
||||
|
||||
|
||||
class KeywordRanking(Base):
|
||||
"""外部关键词多引擎排名"""
|
||||
__tablename__ = "keyword_rankings"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
campaign_id = Column(Integer, ForeignKey("promotion_campaigns.id"), nullable=True)
|
||||
keyword_id = Column(Integer, ForeignKey("campaign_keywords.id"), nullable=True)
|
||||
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=True)
|
||||
keyword = Column(String, nullable=False, comment="关键词")
|
||||
search_engine = Column(String, nullable=False, comment="搜索引擎: baidu/360/sogou/wechat/bing/google")
|
||||
rank = Column(Integer, nullable=True, comment="排名位置")
|
||||
url_found = Column(String, nullable=True, comment="排名URL")
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
checked_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"campaign_id": self.campaign_id,
|
||||
"keyword_id": self.keyword_id,
|
||||
"product_id": self.product_id,
|
||||
"keyword": self.keyword,
|
||||
"search_engine": self.search_engine,
|
||||
"rank": self.rank,
|
||||
"url_found": self.url_found,
|
||||
"org_id": self.org_id,
|
||||
"checked_at": self.checked_at.isoformat() if self.checked_at else None,
|
||||
}
|
||||
|
||||
|
||||
class OptimizationTask(Base):
|
||||
"""优化建议任务"""
|
||||
__tablename__ = "optimization_tasks"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, autoincrement=True)
|
||||
audit_id = Column(Integer, ForeignKey("seo_audits.id"), nullable=False)
|
||||
product_id = Column(Integer, ForeignKey("external_products.id"), nullable=False)
|
||||
category = Column(String, nullable=False, comment="分类: meta/heading/content/performance/links/mobile/other")
|
||||
severity = Column(String, default="medium", comment="严重度: high/medium/low")
|
||||
issue = Column(Text, nullable=False, comment="问题描述")
|
||||
recommendation = Column(Text, nullable=True, comment="优化建议")
|
||||
status = Column(String, default="open", comment="状态: open/resolved/ignored")
|
||||
org_id = Column(String, default="default", nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
audit = relationship("SEOAudit", backref="optimization_tasks")
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
"id": self.id,
|
||||
"audit_id": self.audit_id,
|
||||
"product_id": self.product_id,
|
||||
"category": self.category,
|
||||
"severity": self.severity,
|
||||
"issue": self.issue,
|
||||
"recommendation": self.recommendation,
|
||||
"status": self.status,
|
||||
"org_id": self.org_id,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None,
|
||||
"updated_at": self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
@@ -636,3 +636,163 @@ class MenuResponse(MenuBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
# ========== 外部推广模块 ==========
|
||||
|
||||
class ExternalProductBase(BaseModel):
|
||||
name: str
|
||||
type: str = "website"
|
||||
url: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
app_id: Optional[str] = None
|
||||
account_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ExternalProductCreate(ExternalProductBase):
|
||||
pass
|
||||
|
||||
|
||||
class ExternalProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
type: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
domain: Optional[str] = None
|
||||
app_id: Optional[str] = None
|
||||
account_id: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class ExternalProductResponse(ExternalProductBase):
|
||||
id: int
|
||||
org_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class PromotionCampaignBase(BaseModel):
|
||||
name: str
|
||||
product_id: int
|
||||
status: str = "active"
|
||||
keywords: List[str] = []
|
||||
target_engines: List[str] = []
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PromotionCampaignCreate(PromotionCampaignBase):
|
||||
pass
|
||||
|
||||
|
||||
class PromotionCampaignUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
status: Optional[str] = None
|
||||
keywords: Optional[List[str]] = None
|
||||
target_engines: Optional[List[str]] = None
|
||||
notes: Optional[str] = None
|
||||
|
||||
|
||||
class PromotionCampaignResponse(PromotionCampaignBase):
|
||||
id: int
|
||||
org_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class CampaignKeywordBase(BaseModel):
|
||||
campaign_id: int
|
||||
keyword: str
|
||||
search_volume: Optional[int] = None
|
||||
difficulty: Optional[float] = None
|
||||
current_rank: Optional[int] = None
|
||||
target_rank: Optional[int] = None
|
||||
|
||||
|
||||
class CampaignKeywordCreate(CampaignKeywordBase):
|
||||
pass
|
||||
|
||||
|
||||
class CampaignKeywordUpdate(BaseModel):
|
||||
search_volume: Optional[int] = None
|
||||
difficulty: Optional[float] = None
|
||||
current_rank: Optional[int] = None
|
||||
target_rank: Optional[int] = None
|
||||
|
||||
|
||||
class CampaignKeywordResponse(CampaignKeywordBase):
|
||||
id: int
|
||||
best_rank: Optional[int] = None
|
||||
last_checked: Optional[datetime] = None
|
||||
org_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class SEOAuditResponse(BaseModel):
|
||||
id: int
|
||||
product_id: int
|
||||
audit_type: str
|
||||
overall_score: Optional[float] = None
|
||||
meta_score: Optional[float] = None
|
||||
heading_score: Optional[float] = None
|
||||
content_score: Optional[float] = None
|
||||
perf_score: Optional[float] = None
|
||||
links_score: Optional[float] = None
|
||||
mobile_score: Optional[float] = None
|
||||
page_count: Optional[int] = None
|
||||
issues_found: Optional[int] = None
|
||||
org_id: Optional[str] = None
|
||||
checked_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class KeywordRankingResponse(BaseModel):
|
||||
id: int
|
||||
campaign_id: Optional[int] = None
|
||||
keyword_id: Optional[int] = None
|
||||
product_id: Optional[int] = None
|
||||
keyword: str
|
||||
search_engine: str
|
||||
rank: Optional[int] = None
|
||||
url_found: Optional[str] = None
|
||||
org_id: Optional[str] = None
|
||||
checked_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class OptimizationTaskBase(BaseModel):
|
||||
category: str
|
||||
severity: str = "medium"
|
||||
issue: str
|
||||
recommendation: Optional[str] = None
|
||||
|
||||
|
||||
class OptimizationTaskCreate(OptimizationTaskBase):
|
||||
audit_id: int
|
||||
product_id: int
|
||||
|
||||
|
||||
class OptimizationTaskUpdate(BaseModel):
|
||||
status: Optional[str] = None
|
||||
severity: Optional[str] = None
|
||||
recommendation: Optional[str] = None
|
||||
|
||||
|
||||
class OptimizationTaskResponse(OptimizationTaskBase):
|
||||
id: int
|
||||
audit_id: int
|
||||
product_id: int
|
||||
status: str
|
||||
org_id: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>宇之然内容创作平台 - 外推营销</title>
|
||||
<link rel="stylesheet" href="element-plus.css">
|
||||
<link rel="stylesheet" href="theme-modern.css">
|
||||
<style>
|
||||
.data-table { display: block; }
|
||||
@media (max-width: 768px) { .data-table { display: none; } }
|
||||
.stat-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px; margin-bottom: 16px; }
|
||||
.stat-card { background: #f8f9ff; border-radius: 10px; padding: 16px; text-align: center; }
|
||||
.stat-card .num { font-size: 28px; font-weight: 700; color: #2563eb; }
|
||||
.stat-card .label { font-size: 12px; color: #909399; margin-top: 4px; }
|
||||
.search-engine-tag { margin: 2px; }
|
||||
</style>
|
||||
<script src="uni-nav.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" v-cloak>
|
||||
<uni-nav title="外推营销" :username="currentUser.username" :is-admin="isAdmin" current-page="campaigns" @navigate="redirectToPage" @logout="logout">
|
||||
</uni-nav>
|
||||
<div class="main-content">
|
||||
<main class="content-area">
|
||||
<div class="card page-fade">
|
||||
<div class="page-header">
|
||||
<h2 class="page-title"><el-icon style="vertical-align:-2px;"><IconMenu /></el-icon> 外推营销</h2>
|
||||
<div class="filter-bar">
|
||||
<el-button size="default" :type="activeTab === 'overview' ? 'primary' : ''" @click="switchTab('overview')">概览</el-button>
|
||||
<el-button size="default" :type="activeTab === 'products' ? 'primary' : ''" @click="switchTab('products')">产品管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'campaigns' ? 'primary' : ''" @click="switchTab('campaigns')">活动管理</el-button>
|
||||
<el-button size="default" :type="activeTab === 'rankings' ? 'primary' : ''" @click="switchTab('rankings')">关键词排名</el-button>
|
||||
<el-button size="default" :type="activeTab === 'audits' ? 'primary' : ''" @click="switchTab('audits')">SEO审计</el-button>
|
||||
<el-button size="default" :type="activeTab === 'tasks' ? 'primary' : ''" @click="switchTab('tasks')">优化建议</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'overview'">
|
||||
<div v-if="loading" class="card-loading">加载中...</div>
|
||||
<template v-else>
|
||||
<div class="stat-cards">
|
||||
<div class="stat-card"><div class="num">{{ overview.products || 0 }}</div><div class="label">推广产品</div></div>
|
||||
<div class="stat-card"><div class="num">{{ overview.campaigns || 0 }}</div><div class="label">推广活动</div></div>
|
||||
<div class="stat-card"><div class="num">{{ overview.active_campaigns || 0 }}</div><div class="label">进行中</div></div>
|
||||
<div class="stat-card"><div class="num">{{ overview.audits || 0 }}</div><div class="label">SEO审计</div></div>
|
||||
<div class="stat-card"><div class="num">{{ overview.keywords || 0 }}</div><div class="label">追踪关键词</div></div>
|
||||
<div class="stat-card"><div class="num">{{ overview.open_tasks || 0 }}</div><div class="label">待处理建议</div></div>
|
||||
</div>
|
||||
<div v-if="overview.type_breakdown" style="margin-top:16px;">
|
||||
<h4 style="font-size:14px;color:#303133;margin-bottom:8px;">产品类型分布</h4>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;">
|
||||
<el-tag v-for="(cnt, type) in overview.type_breakdown" :key="type" size="large">{{ typeLabel(type) }}: {{ cnt }}</el-tag>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:20px;">
|
||||
<el-button type="primary" @click="switchTab('products')">管理推广产品</el-button>
|
||||
<el-button @click="switchTab('campaigns')">查看活动</el-button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'products'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showProductDialog = true">新增产品</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ products.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="loading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="products.length === 0">
|
||||
<div class="empty-state">
|
||||
<div class="empty-text">暂无推广产品,点击上方按钮添加</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="products" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="id" label="ID" width="60"></el-table-column>
|
||||
<el-table-column prop="name" label="产品名称" min-width="120"></el-table-column>
|
||||
<el-table-column label="类型" width="110">
|
||||
<template #default="s">{{ typeLabel(s.row.type) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url" label="URL" min-width="180">
|
||||
<template #default="s"><a :href="s.row.url" target="_blank" style="color:#409eff;">{{ s.row.url || '-' }}</a></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="domain" label="域名" width="140"></el-table-column>
|
||||
<el-table-column label="操作" width="150" fixed="right">
|
||||
<template #default="s">
|
||||
<el-button size="small" @click="editProduct(s.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteProduct(s.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="products.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="products.length" :page-size="pageSize" :current-page="productPage" @current-change="productPage = $event"></el-pagination>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'campaigns'">
|
||||
<div class="toolbar">
|
||||
<el-button type="primary" size="small" @click="showCampaignDialog = true">新增活动</el-button>
|
||||
<span style="font-size:13px;color:#909399;margin-left:8px;">共 {{ campaigns.length }} 个</span>
|
||||
</div>
|
||||
<div v-if="loading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="campaigns.length === 0">
|
||||
<div class="empty-state"><div class="empty-text">暂无推广活动</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="campaigns" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="name" label="活动名称" min-width="120"></el-table-column>
|
||||
<el-table-column prop="product_name" label="关联产品" width="120"></el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="s">
|
||||
<el-tag :type="s.row.status === 'active' ? 'success' : s.row.status === 'paused' ? 'warning' : 'info'" size="small">{{ { active: '进行中', paused: '已暂停', completed: '已完成' }[s.row.status] || s.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="目标引擎" min-width="140">
|
||||
<template #default="s">
|
||||
<el-tag v-for="e in (s.row.target_engines || [])" :key="e" size="small" class="search-engine-tag">{{ engineLabel(e) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="关键词数" width="80">
|
||||
<template #default="s">{{ (s.row.keywords || []).length }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="200" fixed="right">
|
||||
<template #default="s">
|
||||
<el-button size="small" @click="viewCampaignKeywords(s.row)">关键词</el-button>
|
||||
<el-button size="small" @click="editCampaign(s.row)">编辑</el-button>
|
||||
<el-button size="small" type="danger" @click="deleteCampaign(s.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="campaigns.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="campaigns.length" :page-size="pageSize" :current-page="campaignPage" @current-change="campaignPage = $event"></el-pagination>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="selectedCampaign" style="margin-top:20px;border-top:1px solid #ebeef5;padding-top:16px;">
|
||||
<h4 style="font-size:14px;color:#303133;margin-bottom:8px;">
|
||||
关键词详情:{{ selectedCampaign.name }}
|
||||
<el-button size="small" type="primary" style="margin-left:8px;" @click="showKeywordDialog = true">添加关键词</el-button>
|
||||
</h4>
|
||||
<el-table v-if="campaignKeywords.length" :data="campaignKeywords" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="keyword" label="关键词" min-width="150"></el-table-column>
|
||||
<el-table-column prop="search_volume" label="搜索量" width="80"></el-table-column>
|
||||
<el-table-column label="难度" width="70">
|
||||
<template #default="s"><el-tag :type="(s.row.difficulty || 0) > 0.7 ? 'danger' : (s.row.difficulty || 0) > 0.4 ? 'warning' : 'success'" size="small">{{ s.row.difficulty != null ? (s.row.difficulty * 100).toFixed(0) + '%' : '-' }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="current_rank" label="当前排名" width="80"></el-table-column>
|
||||
<el-table-column prop="target_rank" label="目标排名" width="80"></el-table-column>
|
||||
<el-table-column prop="best_rank" label="最佳排名" width="80"></el-table-column>
|
||||
<el-table-column label="操作" width="100">
|
||||
<template #default="s">
|
||||
<el-button size="small" type="danger" @click="deleteKeyword(s.row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-else style="text-align:center;padding:20px;color:#909399;font-size:13px;">暂无关键词数据</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'rankings'">
|
||||
<div class="toolbar">
|
||||
<span style="font-size:13px;color:#909399;">
|
||||
共 {{ keywordRankings.length }} 条记录
|
||||
<span v-if="rankingOverview.with_rank != null"> · 有排名 {{ rankingOverview.with_rank }} 条</span>
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="loading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="keywordRankings.length === 0">
|
||||
<div class="empty-state"><div class="empty-text">暂无排名数据,创建活动和关键词后将自动追踪</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="keywordRankings" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="keyword" label="关键词" min-width="140"></el-table-column>
|
||||
<el-table-column label="搜索引擎" width="100">
|
||||
<template #default="s">{{ engineLabel(s.row.search_engine) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="排名" width="80">
|
||||
<template #default="s">
|
||||
<el-tag :type="s.row.rank != null && s.row.rank <= 10 ? 'success' : s.row.rank != null && s.row.rank <= 30 ? 'warning' : 'info'" size="small">{{ s.row.rank || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="url_found" label="排名URL" min-width="200">
|
||||
<template #default="s"><a :href="s.row.url_found" target="_blank" style="color:#409eff;font-size:12px;">{{ s.row.url_found ? s.row.url_found.substring(0, 50) + '...' : '-' }}</a></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="检查时间" width="150">
|
||||
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<div v-if="keywordRankings.length > pageSize" style="display:flex;justify-content:center;margin:12px 0;">
|
||||
<el-pagination background layout="prev, pager, next" :total="keywordRankings.length" :page-size="pageSize" :current-page="rankingPage" @current-change="rankingPage = $event"></el-pagination>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'audits'">
|
||||
<div class="toolbar">
|
||||
<span style="font-size:13px;color:#909399;">共 {{ audits.length }} 条审计记录</span>
|
||||
</div>
|
||||
<div v-if="loading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="audits.length === 0">
|
||||
<div class="empty-state"><div class="empty-text">暂无SEO审计记录</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="audits" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column prop="product_name" label="产品" width="120"></el-table-column>
|
||||
<el-table-column label="审计类型" width="80">
|
||||
<template #default="s">{{ s.row.audit_type === 'full' ? '全面' : '快速' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="总分" width="80">
|
||||
<template #default="s">
|
||||
<el-tag :type="(s.row.overall_score || 0) >= 80 ? 'success' : (s.row.overall_score || 0) >= 60 ? 'warning' : 'danger'" size="small">{{ s.row.overall_score != null ? s.row.overall_score.toFixed(1) : '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Meta" width="65"><template #default="s">{{ s.row.meta_score != null ? s.row.meta_score.toFixed(0) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="标题" width="65"><template #default="s">{{ s.row.heading_score != null ? s.row.heading_score.toFixed(0) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="内容" width="65"><template #default="s">{{ s.row.content_score != null ? s.row.content_score.toFixed(0) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="性能" width="65"><template #default="s">{{ s.row.perf_score != null ? s.row.perf_score.toFixed(0) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="链接" width="65"><template #default="s">{{ s.row.links_score != null ? s.row.links_score.toFixed(0) : '-' }}</template></el-table-column>
|
||||
<el-table-column label="移动端" width="65"><template #default="s">{{ s.row.mobile_score != null ? s.row.mobile_score.toFixed(0) : '-' }}</template></el-table-column>
|
||||
<el-table-column prop="issues_found" label="问题数" width="65"></el-table-column>
|
||||
<el-table-column label="审计时间" width="150">
|
||||
<template #default="s">{{ s.row.checked_at ? new Date(s.row.checked_at).toLocaleString('zh-CN') : '-' }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="activeTab === 'tasks'">
|
||||
<div class="toolbar">
|
||||
<el-button :type="taskFilter === 'open' ? 'primary' : ''" size="small" @click="taskFilter = 'open'; loadTasks()">待处理</el-button>
|
||||
<el-button :type="taskFilter === 'resolved' ? 'primary' : ''" size="small" @click="taskFilter = 'resolved'; loadTasks()">已解决</el-button>
|
||||
<el-button :type="taskFilter === null ? 'primary' : ''" size="small" @click="taskFilter = null; loadTasks()">全部</el-button>
|
||||
</div>
|
||||
<div v-if="loading" class="card-loading">加载中...</div>
|
||||
<template v-else-if="optimizationTasks.length === 0">
|
||||
<div class="empty-state"><div class="empty-text">暂无优化建议</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-table :data="optimizationTasks" border stripe class="data-table" style="width:100%">
|
||||
<el-table-column label="严重度" width="80">
|
||||
<template #default="s">
|
||||
<el-tag :type="s.row.severity === 'high' ? 'danger' : s.row.severity === 'medium' ? 'warning' : 'info'" size="small">{{ { high: '高', medium: '中', low: '低' }[s.row.severity] || s.row.severity }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分类" width="90">
|
||||
<template #default="s">{{ { meta: 'Meta', heading: '标题', content: '内容', performance: '性能', links: '链接', mobile: '移动端' }[s.row.category] || s.row.category }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="issue" label="问题" min-width="200"></el-table-column>
|
||||
<el-table-column prop="recommendation" label="建议" min-width="200"></el-table-column>
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="s">
|
||||
<el-tag :type="s.row.status === 'resolved' ? 'success' : s.row.status === 'ignored' ? 'info' : 'warning'" size="small">{{ { open: '待处理', resolved: '已解决', ignored: '已忽略' }[s.row.status] || s.row.status }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="120" fixed="right">
|
||||
<template #default="s">
|
||||
<el-button v-if="s.row.status === 'open'" size="small" type="success" @click="updateTaskStatus(s.row, 'resolved')">解决</el-button>
|
||||
<el-button size="small" @click="updateTaskStatus(s.row, 'ignored')">忽略</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- Product Dialog -->
|
||||
<el-dialog v-model="showProductDialog" :title="editingProduct ? '编辑产品' : '新增产品'" width="500px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="产品名称"><el-input v-model="productForm.name" placeholder="例: 我的个人博客"></el-input></el-form-item>
|
||||
<el-form-item label="产品类型">
|
||||
<el-select v-model="productForm.type" style="width:100%">
|
||||
<el-option label="网站" value="website"></el-option>
|
||||
<el-option label="文章" value="article"></el-option>
|
||||
<el-option label="公众号" value="wechat_account"></el-option>
|
||||
<el-option label="小程序" value="miniprogram"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="URL"><el-input v-model="productForm.url" placeholder="https://example.com"></el-input></el-form-item>
|
||||
<el-form-item label="域名"><el-input v-model="productForm.domain" placeholder="example.com"></el-input></el-form-item>
|
||||
<el-form-item v-if="productForm.type === 'miniprogram'" label="AppID"><el-input v-model="productForm.app_id" placeholder="微信小程序AppID"></el-input></el-form-item>
|
||||
<el-form-item v-if="productForm.type === 'wechat_account'" label="公众号ID"><el-input v-model="productForm.account_id" placeholder="公众号ID"></el-input></el-form-item>
|
||||
<el-form-item label="描述"><el-input v-model="productForm.description" type="textarea" :rows="3"></el-input></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showProductDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveProduct">{{ editingProduct ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Campaign Dialog -->
|
||||
<el-dialog v-model="showCampaignDialog" :title="editingCampaign ? '编辑活动' : '新增活动'" width="500px">
|
||||
<el-form label-width="100px">
|
||||
<el-form-item label="活动名称"><el-input v-model="campaignForm.name" placeholder="例: 百度SEO推广"></el-input></el-form-item>
|
||||
<el-form-item label="关联产品">
|
||||
<el-select v-model="campaignForm.product_id" style="width:100%" placeholder="选择推广产品">
|
||||
<el-option v-for="p in products" :key="p.id" :label="p.name" :value="p.id"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="campaignForm.status" style="width:100%">
|
||||
<el-option label="进行中" value="active"></el-option>
|
||||
<el-option label="已暂停" value="paused"></el-option>
|
||||
<el-option label="已完成" value="completed"></el-option>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="目标引擎">
|
||||
<el-checkbox-group v-model="campaignForm.target_engines">
|
||||
<el-checkbox label="baidu">百度</el-checkbox>
|
||||
<el-checkbox label="360">360</el-checkbox>
|
||||
<el-checkbox label="sogou">搜狗</el-checkbox>
|
||||
<el-checkbox label="wechat">微信搜索</el-checkbox>
|
||||
<el-checkbox label="bing">Bing</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="关键词">
|
||||
<el-select v-model="campaignForm.keywords" multiple filterable allow-create default-first-option style="width:100%" placeholder="输入关键词后回车添加">
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="备注"><el-input v-model="campaignForm.notes" type="textarea" :rows="2"></el-input></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showCampaignDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveCampaign">{{ editingCampaign ? '保存' : '创建' }}</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- Keyword Dialog -->
|
||||
<el-dialog v-model="showKeywordDialog" title="添加关键词" width="450px">
|
||||
<el-form label-width="90px">
|
||||
<el-form-item label="关键词"><el-input v-model="keywordForm.keyword" placeholder="输入关键词"></el-input></el-form-item>
|
||||
<el-form-item label="搜索量"><el-input-number v-model="keywordForm.search_volume" :min="0" style="width:100%"></el-input-number></el-form-item>
|
||||
<el-form-item label="竞争难度"><el-slider v-model="keywordForm.difficulty" :min="0" :max="1" :step="0.05" show-input style="width:100%"></el-slider></el-form-item>
|
||||
<el-form-item label="目标排名"><el-input-number v-model="keywordForm.target_rank" :min="1" :max="100" style="width:100%"></el-input-number></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showKeywordDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="saveKeyword">添加</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
|
||||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||||
<script src="https://unpkg.com/element-plus"></script>
|
||||
<script src="icon-components.js"></script>
|
||||
<script>
|
||||
const { createApp, ref, computed, onMounted, nextTick } = Vue;
|
||||
|
||||
const app = createApp({
|
||||
data() {
|
||||
return {
|
||||
currentUser: {},
|
||||
isAdmin: false,
|
||||
activeTab: 'overview',
|
||||
|
||||
loading: false,
|
||||
pageSize: 10, productPage: 1, campaignPage: 1, rankingPage: 1,
|
||||
|
||||
overview: {},
|
||||
products: [],
|
||||
campaigns: [],
|
||||
selectedCampaign: null,
|
||||
campaignKeywords: [],
|
||||
keywordRankings: [],
|
||||
keywordRankingOverview: {},
|
||||
audits: [],
|
||||
optimizationTasks: [],
|
||||
taskFilter: 'open',
|
||||
|
||||
showProductDialog: false,
|
||||
editingProduct: null,
|
||||
productForm: { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' },
|
||||
|
||||
showCampaignDialog: false,
|
||||
editingCampaign: null,
|
||||
campaignForm: { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' },
|
||||
|
||||
showKeywordDialog: false,
|
||||
keywordForm: { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 },
|
||||
|
||||
token: '',
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
paginatedProducts() {
|
||||
const start = (this.productPage - 1) * this.pageSize;
|
||||
return this.products.slice(start, start + this.pageSize);
|
||||
},
|
||||
paginatedCampaigns() {
|
||||
const start = (this.campaignPage - 1) * this.pageSize;
|
||||
return this.campaigns.slice(start, start + this.pageSize);
|
||||
},
|
||||
paginatedRankings() {
|
||||
const start = (this.rankingPage - 1) * this.pageSize;
|
||||
return this.keywordRankings.slice(start, start + this.pageSize);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
typeLabel(t) {
|
||||
return { website: '网站', article: '文章', wechat_account: '公众号', miniprogram: '小程序' }[t] || t;
|
||||
},
|
||||
engineLabel(e) {
|
||||
return { baidu: '百度', bing: 'Bing', sogou: '搜狗', '360': '360', wechat: '微信搜索' }[e] || e;
|
||||
},
|
||||
api(path) { return '/api/external' + path; },
|
||||
headers() { return { 'Authorization': 'Bearer ' + this.token, 'Content-Type': 'application/json' }; },
|
||||
async fetchJson(url, opts) {
|
||||
const r = await fetch(url, opts || { headers: this.headers() });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '请求失败');
|
||||
return d.data;
|
||||
},
|
||||
|
||||
switchTab(tab) {
|
||||
this.activeTab = tab;
|
||||
this.selectedCampaign = null;
|
||||
this.loadTabData();
|
||||
},
|
||||
async loadTabData() {
|
||||
this.loading = true;
|
||||
try {
|
||||
if (this.activeTab === 'overview') {
|
||||
this.overview = await this.fetchJson(this.api('/overview'));
|
||||
} else if (this.activeTab === 'products') {
|
||||
this.products = await this.fetchJson(this.api('/products'));
|
||||
} else if (this.activeTab === 'campaigns') {
|
||||
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
||||
} else if (this.activeTab === 'rankings') {
|
||||
await this.loadRankings();
|
||||
} else if (this.activeTab === 'audits') {
|
||||
this.audits = await this.fetchJson(this.api('/audits'));
|
||||
} else if (this.activeTab === 'tasks') {
|
||||
await this.loadTasks();
|
||||
}
|
||||
} catch (e) {
|
||||
ElementPlus.ElMessage.error(e.message);
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
async loadRankings() {
|
||||
this.keywordRankings = await this.fetchJson(this.api('/rankings'));
|
||||
try { this.keywordRankingOverview = await this.fetchJson(this.api('/rankings/overview')); } catch (e) {}
|
||||
},
|
||||
async loadTasks() {
|
||||
let url = this.api('/optimization-tasks');
|
||||
if (this.taskFilter) url += '?status=' + this.taskFilter;
|
||||
this.optimizationTasks = await this.fetchJson(url);
|
||||
},
|
||||
|
||||
// Products
|
||||
editProduct(p) {
|
||||
this.editingProduct = p;
|
||||
this.productForm = { ...p };
|
||||
this.showProductDialog = true;
|
||||
},
|
||||
async saveProduct() {
|
||||
const url = this.editingProduct
|
||||
? this.api('/products/' + this.editingProduct.id)
|
||||
: this.api('/products');
|
||||
const method = this.editingProduct ? 'PUT' : 'POST';
|
||||
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.productForm) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '操作失败');
|
||||
ElementPlus.ElMessage.success(this.editingProduct ? '已更新' : '已创建');
|
||||
this.showProductDialog = false;
|
||||
this.editingProduct = null;
|
||||
this.productForm = { name: '', type: 'website', url: '', domain: '', app_id: '', account_id: '', description: '' };
|
||||
this.products = await this.fetchJson(this.api('/products'));
|
||||
},
|
||||
async deleteProduct(p) {
|
||||
if (!confirm('确定删除产品「' + p.name + '」?')) return;
|
||||
await this.fetchJson(this.api('/products/' + p.id), { method: 'DELETE', headers: this.headers() });
|
||||
ElementPlus.ElMessage.success('已删除');
|
||||
this.products = await this.fetchJson(this.api('/products'));
|
||||
},
|
||||
|
||||
// Campaigns
|
||||
viewCampaignKeywords(c) {
|
||||
this.selectedCampaign = c;
|
||||
this.loadCampaignKeywords(c.id);
|
||||
},
|
||||
async loadCampaignKeywords(campaignId) {
|
||||
try {
|
||||
this.campaignKeywords = await this.fetchJson(this.api('/keywords?campaign_id=' + campaignId));
|
||||
} catch (e) {
|
||||
this.campaignKeywords = [];
|
||||
}
|
||||
},
|
||||
editCampaign(c) {
|
||||
this.editingCampaign = c;
|
||||
this.campaignForm = { name: c.name, product_id: c.product_id, status: c.status, target_engines: c.target_engines || [], keywords: c.keywords || [], notes: c.notes || '' };
|
||||
this.showCampaignDialog = true;
|
||||
},
|
||||
async saveCampaign() {
|
||||
const url = this.editingCampaign
|
||||
? this.api('/campaigns/' + this.editingCampaign.id)
|
||||
: this.api('/campaigns');
|
||||
const method = this.editingCampaign ? 'PUT' : 'POST';
|
||||
const r = await fetch(url, { method, headers: this.headers(), body: JSON.stringify(this.campaignForm) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '操作失败');
|
||||
ElementPlus.ElMessage.success(this.editingCampaign ? '已更新' : '已创建');
|
||||
this.showCampaignDialog = false;
|
||||
this.editingCampaign = null;
|
||||
this.campaignForm = { name: '', product_id: null, status: 'active', target_engines: [], keywords: [], notes: '' };
|
||||
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
||||
},
|
||||
async deleteCampaign(c) {
|
||||
if (!confirm('确定删除活动「' + c.name + '」?')) return;
|
||||
await this.fetchJson(this.api('/campaigns/' + c.id), { method: 'DELETE', headers: this.headers() });
|
||||
ElementPlus.ElMessage.success('已删除');
|
||||
this.campaigns = await this.fetchJson(this.api('/campaigns'));
|
||||
},
|
||||
|
||||
// Keywords
|
||||
async saveKeyword() {
|
||||
if (!this.selectedCampaign) { ElementPlus.ElMessage.warning('请先选择活动'); return; }
|
||||
this.keywordForm.campaign_id = this.selectedCampaign.id;
|
||||
const r = await fetch(this.api('/keywords'), { method: 'POST', headers: this.headers(), body: JSON.stringify(this.keywordForm) });
|
||||
const d = await r.json();
|
||||
if (!d.ok) throw new Error(d.detail || '添加失败');
|
||||
ElementPlus.ElMessage.success('已添加');
|
||||
this.showKeywordDialog = false;
|
||||
this.keywordForm = { keyword: '', search_volume: 0, difficulty: 0.5, target_rank: 10 };
|
||||
this.loadCampaignKeywords(this.selectedCampaign.id);
|
||||
},
|
||||
async deleteKeyword(kw) {
|
||||
if (!confirm('确定删除关键词「' + kw.keyword + '」?')) return;
|
||||
await this.fetchJson(this.api('/keywords/' + kw.id), { method: 'DELETE', headers: this.headers() });
|
||||
ElementPlus.ElMessage.success('已删除');
|
||||
if (this.selectedCampaign) this.loadCampaignKeywords(this.selectedCampaign.id);
|
||||
},
|
||||
|
||||
// Optimization Tasks
|
||||
async updateTaskStatus(task, status) {
|
||||
await this.fetchJson(this.api('/optimization-tasks/' + task.id), { method: 'PATCH', headers: this.headers(), body: JSON.stringify({ status: status }) });
|
||||
ElementPlus.ElMessage.success('已更新');
|
||||
this.loadTasks();
|
||||
},
|
||||
|
||||
redirectToPage(page) {
|
||||
window.location.href = '/' + page;
|
||||
},
|
||||
logout() {
|
||||
localStorage.removeItem('authToken');
|
||||
window.location.href = '/login.html';
|
||||
},
|
||||
},
|
||||
async mounted() {
|
||||
this.token = localStorage.getItem('authToken') || '';
|
||||
if (!this.token) { window.location.href = '/login.html'; return; }
|
||||
try {
|
||||
const r = await fetch('/api/auth/me', { headers: { 'Authorization': 'Bearer ' + this.token } });
|
||||
if (!r.ok) throw new Error('unauthorized');
|
||||
const d = await r.json();
|
||||
this.currentUser = d.user || d;
|
||||
this.isAdmin = this.currentUser.role === 'admin';
|
||||
window.installUniNav(this.$parent ? null : app).component('uni-nav', window.UniNav);
|
||||
} catch (e) {
|
||||
window.location.href = '/login.html';
|
||||
}
|
||||
this.loadTabData();
|
||||
},
|
||||
});
|
||||
|
||||
// Register Element Plus icons
|
||||
const { Edit, Delete, Plus, Search, Setting, Monitor, Document, DataBoard, Folder, Menu as IconMenu } = ElementPlusIconsVue || {};
|
||||
if (ElementPlusIconsVue) {
|
||||
for (const [key, comp] of Object.entries(ElementPlusIconsVue)) {
|
||||
app.component(key, comp);
|
||||
}
|
||||
}
|
||||
|
||||
app.use(ElementPlus);
|
||||
app.mount('#app');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -132,6 +132,7 @@
|
||||
{ key: 'factory', label: '内容工厂', page: 'factory.html' },
|
||||
{ key: 'insights', label: '数据洞察', page: 'insights.html' },
|
||||
{ key: 'assets', label: '资产库', page: 'assets.html' },
|
||||
{ key: 'campaigns', label: '外推营销', page: 'campaigns.html' },
|
||||
];
|
||||
if (isAdmin) {
|
||||
items.push({ key: 'admin', label: '系统管理', page: 'admin.html', admin: true });
|
||||
|
||||
@@ -0,0 +1,416 @@
|
||||
"""
|
||||
SEO Auditor — 外部网站 SEO 审计脚本
|
||||
|
||||
功能:
|
||||
- 抓取目标网站首页和关键页面
|
||||
- 分析 Meta 标签、标题结构、内容质量、性能、链接、移动端适配
|
||||
- 生成评分报告(0-100 分/维度)
|
||||
- 自动生成优化建议
|
||||
- 结果写入 DB(SEOAudit + OptimizationTask)
|
||||
|
||||
用法:
|
||||
python3 scripts/seo_auditor.py --product-id 1
|
||||
python3 scripts/seo_auditor.py --url https://example.com --name "我的网站"
|
||||
"""
|
||||
import sys
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import urlparse
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'platform', 'backend'))
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def extract_meta(html):
|
||||
"""分析 Meta 标签"""
|
||||
score = 100
|
||||
issues = []
|
||||
|
||||
title = re.search(r'<title[^>]*>(.*?)</title>', html, re.I | re.S)
|
||||
if not title:
|
||||
score -= 30
|
||||
issues.append({"category": "meta", "severity": "high", "issue": "缺少 <title> 标签", "recommendation": "添加包含目标关键词的 title 标签,建议 50-60 字符"})
|
||||
else:
|
||||
t = title.group(1).strip()
|
||||
if len(t) < 10:
|
||||
score -= 15
|
||||
issues.append({"category": "meta", "severity": "medium", "issue": f"title 过短 ({len(t)}字符)", "recommendation": "title 建议 50-60 字符"})
|
||||
if len(t) > 80:
|
||||
score -= 10
|
||||
issues.append({"category": "meta", "severity": "low", "issue": f"title 过长 ({len(t)}字符)", "recommendation": "title 超过 80 字符可能在搜索结果中被截断"})
|
||||
|
||||
desc = re.search(r'<meta\s+name=["\']description["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
|
||||
if not desc:
|
||||
score -= 25
|
||||
issues.append({"category": "meta", "severity": "high", "issue": "缺少 meta description", "recommendation": "添加包含关键词的 meta description,建议 120-160 字符"})
|
||||
else:
|
||||
d = desc.group(1).strip()
|
||||
if len(d) < 50:
|
||||
score -= 10
|
||||
issues.append({"category": "meta", "severity": "medium", "issue": f"description 过短 ({len(d)}字符)", "recommendation": "description 建议 120-160 字符"})
|
||||
|
||||
keywords = re.search(r'<meta\s+name=["\']keywords["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
|
||||
if not keywords:
|
||||
score -= 5
|
||||
issues.append({"category": "meta", "severity": "low", "issue": "缺少 meta keywords", "recommendation": "添加 meta keywords(虽然不是排名因素,但用于内容相关性提示)"})
|
||||
|
||||
og_title = re.search(r'<meta\s+property=["\']og:title["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
|
||||
og_desc = re.search(r'<meta\s+property=["\']og:description["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
|
||||
og_image = re.search(r'<meta\s+property=["\']og:image["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
|
||||
if not og_title:
|
||||
score -= 10
|
||||
issues.append({"category": "meta", "severity": "medium", "issue": "缺少 og:title", "recommendation": "添加 Open Graph title 改善社交分享展示"})
|
||||
if not og_desc:
|
||||
score -= 5
|
||||
issues.append({"category": "meta", "severity": "low", "issue": "缺少 og:description", "recommendation": "添加 og:description 改善社交分享展示"})
|
||||
if not og_image:
|
||||
score -= 5
|
||||
issues.append({"category": "meta", "severity": "low", "issue": "缺少 og:image", "recommendation": "添加 og:image 让分享链接显示缩略图"})
|
||||
|
||||
return max(0, score), issues
|
||||
|
||||
|
||||
def extract_headings(html):
|
||||
"""分析标题结构"""
|
||||
score = 100
|
||||
issues = []
|
||||
|
||||
h1s = re.findall(r'<h1[^>]*>(.*?)</h1>', html, re.I | re.S)
|
||||
if len(h1s) == 0:
|
||||
score -= 30
|
||||
issues.append({"category": "heading", "severity": "high", "issue": "页面缺少 H1 标签", "recommendation": "每个页面应当只有一个 H1,包含主要关键词"})
|
||||
elif len(h1s) > 1:
|
||||
score -= 15
|
||||
issues.append({"category": "heading", "severity": "medium", "issue": f"存在 {len(h1s)} 个 H1 标签", "recommendation": "每个页面应当只有一个 H1 标签"})
|
||||
else:
|
||||
h1_text = re.sub(r'<[^>]+>', '', h1s[0]).strip()
|
||||
if len(h1_text) < 5:
|
||||
score -= 5
|
||||
issues.append({"category": "heading", "severity": "low", "issue": "H1 内容过短", "recommendation": "H1 应清晰描述页面主题"})
|
||||
|
||||
h2s = re.findall(r'<h2[^>]*>(.*?)</h2>', html, re.I | re.S)
|
||||
if len(h2s) == 0 and len(re.findall(r'<(h[2-6])', html, re.I)) > 0:
|
||||
pass
|
||||
elif len(h2s) == 0:
|
||||
score -= 10
|
||||
issues.append({"category": "heading", "severity": "medium", "issue": "缺少 H2 子标题", "recommendation": "使用 H2 组织内容结构,提升可读性和 SEO"})
|
||||
|
||||
# Check heading hierarchy
|
||||
all_headings = re.findall(r'<h([1-6])[^>]*>', html, re.I)
|
||||
if all_headings:
|
||||
prev_level = int(all_headings[0])
|
||||
for level in all_headings[1:]:
|
||||
l = int(level)
|
||||
if l > prev_level + 1:
|
||||
score -= 5
|
||||
issues.append({"category": "heading", "severity": "medium", "issue": f"标题层级跳级:H{prev_level} → H{l}", "recommendation": "标题层级应连续,不要跳级"})
|
||||
break
|
||||
prev_level = l
|
||||
|
||||
return max(0, score), issues
|
||||
|
||||
|
||||
def extract_content(html):
|
||||
"""分析内容质量"""
|
||||
score = 100
|
||||
issues = []
|
||||
|
||||
text = re.sub(r'<[^>]+>', ' ', html)
|
||||
text = re.sub(r'\s+', ' ', text).strip()
|
||||
|
||||
word_count = len(text)
|
||||
if word_count < 300:
|
||||
score -= 30
|
||||
issues.append({"category": "content", "severity": "high", "issue": f"内容过短({word_count}字符)", "recommendation": "正文建议至少 300 字符,优质内容建议 1000+ 字符"})
|
||||
elif word_count < 1000:
|
||||
score -= 15
|
||||
issues.append({"category": "content", "severity": "medium", "issue": f"内容偏短({word_count}字符)", "recommendation": "增加内容深度,建议达到 1000+ 字符"})
|
||||
|
||||
# Check image alt
|
||||
imgs = re.findall(r'<img[^>]+>', html, re.I)
|
||||
no_alt = sum(1 for img in imgs if 'alt=' not in img.lower())
|
||||
if imgs and no_alt == len(imgs):
|
||||
score -= 10
|
||||
issues.append({"category": "content", "severity": "medium", "issue": "所有图片缺少 alt 属性", "recommendation": "为图片添加描述性的 alt 文本"})
|
||||
elif no_alt > 0:
|
||||
score -= 5
|
||||
issues.append({"category": "content", "severity": "low", "issue": f"{no_alt}/{len(imgs)} 张图片缺少 alt", "recommendation": "为剩余图片补全 alt 属性"})
|
||||
|
||||
return max(0, score), issues, {"word_count": word_count, "image_count": len(imgs)}
|
||||
|
||||
|
||||
def extract_links(html, base_url):
|
||||
"""分析链接"""
|
||||
score = 100
|
||||
issues = []
|
||||
domain = urlparse(base_url).netloc
|
||||
|
||||
internal_links = re.findall(r'href=["\'](https?://[^"\']+)["\']', html, re.I)
|
||||
external_links = [url for url in internal_links if urlparse(url).netloc != domain]
|
||||
internal_links = [url for url in internal_links if urlparse(url).netloc == domain]
|
||||
broken_keywords = re.findall(r'href=["\']([^"\']*(?:404|broken|dead)[^"\']*)["\']', html, re.I)
|
||||
|
||||
if len(internal_links) == 0:
|
||||
score -= 15
|
||||
issues.append({"category": "links", "severity": "medium", "issue": "页面没有内部链接", "recommendation": "添加指向站内其他页面的链接,改善爬虫抓取和用户导航"})
|
||||
if len(external_links) == 0:
|
||||
pass
|
||||
ext_no_nofollow = re.findall(r'<a\s+[^>]*href=["\']https?://(?!' + re.escape(domain) + r')["\'][^>]*>', html, re.I)
|
||||
if ext_no_nofollow:
|
||||
score -= 5
|
||||
issues.append({"category": "links", "severity": "low", "issue": "外部链接缺少 nofollow", "recommendation": "对外部链接添加 rel=\"nofollow noopener\" 属性"})
|
||||
|
||||
# Check for broken link patterns
|
||||
broken = re.findall(r'href=["\'](?:https?://[^"\']*?(?:404|error|not-found)[^"\']*)["\']', html, re.I)
|
||||
if broken:
|
||||
score -= 10
|
||||
issues.append({"category": "links", "severity": "high", "issue": f"发现 {len(broken)} 个疑似断链", "recommendation": "检查并修复或删除失效链接"})
|
||||
|
||||
return max(0, score), issues, {"internal": len(internal_links), "external": len(external_links)}
|
||||
|
||||
|
||||
def extract_performance(html):
|
||||
"""分析性能(基础版 — 仅分析可前端检测的指标)"""
|
||||
score = 100
|
||||
issues = []
|
||||
|
||||
# Check for render-blocking resources
|
||||
css_links = re.findall(r'<link[^>]*href=["\'].*?\.css["\']', html, re.I)
|
||||
js_scripts = re.findall(r'<script[^>]*src=["\']([^"\']+)["\']', html, re.I)
|
||||
render_blocking = len(css_links) + len(js_scripts)
|
||||
if render_blocking > 10:
|
||||
score -= 10
|
||||
issues.append({"category": "performance", "severity": "medium", "issue": f"存在 {render_blocking} 个渲染阻塞资源", "recommendation": "考虑异步加载非关键 CSS/JS,使用 defer 或 async"})
|
||||
|
||||
# Check image without dimensions
|
||||
imgs_no_dim = re.findall(r'<img(?!\s*(?:width|height)=)', html, re.I)
|
||||
if imgs_no_dim:
|
||||
score -= 5
|
||||
issues.append({"category": "performance", "severity": "low", "issue": f"存在 {len(imgs_no_dim)} 张无宽高属性的图片", "recommendation": "为图片添加 width/height 属性,减少布局偏移(CLS)"})
|
||||
|
||||
return max(0, score), issues
|
||||
|
||||
|
||||
def extract_mobile(html):
|
||||
"""分析移动端适配"""
|
||||
score = 100
|
||||
issues = []
|
||||
|
||||
viewport = re.search(r'<meta\s+name=["\']viewport["\'][^>]*content=["\']([^"\']*)["\']', html, re.I)
|
||||
if not viewport:
|
||||
score -= 40
|
||||
issues.append({"category": "mobile", "severity": "high", "issue": "缺少 viewport meta 标签", "recommendation": "添加 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"> 确保移动端正确渲染"})
|
||||
|
||||
# Check for responsive CSS
|
||||
media_queries = re.findall(r'@media\s', html, re.I)
|
||||
if not media_queries:
|
||||
score -= 15
|
||||
issues.append({"category": "mobile", "severity": "medium", "issue": "未检测到响应式 CSS", "recommendation": "实现响应式设计,使用媒体查询适配不同屏幕尺寸"})
|
||||
|
||||
# Check font size
|
||||
font_small = re.findall(r'font-size\s*:\s*(?:10|11|12)px', html, re.I)
|
||||
if font_small:
|
||||
score -= 5
|
||||
issues.append({"category": "mobile", "severity": "low", "issue": "检测到小字体 (≤12px)", "recommendation": "移动端正文字号建议至少 16px 防止 iOS 自动缩放"})
|
||||
|
||||
return max(0, score), issues
|
||||
|
||||
|
||||
def audit_url(url, name=None):
|
||||
"""对单个 URL 执行完整 SEO 审计"""
|
||||
import urllib.request
|
||||
import ssl
|
||||
|
||||
parsed = urlparse(url)
|
||||
domain = parsed.netloc
|
||||
page_name = name or domain
|
||||
|
||||
logger.info(f"审计: {page_name} ({url})")
|
||||
|
||||
try:
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
req = urllib.request.Request(url, headers={
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept': 'text/html,application/xhtml+xml',
|
||||
'Accept-Language': 'zh-CN,zh;q=0.9',
|
||||
})
|
||||
resp = urllib.request.urlopen(req, timeout=15, context=ctx)
|
||||
html = resp.read().decode('utf-8', errors='ignore')
|
||||
logger.info(f" 获取成功: {len(html)} bytes")
|
||||
except Exception as e:
|
||||
logger.error(f" 获取失败: {e}")
|
||||
return None
|
||||
|
||||
meta_score, meta_issues = extract_meta(html)
|
||||
heading_score, heading_issues = extract_headings(html)
|
||||
content_score, content_issues, content_info = extract_content(html)
|
||||
perf_score, perf_issues = extract_performance(html)
|
||||
links_score, links_issues, links_info = extract_links(html, url)
|
||||
mobile_score, mobile_issues = extract_mobile(html)
|
||||
|
||||
all_issues = meta_issues + heading_issues + content_issues + perf_issues + links_issues + mobile_issues
|
||||
overall_score = round(
|
||||
meta_score * 0.20 +
|
||||
heading_score * 0.15 +
|
||||
content_score * 0.25 +
|
||||
perf_score * 0.10 +
|
||||
links_score * 0.10 +
|
||||
mobile_score * 0.20,
|
||||
1
|
||||
)
|
||||
|
||||
raw_data = {
|
||||
"url": url,
|
||||
"title": re.search(r'<title[^>]*>(.*?)</title>', html, re.I | re.S).group(1).strip() if re.search(r'<title[^>]*>(.*?)</title>', html, re.I | re.S) else None,
|
||||
"content_info": content_info,
|
||||
"links_info": links_info,
|
||||
"issues_count": len(all_issues),
|
||||
"issues_by_severity": {
|
||||
"high": sum(1 for i in all_issues if i.get("severity") == "high"),
|
||||
"medium": sum(1 for i in all_issues if i.get("severity") == "medium"),
|
||||
"low": sum(1 for i in all_issues if i.get("severity") == "low"),
|
||||
}
|
||||
}
|
||||
|
||||
result = {
|
||||
"url": url,
|
||||
"page_name": page_name,
|
||||
"overall_score": overall_score,
|
||||
"meta_score": meta_score,
|
||||
"heading_score": heading_score,
|
||||
"content_score": content_score,
|
||||
"perf_score": perf_score,
|
||||
"links_score": links_score,
|
||||
"mobile_score": mobile_score,
|
||||
"issues_found": len(all_issues),
|
||||
"raw_data": raw_data,
|
||||
"issues": all_issues,
|
||||
}
|
||||
|
||||
logger.info(f" 总分: {overall_score}/100 (Meta:{meta_score} 标题:{heading_score} 内容:{content_score} 性能:{perf_score} 链接:{links_score} 移动端:{mobile_score})")
|
||||
logger.info(f" 发现问题: {len(all_issues)} ({raw_data['issues_by_severity']['high']}高/{raw_data['issues_by_severity']['medium']}中/{raw_data['issues_by_severity']['low']}低)")
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def save_audit_to_db(product_id, result):
|
||||
"""将审计结果写入 DB"""
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models import ExternalProduct, SEOAudit, OptimizationTask
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == product_id).first()
|
||||
if not product:
|
||||
logger.error(f"产品 {product_id} 不存在")
|
||||
return False
|
||||
|
||||
audit = SEOAudit(
|
||||
product_id=product_id,
|
||||
audit_type="full",
|
||||
overall_score=result["overall_score"],
|
||||
meta_score=result["meta_score"],
|
||||
heading_score=result["heading_score"],
|
||||
content_score=result["content_score"],
|
||||
perf_score=result["perf_score"],
|
||||
links_score=result["links_score"],
|
||||
mobile_score=result["mobile_score"],
|
||||
raw_data=result.get("raw_data"),
|
||||
page_count=1,
|
||||
issues_found=result["issues_found"],
|
||||
org_id=product.org_id,
|
||||
)
|
||||
db.add(audit)
|
||||
db.flush()
|
||||
|
||||
for issue in result.get("issues", []):
|
||||
task = OptimizationTask(
|
||||
audit_id=audit.id,
|
||||
product_id=product_id,
|
||||
category=issue.get("category", "other"),
|
||||
severity=issue.get("severity", "medium"),
|
||||
issue=issue.get("issue", ""),
|
||||
recommendation=issue.get("recommendation"),
|
||||
status="open",
|
||||
org_id=product.org_id,
|
||||
)
|
||||
db.add(task)
|
||||
|
||||
db.commit()
|
||||
logger.info(f" 审计结果已保存: audit_id={audit.id}, tasks={len(result.get('issues', []))}")
|
||||
return True
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f" DB 保存失败: {e}")
|
||||
return False
|
||||
finally:
|
||||
db.close()
|
||||
except ImportError as e:
|
||||
logger.error(f" 无法导入应用模块: {e}")
|
||||
logger.info(" (审计结果仅输出到日志)")
|
||||
return False
|
||||
|
||||
|
||||
def run_for_product(product_id):
|
||||
"""为指定产品执行 SEO 审计"""
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'platform', 'backend'))
|
||||
try:
|
||||
from app.database import SessionLocal
|
||||
from app.models import ExternalProduct
|
||||
db = SessionLocal()
|
||||
try:
|
||||
product = db.query(ExternalProduct).filter(ExternalProduct.id == product_id).first()
|
||||
if not product:
|
||||
logger.error(f"产品 {product_id} 不存在")
|
||||
return
|
||||
url = product.url
|
||||
name = product.name
|
||||
finally:
|
||||
db.close()
|
||||
except ImportError:
|
||||
logger.error("无法连接到数据库,请确保在项目根目录运行")
|
||||
return
|
||||
|
||||
if not url:
|
||||
logger.error(f"产品 {name} 没有配置 URL")
|
||||
return
|
||||
|
||||
result = audit_url(url, name)
|
||||
if not result:
|
||||
logger.error("审计失败")
|
||||
return
|
||||
|
||||
save_audit_to_db(product_id, result)
|
||||
return result
|
||||
|
||||
|
||||
def run_for_url(url, name=None):
|
||||
"""为指定 URL 执行审计(不保存到 DB)"""
|
||||
result = audit_url(url, name)
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
parser = argparse.ArgumentParser(description="SEO Auditor - 外部网站 SEO 审计")
|
||||
parser.add_argument("--product-id", type=int, help="产品 ID(从 DB 读取 URL)")
|
||||
parser.add_argument("--url", help="直接指定 URL")
|
||||
parser.add_argument("--name", help="页面名称")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.product_id:
|
||||
run_for_product(args.product_id)
|
||||
elif args.url:
|
||||
result = run_for_url(args.url, args.name)
|
||||
if result:
|
||||
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
parser.print_help()
|
||||
Reference in New Issue
Block a user