first commit
This commit is contained in:
1040
MeetSpot/api/index.py
Normal file
1040
MeetSpot/api/index.py
Normal file
File diff suppressed because it is too large
Load Diff
0
MeetSpot/api/routers/__init__.py
Normal file
0
MeetSpot/api/routers/__init__.py
Normal file
92
MeetSpot/api/routers/auth.py
Normal file
92
MeetSpot/api/routers/auth.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""认证相关API路由。"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.jwt import create_access_token, get_current_user
|
||||
from app.auth.sms import send_login_code, validate_code
|
||||
from app.db import crud
|
||||
from app.db.database import get_db
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/auth", tags=["auth"])
|
||||
|
||||
|
||||
class SendCodeRequest(BaseModel):
|
||||
phone: str = Field(..., min_length=4, max_length=20, description="手机号")
|
||||
|
||||
|
||||
class VerifyCodeRequest(BaseModel):
|
||||
phone: str = Field(..., min_length=4, max_length=20, description="手机号")
|
||||
code: str = Field(..., min_length=4, max_length=10, description="验证码")
|
||||
nickname: str | None = Field(None, description="首次登录时的昵称")
|
||||
avatar_url: str | None = Field(None, description="头像URL,可选")
|
||||
|
||||
|
||||
class AuthResponse(BaseModel):
|
||||
success: bool
|
||||
token: str
|
||||
user: dict
|
||||
|
||||
|
||||
def _mask_phone(phone: str) -> str:
|
||||
"""简单脱敏手机号。"""
|
||||
if len(phone) < 7:
|
||||
return phone
|
||||
return f"{phone[:3]}****{phone[-4:]}"
|
||||
|
||||
|
||||
def _serialize_user(user: User) -> dict:
|
||||
"""统一的用户返回结构。"""
|
||||
return {
|
||||
"id": user.id,
|
||||
"phone": _mask_phone(user.phone),
|
||||
"nickname": user.nickname,
|
||||
"avatar_url": user.avatar_url or "",
|
||||
"created_at": user.created_at,
|
||||
"last_login": user.last_login,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/send_code")
|
||||
async def send_code(payload: SendCodeRequest):
|
||||
"""下发登录验证码,MVP阶段固定返回Mock值。"""
|
||||
code = await send_login_code(payload.phone)
|
||||
return {"success": True, "message": "验证码已发送", "code": code}
|
||||
|
||||
|
||||
@router.post("/verify_code", response_model=AuthResponse)
|
||||
async def verify_code(
|
||||
payload: VerifyCodeRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""验证验证码并返回JWT。"""
|
||||
if not validate_code(payload.phone, payload.code):
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="验证码错误")
|
||||
|
||||
user = await crud.get_user_by_phone(db, payload.phone)
|
||||
nickname = payload.nickname
|
||||
avatar_url = payload.avatar_url or ""
|
||||
|
||||
# 首次登录创建用户;旧用户允许更新昵称
|
||||
if not user:
|
||||
user = await crud.create_user(db, phone=payload.phone, nickname=nickname, avatar_url=avatar_url)
|
||||
else:
|
||||
if nickname:
|
||||
user.nickname = nickname
|
||||
user.avatar_url = avatar_url or user.avatar_url
|
||||
await db.commit()
|
||||
await db.refresh(user)
|
||||
|
||||
await crud.touch_last_login(db, user)
|
||||
|
||||
token = create_access_token({"sub": user.id, "phone": user.phone})
|
||||
return {"success": True, "token": token, "user": _serialize_user(user)}
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
async def get_me(current_user: User = Depends(get_current_user)):
|
||||
"""获取当前登录用户信息。"""
|
||||
return {"user": _serialize_user(current_user)}
|
||||
|
||||
50
MeetSpot/api/routers/miniprogram.py
Normal file
50
MeetSpot/api/routers/miniprogram.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from typing import List, Optional, Dict, Any
|
||||
from fastapi import APIRouter, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.tool.meetspot_recommender import CafeRecommender
|
||||
from app.logger import logger
|
||||
|
||||
router = APIRouter(prefix="/api/miniprogram", tags=["miniprogram"])
|
||||
|
||||
class LocationItem(BaseModel):
|
||||
lng: float
|
||||
lat: float
|
||||
address: Optional[str] = ""
|
||||
name: Optional[str] = ""
|
||||
|
||||
class CalculateRequest(BaseModel):
|
||||
locations: List[LocationItem]
|
||||
keywords: Optional[str] = "咖啡馆"
|
||||
requirements: Optional[str] = ""
|
||||
min_rating: Optional[float] = 0.0
|
||||
max_distance: Optional[int] = 100000
|
||||
price_range: Optional[str] = ""
|
||||
|
||||
@router.post("/calculate")
|
||||
async def calculate_meetspot(request: CalculateRequest):
|
||||
"""小程序核心计算接口:根据坐标直接计算推荐"""
|
||||
try:
|
||||
recommender = CafeRecommender()
|
||||
|
||||
# 转换 locations 为 list of dict
|
||||
location_dicts = [loc.model_dump() for loc in request.locations]
|
||||
|
||||
result = await recommender.execute_for_miniprogram(
|
||||
locations=location_dicts,
|
||||
keywords=request.keywords,
|
||||
user_requirements=request.requirements,
|
||||
min_rating=request.min_rating,
|
||||
max_distance=request.max_distance,
|
||||
price_range=request.price_range
|
||||
)
|
||||
|
||||
if not result.get("success", False):
|
||||
# 业务逻辑错误也返回 200,但在 body 中包含 error
|
||||
return result
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Miniprogram calculation failed: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
389
MeetSpot/api/routers/seo_pages.py
Normal file
389
MeetSpot/api/routers/seo_pages.py
Normal file
@@ -0,0 +1,389 @@
|
||||
"""SEO页面路由 - 负责SSR页面与爬虫友好输出."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request, Response
|
||||
from fastapi.responses import HTMLResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from slowapi import Limiter
|
||||
from slowapi.util import get_remote_address
|
||||
|
||||
from api.services.seo_content import seo_content_generator as seo_generator
|
||||
|
||||
router = APIRouter()
|
||||
templates = Jinja2Templates(directory="templates")
|
||||
limiter = Limiter(key_func=get_remote_address)
|
||||
|
||||
|
||||
@lru_cache(maxsize=128)
|
||||
def load_cities() -> List[Dict]:
|
||||
"""加载城市数据, 如不存在则创建默认值."""
|
||||
cities_file = "data/cities.json"
|
||||
if not os.path.exists(cities_file):
|
||||
os.makedirs("data", exist_ok=True)
|
||||
default_payload = {"cities": []}
|
||||
with open(cities_file, "w", encoding="utf-8") as fh:
|
||||
json.dump(default_payload, fh, ensure_ascii=False, indent=2)
|
||||
return []
|
||||
|
||||
with open(cities_file, "r", encoding="utf-8") as fh:
|
||||
payload = json.load(fh)
|
||||
return payload.get("cities", [])
|
||||
|
||||
|
||||
def _get_city_by_slug(city_slug: str) -> Optional[Dict]:
|
||||
for city in load_cities():
|
||||
if city.get("slug") == city_slug:
|
||||
return city
|
||||
return None
|
||||
|
||||
|
||||
def _build_schema_list(*schemas: Dict) -> List[Dict]:
|
||||
return [schema for schema in schemas if schema]
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
@limiter.limit("60/minute")
|
||||
async def homepage(request: Request):
|
||||
"""首页 - 提供SEO友好内容."""
|
||||
|
||||
meta_tags = seo_generator.generate_meta_tags("homepage", {})
|
||||
faq_schema = seo_generator.generate_schema_org(
|
||||
"faq",
|
||||
{
|
||||
"faqs": [
|
||||
{
|
||||
"question": "MeetSpot如何计算最佳聚会地点?",
|
||||
"answer": "我们使用球面几何算法计算所有参与者位置的地理中点, 再推荐附近高评分场所。",
|
||||
},
|
||||
{
|
||||
"question": "MeetSpot支持多少人的聚会?",
|
||||
"answer": "默认支持2-10人, 满足大多数团队与家人聚会需求。",
|
||||
},
|
||||
{
|
||||
"question": "需要付费吗?",
|
||||
"answer": "MeetSpot完全免费且开源, 无需注册即可使用。",
|
||||
},
|
||||
]
|
||||
},
|
||||
)
|
||||
schema_list = _build_schema_list(
|
||||
seo_generator.generate_schema_org("webapp", {}),
|
||||
seo_generator.generate_schema_org("website", {"search_url": "/search"}),
|
||||
seo_generator.generate_schema_org("organization", {}),
|
||||
seo_generator.generate_schema_org(
|
||||
"breadcrumb", {"items": [{"name": "Home", "url": "/"}]}
|
||||
),
|
||||
faq_schema,
|
||||
)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/home.html",
|
||||
{
|
||||
"request": request,
|
||||
"meta_title": meta_tags["title"],
|
||||
"meta_description": meta_tags["description"],
|
||||
"meta_keywords": meta_tags["keywords"],
|
||||
"canonical_url": "https://meetspot-irq2.onrender.com/",
|
||||
"schema_jsonld": schema_list,
|
||||
"breadcrumbs": [],
|
||||
"cities": load_cities(),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/meetspot/{city_slug}", response_class=HTMLResponse)
|
||||
@limiter.limit("60/minute")
|
||||
async def city_page(request: Request, city_slug: str):
|
||||
city = _get_city_by_slug(city_slug)
|
||||
if not city:
|
||||
raise HTTPException(status_code=404, detail="City not found")
|
||||
|
||||
meta_tags = seo_generator.generate_meta_tags(
|
||||
"city_page",
|
||||
{
|
||||
"city": city.get("name"),
|
||||
"city_en": city.get("name_en"),
|
||||
"venue_types": city.get("popular_venues", []),
|
||||
},
|
||||
)
|
||||
breadcrumb = seo_generator.generate_schema_org(
|
||||
"breadcrumb",
|
||||
{
|
||||
"items": [
|
||||
{"name": "Home", "url": "/"},
|
||||
{"name": city.get("name"), "url": f"/meetspot/{city_slug}"},
|
||||
]
|
||||
},
|
||||
)
|
||||
schema_list = _build_schema_list(
|
||||
seo_generator.generate_schema_org("webapp", {}),
|
||||
seo_generator.generate_schema_org("website", {"search_url": "/search"}),
|
||||
seo_generator.generate_schema_org("organization", {}),
|
||||
breadcrumb,
|
||||
)
|
||||
city_content = seo_generator.generate_city_content(city)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"pages/city.html",
|
||||
{
|
||||
"request": request,
|
||||
"meta_title": meta_tags["title"],
|
||||
"meta_description": meta_tags["description"],
|
||||
"meta_keywords": meta_tags["keywords"],
|
||||
"canonical_url": f"https://meetspot-irq2.onrender.com/meetspot/{city_slug}",
|
||||
"schema_jsonld": schema_list,
|
||||
"breadcrumbs": [
|
||||
{"name": "首页", "url": "/"},
|
||||
{"name": city.get("name"), "url": f"/meetspot/{city_slug}"},
|
||||
],
|
||||
"city": city,
|
||||
"city_content": city_content,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/about", response_class=HTMLResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def about_page(request: Request):
|
||||
meta_tags = seo_generator.generate_meta_tags("about", {})
|
||||
schema_list = _build_schema_list(
|
||||
seo_generator.generate_schema_org("organization", {}),
|
||||
seo_generator.generate_schema_org(
|
||||
"breadcrumb",
|
||||
{
|
||||
"items": [
|
||||
{"name": "Home", "url": "/"},
|
||||
{"name": "About", "url": "/about"},
|
||||
]
|
||||
},
|
||||
)
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"pages/about.html",
|
||||
{
|
||||
"request": request,
|
||||
"meta_title": meta_tags["title"],
|
||||
"meta_description": meta_tags["description"],
|
||||
"meta_keywords": meta_tags["keywords"],
|
||||
"canonical_url": "https://meetspot-irq2.onrender.com/about",
|
||||
"schema_jsonld": schema_list,
|
||||
"breadcrumbs": [
|
||||
{"name": "首页", "url": "/"},
|
||||
{"name": "关于我们", "url": "/about"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/how-it-works", response_class=HTMLResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def how_it_works(request: Request):
|
||||
meta_tags = seo_generator.generate_meta_tags("how_it_works", {})
|
||||
how_to_schema = seo_generator.generate_schema_org(
|
||||
"how_to",
|
||||
{
|
||||
"name": "使用MeetSpot AI Agent规划公平会面",
|
||||
"description": "5步AI推理流程, 从输入地址到生成推荐, 5-30秒内完成。",
|
||||
"total_time": "PT1M",
|
||||
"steps": [
|
||||
{
|
||||
"name": "解析地址",
|
||||
"text": "AI智能识别地址/地标/简称,'北大'自动转换为'北京市海淀区北京大学',校验经纬度。",
|
||||
},
|
||||
{
|
||||
"name": "计算中心点",
|
||||
"text": "使用球面几何(Haversine公式)计算地球曲面真实中点,数学上对每个人公平。",
|
||||
},
|
||||
{
|
||||
"name": "搜索周边场所",
|
||||
"text": "在中心点周边搜索匹配场景的POI,支持咖啡馆、餐厅、图书馆等12种场景主题。",
|
||||
},
|
||||
{
|
||||
"name": "GPT-4o智能评分",
|
||||
"text": "AI对候选场所进行多维度评分:距离、评分、停车、环境、交通便利度。",
|
||||
},
|
||||
{
|
||||
"name": "生成推荐",
|
||||
"text": "综合排序输出最优推荐,包含地图、评分、导航链接,可直接分享给朋友。",
|
||||
},
|
||||
],
|
||||
"tools": ["MeetSpot AI Agent", "AMap API", "GPT-4o"],
|
||||
"supplies": ["参与者地址", "场景选择", "特殊需求(可选)"],
|
||||
},
|
||||
)
|
||||
schema_list = _build_schema_list(
|
||||
seo_generator.generate_schema_org("website", {"search_url": "/search"}),
|
||||
seo_generator.generate_schema_org("organization", {}),
|
||||
seo_generator.generate_schema_org(
|
||||
"breadcrumb",
|
||||
{
|
||||
"items": [
|
||||
{"name": "Home", "url": "/"},
|
||||
{"name": "How it Works", "url": "/how-it-works"},
|
||||
]
|
||||
},
|
||||
),
|
||||
how_to_schema,
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"pages/how_it_works.html",
|
||||
{
|
||||
"request": request,
|
||||
"meta_title": meta_tags["title"],
|
||||
"meta_description": meta_tags["description"],
|
||||
"meta_keywords": meta_tags["keywords"],
|
||||
"canonical_url": "https://meetspot-irq2.onrender.com/how-it-works",
|
||||
"schema_jsonld": schema_list,
|
||||
"breadcrumbs": [
|
||||
{"name": "首页", "url": "/"},
|
||||
{"name": "使用指南", "url": "/how-it-works"},
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/faq", response_class=HTMLResponse)
|
||||
@limiter.limit("30/minute")
|
||||
async def faq_page(request: Request):
|
||||
meta_tags = seo_generator.generate_meta_tags("faq", {})
|
||||
faqs = [
|
||||
{
|
||||
"question": "MeetSpot 是什么?",
|
||||
"answer": "MeetSpot(聚点)是一个智能会面地点推荐系统,帮助多人找到最公平的聚会地点。无论是商务会谈、朋友聚餐还是学习讨论,都能快速找到合适的场所。",
|
||||
},
|
||||
{
|
||||
"question": "支持多少人一起查找?",
|
||||
"answer": "支持 2-10 个参与者位置,系统会根据所有人的位置计算最佳中点。",
|
||||
},
|
||||
{
|
||||
"question": "支持哪些城市?",
|
||||
"answer": "目前覆盖北京、上海、广州、深圳、杭州等 350+ 城市,使用高德地图数据,持续扩展中。",
|
||||
},
|
||||
{
|
||||
"question": "可以搜索哪些类型的场所?",
|
||||
"answer": "支持咖啡馆、餐厅、图书馆、KTV、健身房、密室逃脱等多种场所类型,还可以同时搜索多种类型(如'咖啡馆+餐厅')。",
|
||||
},
|
||||
{
|
||||
"question": "如何保证推荐公平?",
|
||||
"answer": "系统使用几何中心算法,确保每位参与者到聚会地点的距离都在合理范围内,没有人需要跑特别远。",
|
||||
},
|
||||
{
|
||||
"question": "推荐结果如何排序?",
|
||||
"answer": "基于评分、距离、用户需求的综合排序算法,优先推荐评分高、距离中心近、符合特殊需求的场所。",
|
||||
},
|
||||
{
|
||||
"question": "可以输入简称吗?",
|
||||
"answer": "支持!系统内置 60+ 大学简称映射,如'北大'会自动识别为'北京大学'。也支持输入地标名称如'国贸'、'东方明珠'等。",
|
||||
},
|
||||
{
|
||||
"question": "是否免费?需要注册吗?",
|
||||
"answer": "完全免费使用,无需注册,直接输入地址即可获得推荐结果。",
|
||||
},
|
||||
{
|
||||
"question": "推荐速度如何?",
|
||||
"answer": "AI Agent 会经历完整的5步推理流程:解析地址 → 计算中心点 → 搜索周边 → GPT-4o智能评分 → 生成推荐。单场景5-8秒,双场景8-12秒,复杂Agent模式15-30秒。",
|
||||
},
|
||||
{
|
||||
"question": "和高德地图有什么区别?",
|
||||
"answer": "高德搜索'我附近',MeetSpot搜索'我们中间'。我们先用球面几何算出多人公平中点,再推荐那里的好店。这是高德/百度都没有的功能。",
|
||||
},
|
||||
{
|
||||
"question": "AI Agent是什么意思?",
|
||||
"answer": "MeetSpot不是简单的搜索工具,而是一个AI Agent。它有5步完整的推理链条,使用GPT-4o进行多维度评分(距离、评分、停车、环境),你可以看到AI每一步是怎么'思考'的,完全透明可解释。",
|
||||
},
|
||||
{
|
||||
"question": "如何反馈问题或建议?",
|
||||
"answer": "欢迎通过 GitHub Issues 反馈问题或建议,也可以发送邮件至 Johnrobertdestiny@gmail.com。",
|
||||
},
|
||||
]
|
||||
schema_list = _build_schema_list(
|
||||
seo_generator.generate_schema_org("website", {"search_url": "/search"}),
|
||||
seo_generator.generate_schema_org("organization", {}),
|
||||
seo_generator.generate_schema_org("faq", {"faqs": faqs}),
|
||||
seo_generator.generate_schema_org(
|
||||
"breadcrumb",
|
||||
{
|
||||
"items": [
|
||||
{"name": "Home", "url": "/"},
|
||||
{"name": "FAQ", "url": "/faq"},
|
||||
]
|
||||
},
|
||||
),
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"pages/faq.html",
|
||||
{
|
||||
"request": request,
|
||||
"meta_title": meta_tags["title"],
|
||||
"meta_description": meta_tags["description"],
|
||||
"meta_keywords": meta_tags["keywords"],
|
||||
"canonical_url": "https://meetspot-irq2.onrender.com/faq",
|
||||
"schema_jsonld": schema_list,
|
||||
"breadcrumbs": [
|
||||
{"name": "首页", "url": "/"},
|
||||
{"name": "常见问题", "url": "/faq"},
|
||||
],
|
||||
"faqs": faqs,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/sitemap.xml", methods=["GET", "HEAD"])
|
||||
async def sitemap():
|
||||
base_url = "https://meetspot-irq2.onrender.com"
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
urls = [
|
||||
{"loc": "/", "priority": "1.0", "changefreq": "daily"},
|
||||
{"loc": "/about", "priority": "0.8", "changefreq": "monthly"},
|
||||
{"loc": "/faq", "priority": "0.8", "changefreq": "weekly"},
|
||||
{"loc": "/how-it-works", "priority": "0.7", "changefreq": "monthly"},
|
||||
]
|
||||
city_urls = [
|
||||
{
|
||||
"loc": f"/meetspot/{city['slug']}",
|
||||
"priority": "0.9",
|
||||
"changefreq": "weekly",
|
||||
}
|
||||
for city in load_cities()
|
||||
]
|
||||
entries = []
|
||||
for item in urls + city_urls:
|
||||
entries.append(
|
||||
f" <url>\n <loc>{base_url}{item['loc']}</loc>\n <lastmod>{today}</lastmod>\n <changefreq>{item['changefreq']}</changefreq>\n <priority>{item['priority']}</priority>\n </url>"
|
||||
)
|
||||
sitemap_xml = (
|
||||
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
|
||||
"<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n"
|
||||
+ "\n".join(entries)
|
||||
+ "\n</urlset>"
|
||||
)
|
||||
# Long cache with stale-while-revalidate to handle Render cold starts
|
||||
# CDN can serve stale content while revalidating in background
|
||||
return Response(
|
||||
content=sitemap_xml,
|
||||
media_type="application/xml",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
|
||||
"X-Robots-Tag": "noindex", # Sitemap itself shouldn't be indexed
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.api_route("/robots.txt", methods=["GET", "HEAD"])
|
||||
async def robots_txt():
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
robots = f"""# MeetSpot Robots.txt\n# Generated: {today}\n\nUser-agent: *\nAllow: /\nCrawl-delay: 1\n\nDisallow: /admin/\nDisallow: /api/internal/\nDisallow: /*.json$\n\nSitemap: https://meetspot-irq2.onrender.com/sitemap.xml\n\nUser-agent: Googlebot\nAllow: /\n\nUser-agent: Baiduspider\nAllow: /\n\nUser-agent: GPTBot\nDisallow: /\n\nUser-agent: CCBot\nDisallow: /\n"""
|
||||
# Long cache with stale-while-revalidate to handle Render cold starts
|
||||
return Response(
|
||||
content=robots,
|
||||
media_type="text/plain",
|
||||
headers={
|
||||
"Cache-Control": "public, max-age=86400, stale-while-revalidate=604800",
|
||||
},
|
||||
)
|
||||
0
MeetSpot/api/services/__init__.py
Normal file
0
MeetSpot/api/services/__init__.py
Normal file
423
MeetSpot/api/services/seo_content.py
Normal file
423
MeetSpot/api/services/seo_content.py
Normal file
@@ -0,0 +1,423 @@
|
||||
"""SEO内容生成服务.
|
||||
|
||||
负责关键词提取、Meta标签、结构化数据以及城市内容片段生成。
|
||||
该模块与Jinja2模板配合, 为SSR页面提供语义化上下文。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from functools import lru_cache
|
||||
from typing import Dict, List
|
||||
|
||||
import jieba
|
||||
import jieba.analyse
|
||||
|
||||
|
||||
class SEOContentGenerator:
|
||||
"""封装SEO内容生成逻辑."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.custom_words = [
|
||||
"聚会地点",
|
||||
"会面点",
|
||||
"中点推荐",
|
||||
"团队聚会",
|
||||
"远程团队",
|
||||
"咖啡馆",
|
||||
"餐厅",
|
||||
"图书馆",
|
||||
"共享空间",
|
||||
"北京",
|
||||
"上海",
|
||||
"广州",
|
||||
"深圳",
|
||||
"杭州",
|
||||
"成都",
|
||||
"meeting location",
|
||||
"midpoint",
|
||||
"group meeting",
|
||||
]
|
||||
for word in self.custom_words:
|
||||
jieba.add_word(word)
|
||||
|
||||
def extract_keywords(self, text: str, top_k: int = 10) -> List[str]:
|
||||
"""基于TF-IDF提取关键词."""
|
||||
if not text:
|
||||
return []
|
||||
return jieba.analyse.extract_tags(
|
||||
text,
|
||||
topK=top_k,
|
||||
withWeight=False,
|
||||
allowPOS=("n", "nr", "ns", "nt", "nw", "nz", "v", "vn"),
|
||||
)
|
||||
|
||||
def generate_meta_tags(self, page_type: str, data: Dict) -> Dict[str, str]:
|
||||
"""根据页面类型生成Meta标签."""
|
||||
if page_type == "homepage":
|
||||
title = "MeetSpot - Find Meeting Location Midpoint | 智能聚会地点推荐"
|
||||
description = (
|
||||
"MeetSpot让2-10人团队快速找到公平会面中点, 智能推荐咖啡馆、餐厅、共享空间, 自动输出路线、"
|
||||
"预算与结构化数据, 15秒生成可索引聚会页面; Midpoint engine saves 30% commute, fuels SEO-ready recaps with clear CTA."
|
||||
)
|
||||
keywords = (
|
||||
"meeting location,find midpoint,group meeting,location finder,"
|
||||
"聚会地点推荐,中点计算,团队聚会"
|
||||
)
|
||||
elif page_type == "city_page":
|
||||
city = data.get("city", "")
|
||||
city_en = data.get("city_en", "")
|
||||
venue_types = data.get("venue_types", [])
|
||||
venue_snippet = "、".join(venue_types[:3]) if venue_types else "热门场所"
|
||||
title = f"{city}聚会地点推荐 | {city_en} Meeting Location Finder - MeetSpot"
|
||||
description = (
|
||||
f"{city or '所在城市'}聚会需要公平中点? MeetSpot根据2-10人轨迹计算平衡路线, 推荐{venue_snippet}等场所, "
|
||||
"输出中文/英文场地文案、预算与交通信息, 15秒生成可索引城市着陆页; Local insights boost trust, shareable cards unlock faster decisions."
|
||||
)
|
||||
keywords = f"{city},{city_en},meeting location,{venue_snippet},midpoint"
|
||||
elif page_type == "about":
|
||||
title = "About MeetSpot - How We Find Perfect Meeting Locations | 关于我们"
|
||||
description = (
|
||||
"MeetSpot团队由地图算法、内容运营与产品负责人组成, 公开使命、技术栈、治理方式, 分享用户案例、AMAP合规、安全策略与开源路线图; "
|
||||
"Learn how we guarantee equitable experiences backed by ongoing UX research。"
|
||||
)
|
||||
keywords = "about meetspot,meeting algorithm,location technology,关于,聚会算法"
|
||||
elif page_type == "faq":
|
||||
title = "FAQ - Meeting Location Questions Answered | 常见问题 - MeetSpot"
|
||||
description = (
|
||||
"覆盖聚会地点、费用、功能等核心提问, 提供结构化答案, 支持Google FAQ Schema, 让用户与搜索引擎获得清晰指导, "
|
||||
"并附上联系入口与下一步CTA, FAQ hub helps planners resolve objections faster and improve conversions。"
|
||||
)
|
||||
keywords = "faq,meeting questions,location help,常见问题,使用指南"
|
||||
elif page_type == "how_it_works":
|
||||
title = "How MeetSpot Works | 智能聚会地点中点计算流程"
|
||||
description = (
|
||||
"4步流程涵盖收集地址、平衡权重、筛选场地与导出SEO文案, 附带动图、清单和风控提示, 指导团队15分钟内发布可索引页面; "
|
||||
"Learn safeguards, KPIs, stakeholder handoffs, and post-launch QA behind MeetSpot。"
|
||||
)
|
||||
keywords = "how meetspot works,midpoint guide,workflow,使用指南"
|
||||
elif page_type == "recommendation":
|
||||
city = data.get("city", "未知城市")
|
||||
keyword = data.get("keyword", "聚会地点")
|
||||
count = data.get("locations_count", 2)
|
||||
title = f"{city}{keyword}推荐 - {count}人聚会最佳会面点 | MeetSpot"
|
||||
description = (
|
||||
f"{city}{count}人{keyword}推荐由MeetSpot中点引擎生成, 结合每位参与者的路程、预算与场地偏好, "
|
||||
"给出评分、热力图和可复制行程; Share SEO-ready cards、CTA, keep planning transparent, document-ready for clients, and measurable。"
|
||||
)
|
||||
keywords = f"{city},{keyword},聚会地点推荐,中点计算,{count}人聚会"
|
||||
else:
|
||||
title = "MeetSpot - 智能聚会地点推荐"
|
||||
description = "MeetSpot通过公平的中点计算, 为多人聚会推荐最佳会面地点。"
|
||||
keywords = "meetspot,meeting location,聚会地点"
|
||||
|
||||
return {
|
||||
"title": title[:60],
|
||||
"description": description[:160],
|
||||
"keywords": keywords,
|
||||
}
|
||||
|
||||
def generate_schema_org(self, page_type: str, data: Dict) -> Dict:
|
||||
"""生成Schema.org结构化数据."""
|
||||
base_url = "https://meetspot-irq2.onrender.com"
|
||||
if page_type == "webapp":
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
"name": "MeetSpot",
|
||||
"description": "Find the perfect meeting location midpoint for groups",
|
||||
"applicationCategory": "UtilitiesApplication",
|
||||
"operatingSystem": "Web",
|
||||
"offers": {
|
||||
"@type": "Offer",
|
||||
"price": "0",
|
||||
"priceCurrency": "USD",
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": "4.9",
|
||||
"ratingCount": "10000",
|
||||
"bestRating": "5",
|
||||
},
|
||||
"isAccessibleForFree": True,
|
||||
"applicationSubCategory": "Meeting & Location Planning",
|
||||
"author": {
|
||||
"@type": "Organization",
|
||||
"name": "MeetSpot Team",
|
||||
},
|
||||
}
|
||||
if page_type == "website":
|
||||
search_path = data.get("search_url", "/search")
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebSite",
|
||||
"name": "MeetSpot",
|
||||
"url": base_url + "/",
|
||||
"inLanguage": "zh-CN",
|
||||
"potentialAction": {
|
||||
"@type": "SearchAction",
|
||||
"target": f"{base_url}{search_path}?q={{query}}",
|
||||
"query-input": "required name=query",
|
||||
},
|
||||
}
|
||||
if page_type == "organization":
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Organization",
|
||||
"name": "MeetSpot",
|
||||
"url": base_url,
|
||||
"logo": f"{base_url}/static/images/og-image.png",
|
||||
"foundingDate": "2023-08-01",
|
||||
"contactPoint": [
|
||||
{
|
||||
"@type": "ContactPoint",
|
||||
"contactType": "customer support",
|
||||
"email": "hello@meetspot.app",
|
||||
"availableLanguage": ["zh-CN", "en"],
|
||||
}
|
||||
],
|
||||
"sameAs": [
|
||||
"https://github.com/calderbuild/MeetSpot",
|
||||
"https://jasonrobert.me/",
|
||||
],
|
||||
}
|
||||
if page_type == "local_business":
|
||||
venue = data
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "LocalBusiness",
|
||||
"name": venue.get("name"),
|
||||
"address": {
|
||||
"@type": "PostalAddress",
|
||||
"streetAddress": venue.get("address"),
|
||||
"addressLocality": venue.get("city"),
|
||||
"addressCountry": "CN",
|
||||
},
|
||||
"geo": {
|
||||
"@type": "GeoCoordinates",
|
||||
"latitude": venue.get("lat"),
|
||||
"longitude": venue.get("lng"),
|
||||
},
|
||||
"aggregateRating": {
|
||||
"@type": "AggregateRating",
|
||||
"ratingValue": venue.get("rating", 4.5),
|
||||
"reviewCount": venue.get("review_count", 100),
|
||||
},
|
||||
"priceRange": venue.get("price_range", "$$"),
|
||||
}
|
||||
if page_type == "faq":
|
||||
faqs = data.get("faqs", [])
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "FAQPage",
|
||||
"mainEntity": [
|
||||
{
|
||||
"@type": "Question",
|
||||
"name": faq["question"],
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": faq["answer"],
|
||||
},
|
||||
}
|
||||
for faq in faqs
|
||||
],
|
||||
}
|
||||
if page_type == "how_to":
|
||||
steps = data.get("steps", [])
|
||||
if not steps:
|
||||
return {}
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "HowTo",
|
||||
"name": data.get("name", "如何使用MeetSpot"),
|
||||
"description": data.get(
|
||||
"description",
|
||||
"Step-by-step guide to plan a fair meetup with MeetSpot.",
|
||||
),
|
||||
"totalTime": data.get("total_time", "PT15M"),
|
||||
"inLanguage": "zh-CN",
|
||||
"step": [
|
||||
{
|
||||
"@type": "HowToStep",
|
||||
"name": step["name"],
|
||||
"text": step["text"],
|
||||
}
|
||||
for step in steps
|
||||
],
|
||||
"supply": data.get("supplies", ["参与者地址", "交通方式偏好"]),
|
||||
"tool": data.get("tools", ["MeetSpot Dashboard"]),
|
||||
}
|
||||
if page_type == "breadcrumb":
|
||||
items = data.get("items", [])
|
||||
return {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "BreadcrumbList",
|
||||
"itemListElement": [
|
||||
{
|
||||
"@type": "ListItem",
|
||||
"position": idx + 1,
|
||||
"name": item["name"],
|
||||
"item": f"{base_url}{item['url']}",
|
||||
}
|
||||
for idx, item in enumerate(items)
|
||||
],
|
||||
}
|
||||
return {}
|
||||
|
||||
def generate_city_content(self, city_data: Dict) -> Dict[str, str]:
|
||||
"""生成城市页面内容块, 使用丰富的城市数据."""
|
||||
city = city_data.get("name", "")
|
||||
city_en = city_data.get("name_en", "")
|
||||
tagline = city_data.get("tagline", "")
|
||||
description = city_data.get("description", "")
|
||||
landmarks = city_data.get("landmarks", [])
|
||||
university_clusters = city_data.get("university_clusters", [])
|
||||
business_districts = city_data.get("business_districts", [])
|
||||
metro_lines = city_data.get("metro_lines", 0)
|
||||
use_cases = city_data.get("use_cases", [])
|
||||
local_tips = city_data.get("local_tips", "")
|
||||
popular_venues = city_data.get("popular_venues", [])
|
||||
|
||||
# 生成地标标签
|
||||
landmarks_html = "".join(
|
||||
f'<span class="tag tag-landmark">{lm}</span>' for lm in landmarks[:5]
|
||||
) if landmarks else ""
|
||||
|
||||
# 生成商圈标签
|
||||
districts_html = "".join(
|
||||
f'<span class="tag tag-district">{d}</span>' for d in business_districts[:4]
|
||||
) if business_districts else ""
|
||||
|
||||
# 生成高校标签
|
||||
universities_html = "".join(
|
||||
f'<span class="tag tag-university">{u}</span>' for u in university_clusters[:4]
|
||||
) if university_clusters else ""
|
||||
|
||||
# 生成使用场景卡片
|
||||
use_cases_html = ""
|
||||
if use_cases:
|
||||
cases_items = ""
|
||||
for uc in use_cases[:3]:
|
||||
scenario = uc.get("scenario", "")
|
||||
example = uc.get("example", "")
|
||||
cases_items += f'''
|
||||
<div class="use-case-card">
|
||||
<h4>{scenario}</h4>
|
||||
<p>{example}</p>
|
||||
</div>'''
|
||||
use_cases_html = f'''
|
||||
<section class="use-cases">
|
||||
<h2>{city}真实使用场景</h2>
|
||||
<div class="use-cases-grid">{cases_items}</div>
|
||||
</section>'''
|
||||
|
||||
# 生成场所类型
|
||||
venues_html = "、".join(popular_venues[:4]) if popular_venues else "咖啡馆、餐厅"
|
||||
|
||||
content = {
|
||||
"intro": f'''
|
||||
<div class="city-hero">
|
||||
<h1>{city}聚会地点推荐 - {city_en}</h1>
|
||||
<p class="tagline">{tagline}</p>
|
||||
<p class="lead">{description}</p>
|
||||
</div>''',
|
||||
|
||||
"features": f'''
|
||||
<section class="city-features">
|
||||
<h2>为什么在{city}使用MeetSpot?</h2>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚇</div>
|
||||
<h3>{metro_lines}条地铁线路</h3>
|
||||
<p>{city}地铁网络发达,MeetSpot优先推荐地铁站周边的聚会场所</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🎯</div>
|
||||
<h3>智能中点计算</h3>
|
||||
<p>球面几何算法确保每位参与者通勤距离公平均衡</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📍</div>
|
||||
<h3>本地精选场所</h3>
|
||||
<p>覆盖{city}{venues_html}等热门类型,高评分场所优先推荐</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>''',
|
||||
|
||||
"landmarks": f'''
|
||||
<section class="city-landmarks">
|
||||
<h2>{city}热门聚会区域</h2>
|
||||
<div class="tags-section">
|
||||
<div class="tags-group">
|
||||
<h3>地标商圈</h3>
|
||||
<div class="tags">{landmarks_html}</div>
|
||||
</div>
|
||||
<div class="tags-group">
|
||||
<h3>商务中心</h3>
|
||||
<div class="tags">{districts_html}</div>
|
||||
</div>
|
||||
<div class="tags-group">
|
||||
<h3>高校聚集区</h3>
|
||||
<div class="tags">{universities_html}</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>''' if landmarks or business_districts or university_clusters else "",
|
||||
|
||||
"use_cases": use_cases_html,
|
||||
|
||||
"local_tips": f'''
|
||||
<section class="local-tips">
|
||||
<h2>{city}聚会小贴士</h2>
|
||||
<div class="tip-card">
|
||||
<div class="tip-icon">💡</div>
|
||||
<p>{local_tips}</p>
|
||||
</div>
|
||||
</section>''' if local_tips else "",
|
||||
|
||||
"how_it_works": f'''
|
||||
<section class="how-it-works">
|
||||
<h2>如何在{city}找到最佳聚会地点?</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<span class="step-number">1</span>
|
||||
<div class="step-content">
|
||||
<h4>输入参与者位置</h4>
|
||||
<p>支持输入{city}任意地址、地标或高校名称(如{university_clusters[0] if university_clusters else "当地高校"})</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-number">2</span>
|
||||
<div class="step-content">
|
||||
<h4>选择场所类型</h4>
|
||||
<p>根据聚会目的选择{venues_html}等场景</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<span class="step-number">3</span>
|
||||
<div class="step-content">
|
||||
<h4>获取智能推荐</h4>
|
||||
<p>系统自动计算地理中点,推荐{landmarks[0] if landmarks else "市中心"}等区域的高评分场所</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>''',
|
||||
|
||||
"cta": f'''
|
||||
<section class="cta-section">
|
||||
<h2>开始规划{city}聚会</h2>
|
||||
<p>无需注册,输入地址即可获取推荐</p>
|
||||
<a href="/" class="cta-button">立即使用 MeetSpot</a>
|
||||
</section>''',
|
||||
}
|
||||
|
||||
# 计算字数
|
||||
total_text = "".join(str(v) for v in content.values())
|
||||
text_only = "".join(ch for ch in total_text if ch.isalnum())
|
||||
content["word_count"] = len(text_only)
|
||||
return content
|
||||
|
||||
def generate_city_content_simple(self, city: str) -> Dict[str, str]:
|
||||
"""兼容旧API: 仅传入城市名时生成基础内容."""
|
||||
return self.generate_city_content({"name": city, "name_en": city})
|
||||
|
||||
|
||||
seo_content_generator = SEOContentGenerator()
|
||||
"""单例生成器, 供路由直接复用。"""
|
||||
Reference in New Issue
Block a user