4 Commits

Author SHA1 Message Date
yuzhiran e9cd0374f4 feat(script): SEO auditor + PROGRESS.md v24 update
Add seo_auditor.py - crawls external websites, scores 6 SEO dimensions (meta/heading/content/performance/links/mobile), generates optimization tasks. Update PROGRESS.md with v24 external promotion feature.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:07:43 +08:00
yuzhiran 8a04a23e86 feat(ui): external promotion dashboard (campaigns.html)
Add campaigns.html with 6 tabs (Overview/Products/Campaigns/Rankings/Audits/Tasks) using Vue 3 + Element Plus CDN. Add nav entry to uni-nav.js.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:06:46 +08:00
yuzhiran 8f5a9e7ede feat(api): external promotion CRUD + audit endpoints
Add 20 API endpoints under /api/external/* covering products, campaigns, keywords, audits, rankings, optimization tasks with org_id filtering.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:05:55 +08:00
yuzhiran 12e49f2811 feat(models): add external promotion tables (6 models)
Add ExternalProduct, PromotionCampaign, CampaignKeyword, SEOAudit, KeywordRanking, OptimizationTask models and 18 Pydantic schemas for the external promotion module.

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-06-21 16:04:52 +08:00
8 changed files with 1894 additions and 2 deletions
+60 -1
View File
@@ -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,
}
}
+2 -1
View File
@@ -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
+205
View File
@@ -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,
}
+160
View File
@@ -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)
+581
View File
@@ -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>
+1
View File
@@ -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 });
+416
View File
@@ -0,0 +1,416 @@
"""
SEO Auditor — 外部网站 SEO 审计脚本
功能:
- 抓取目标网站首页和关键页面
- 分析 Meta 标签、标题结构、内容质量、性能、链接、移动端适配
- 生成评分报告(0-100 分/维度)
- 自动生成优化建议
- 结果写入 DBSEOAudit + 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()