diff --git a/PROGRESS.md b/PROGRESS.md
index 938d094..ff7c01c 100644
--- a/PROGRESS.md
+++ b/PROGRESS.md
@@ -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 审计自动化脚本 |
+
---
## 六、归档日志
diff --git a/scripts/seo_auditor.py b/scripts/seo_auditor.py
new file mode 100644
index 0000000..a4167b7
--- /dev/null
+++ b/scripts/seo_auditor.py
@@ -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'
]*>(.*?)', html, re.I | re.S)
+ if not title:
+ score -= 30
+ issues.append({"category": "meta", "severity": "high", "issue": "缺少 标签", "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']*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']*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']*content=["\']([^"\']*)["\']', html, re.I)
+ og_desc = re.search(r']*content=["\']([^"\']*)["\']', html, re.I)
+ og_image = re.search(r']*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']*>(.*?)
', 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']*>(.*?)
', 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']*>', 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'
]+>', 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']*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']*href=["\'].*?\.css["\']', html, re.I)
+ js_scripts = re.findall(r'