627 lines
22 KiB
Python
627 lines
22 KiB
Python
"""
|
||
通用采集路由 - 基于 Scrapling 引擎
|
||
结合上层业务逻辑:编码修复、数学公式处理、微信防盗链、图片下载等
|
||
"""
|
||
|
||
import os
|
||
import re
|
||
import sys
|
||
import uuid
|
||
import asyncio
|
||
import tempfile
|
||
import base64
|
||
from datetime import datetime
|
||
from typing import Optional, List
|
||
from urllib.parse import urljoin, urlparse
|
||
from pathlib import Path
|
||
|
||
from fastapi import APIRouter, HTTPException, Depends, UploadFile, File
|
||
from pydantic import BaseModel, HttpUrl
|
||
from sqlalchemy.orm import Session
|
||
from bs4 import BeautifulSoup
|
||
|
||
# Scrapling 引擎
|
||
from scrapling import Fetcher, StealthyFetcher
|
||
|
||
# 复用 crawl.py 的通用工具函数
|
||
from .crawl import (
|
||
fix_encoding,
|
||
fix_math_formulas,
|
||
html_to_markdown,
|
||
extract_title_from_html,
|
||
extract_main_content,
|
||
download_image,
|
||
extract_images,
|
||
extract_images_from_markdown,
|
||
is_spa_site,
|
||
convert_spa_html_to_markdown,
|
||
CrawlRequest,
|
||
CrawlResponse,
|
||
CrawlImage,
|
||
)
|
||
|
||
|
||
class CrawlSaveRequest(BaseModel):
|
||
"""通用采集保存请求"""
|
||
title: str
|
||
content: str
|
||
content_html: Optional[str] = ""
|
||
images: List[CrawlImage] = []
|
||
summary: Optional[str] = ""
|
||
category_id: Optional[int] = None
|
||
is_draft: bool = True
|
||
is_published: bool = False
|
||
tags: Optional[List[str]] = []
|
||
source_url: Optional[str] = None
|
||
|
||
# 复用图片处理
|
||
from .images import generate_thumbnail, get_image_dimensions
|
||
|
||
# 数据库
|
||
from database import get_db
|
||
from models import Post, Category, Tag, PostTag, Image
|
||
|
||
router = APIRouter(prefix="/api/universal-crawl", tags=["通用采集"])
|
||
|
||
# 确保上传目录存在
|
||
UPLOAD_DIR = Path("uploads")
|
||
UPLOAD_DIR.mkdir(exist_ok=True)
|
||
TEMP_DIR = Path("uploads/temp_crawl")
|
||
TEMP_DIR.mkdir(exist_ok=True)
|
||
|
||
|
||
class UniversalCrawlRequest(BaseModel):
|
||
url: str
|
||
|
||
|
||
def _needs_browser(url: str) -> bool:
|
||
"""判断是否需要使用浏览器渲染"""
|
||
domain = urlparse(url).netloc.lower()
|
||
browser_domains = [
|
||
'mp.weixin.qq.com',
|
||
'juejin.cn',
|
||
'zhihu.com',
|
||
'segmentfault.com',
|
||
'infoq.cn',
|
||
'infoq.com',
|
||
'51cto.com',
|
||
'eeworld.com.cn', # JS WAF 防护,必须用浏览器渲染
|
||
]
|
||
return any(d in domain for d in browser_domains)
|
||
|
||
|
||
def _fetch_with_scrapling(url: str) -> str:
|
||
"""使用 Scrapling Fetcher (curl_cffi) 获取页面"""
|
||
fetcher = Fetcher()
|
||
response = fetcher.get(url)
|
||
if response.status != 200:
|
||
raise Exception(f"HTTP {response.status}: {response.reason}")
|
||
# Scrapling Response 的 text 属性返回解析后的文本,html_content 返回原始 HTML
|
||
return response.html_content if hasattr(response, 'html_content') else response.text
|
||
|
||
|
||
async def _fetch_with_stealth(url: str) -> str:
|
||
"""使用 Scrapling StealthyFetcher 获取页面,失败时回退到原生 Playwright"""
|
||
try:
|
||
from scrapling import StealthyFetcher
|
||
# Scrapling v0.3+ 推荐使用类方法直接调用,无需实例化
|
||
response = await StealthyFetcher.async_fetch(url, wait=3000)
|
||
if not response or response.status != 200:
|
||
raise Exception(f"HTTP {response.status}")
|
||
html = response.html_content if hasattr(response, 'html_content') else response.text
|
||
if not html or len(html) < 100:
|
||
raise Exception("Empty page")
|
||
print(f"[Universal Crawl] StealthyFetcher success: {len(html)} bytes")
|
||
return html
|
||
except Exception as e:
|
||
print(f"[Universal Crawl] StealthyFetcher failed: {e}")
|
||
print("[Universal Crawl] Falling back to native Playwright")
|
||
return await _fetch_with_playwright(url)
|
||
|
||
|
||
async def _fetch_with_playwright(url: str) -> str:
|
||
"""使用原生 Playwright 异步获取页面,带完善反爬配置"""
|
||
from playwright.async_api import async_playwright
|
||
|
||
async with async_playwright() as p:
|
||
browser = await p.chromium.launch(
|
||
headless=True,
|
||
args=[
|
||
"--disable-blink-features=AutomationControlled",
|
||
"--disable-web-security",
|
||
"--disable-features=IsolateOrigins,site-per-process",
|
||
"--disable-dev-shm-usage",
|
||
"--no-sandbox",
|
||
]
|
||
)
|
||
context = await browser.new_context(
|
||
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 Edg/120.0.0.0",
|
||
viewport={"width": 1920, "height": 1080},
|
||
locale="zh-CN",
|
||
timezone_id="Asia/Shanghai",
|
||
extra_http_headers={
|
||
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
|
||
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
|
||
"Accept-Encoding": "gzip, deflate, br",
|
||
"Cache-Control": "max-age=0",
|
||
"Sec-Ch-Ua": '"Not_A Brand";v="8", "Chromium";v="120", "Microsoft Edge";v="120"',
|
||
"Sec-Ch-Ua-Mobile": "?0",
|
||
"Sec-Ch-Ua-Platform": '"Windows"',
|
||
"Sec-Fetch-Dest": "document",
|
||
"Sec-Fetch-Mode": "navigate",
|
||
"Sec-Fetch-Site": "none",
|
||
"Sec-Fetch-User": "?1",
|
||
"Upgrade-Insecure-Requests": "1",
|
||
},
|
||
)
|
||
page = await context.new_page()
|
||
try:
|
||
# SegmentFault 需要先访问首页建立信任,再跳转目标页面
|
||
if 'segmentfault.com' in url:
|
||
await page.goto("https://segmentfault.com", wait_until="domcontentloaded", timeout=15000)
|
||
await asyncio.sleep(2)
|
||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||
await asyncio.sleep(5)
|
||
# 知乎需要先访问首页建立 cookie,再跳转目标页面
|
||
elif 'zhihu.com' in url:
|
||
await page.goto("https://www.zhihu.com", wait_until="domcontentloaded", timeout=15000)
|
||
await asyncio.sleep(2)
|
||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||
await asyncio.sleep(3)
|
||
# 等待正文加载(知乎专栏文章正文在 .Post-RichTextContainer 或 .RichContent-inner 里)
|
||
try:
|
||
await page.wait_for_selector(".Post-RichTextContainer, .RichContent-inner", timeout=10000)
|
||
await asyncio.sleep(1)
|
||
except Exception:
|
||
pass
|
||
else:
|
||
await page.goto(url, wait_until="domcontentloaded", timeout=30000)
|
||
await asyncio.sleep(2)
|
||
html = await page.content()
|
||
finally:
|
||
await context.close()
|
||
await browser.close()
|
||
return html
|
||
|
||
|
||
def _bypass_wechat_images(html: str, url: str) -> str:
|
||
"""
|
||
微信图片防盗链绕过:在页面上下文中执行 fetch 获取图片 base64
|
||
这里我们在后端模拟:如果检测到 mmbiz.qpic.cn 图片,
|
||
将其 src 替换为 data-src(微信文章中 data-src 通常包含真实 URL)
|
||
"""
|
||
if 'mp.weixin.qq.com' not in url:
|
||
return html
|
||
|
||
soup = BeautifulSoup(html, 'lxml')
|
||
for img in soup.find_all('img'):
|
||
data_src = img.get('data-src')
|
||
if data_src and 'mmbiz.qpic.cn' in data_src:
|
||
img['src'] = data_src
|
||
return str(soup)
|
||
|
||
|
||
def _extract_infoq_content(html: str) -> str:
|
||
"""提取 InfoQ 文章正文(.ProseMirror 或 article 标签)"""
|
||
soup = BeautifulSoup(html, 'lxml')
|
||
# 优先使用 .ProseMirror(InfoQ 正文编辑器容器)
|
||
container = soup.select_one('.ProseMirror')
|
||
if not container:
|
||
# fallback 到 article 标签
|
||
container = soup.find('article')
|
||
if container:
|
||
return str(container)
|
||
return html
|
||
|
||
|
||
def _extract_infoq_title(html: str) -> Optional[str]:
|
||
"""提取 InfoQ 文章标题,从 <title> 中去掉 ' - InfoQ' 后缀"""
|
||
match = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
title = match.group(1).strip()
|
||
# 去掉 " - InfoQ" 后缀
|
||
title = re.sub(r'\s*[-–—]\s*InfoQ\s*$', '', title, flags=re.IGNORECASE).strip()
|
||
if len(title) > 5:
|
||
return title
|
||
return None
|
||
|
||
|
||
def _extract_juejin_content(html: str) -> str:
|
||
"""提取掘金文章正文(article.article 或 .article-viewer)"""
|
||
soup = BeautifulSoup(html, 'lxml')
|
||
# 优先使用 article.article
|
||
container = soup.select_one('article.article')
|
||
if not container:
|
||
# fallback 到 .article-viewer
|
||
container = soup.select_one('.article-viewer')
|
||
if not container:
|
||
# 再 fallback 到 #article-root
|
||
container = soup.select_one('#article-root')
|
||
if container:
|
||
return str(container)
|
||
return html
|
||
|
||
|
||
def _extract_juejin_title(html: str) -> Optional[str]:
|
||
"""提取掘金文章标题,从 <title> 中去掉 ' - 掘金' 后缀"""
|
||
match = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
title = match.group(1).strip()
|
||
# 去掉 " - 掘金" 后缀
|
||
title = re.sub(r'\s*[-–—]\s*掘金\s*$', '', title, flags=re.IGNORECASE).strip()
|
||
if len(title) > 5:
|
||
return title
|
||
return None
|
||
|
||
|
||
def _extract_csdn_content(html: str) -> str:
|
||
"""提取 CSDN 文章正文(#content_views 或 .article_content)"""
|
||
soup = BeautifulSoup(html, 'lxml')
|
||
# 优先使用 #content_views(CSDN 正文主容器)
|
||
container = soup.select_one('#content_views')
|
||
if not container:
|
||
# fallback 到 .article_content
|
||
container = soup.select_one('.article_content')
|
||
if container:
|
||
return str(container)
|
||
return html
|
||
|
||
|
||
def _extract_csdn_title(html: str) -> Optional[str]:
|
||
"""提取 CSDN 文章标题,从 <title> 中去掉 '-CSDN博客' 后缀"""
|
||
match = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
title = match.group(1).strip()
|
||
# 去掉 " - CSDN博客" / "-CSDN博客" 后缀
|
||
title = re.sub(r'\s*[-–—]?\s*CSDN博客\s*$', '', title, flags=re.IGNORECASE).strip()
|
||
if len(title) > 5:
|
||
return title
|
||
return None
|
||
|
||
|
||
def _filter_csdn_images(images: list) -> list:
|
||
"""过滤掉 CSDN 的 UI 图标图片(csdnimg.cn 的收藏/点赞等图标)"""
|
||
filtered = []
|
||
skip_patterns = [
|
||
'newHeart2023',
|
||
'tobarCollect',
|
||
'tobarCollection',
|
||
'copyright',
|
||
'avatar',
|
||
'blogv2/dist/pc/img',
|
||
]
|
||
for img in images:
|
||
url = img.original_url.lower()
|
||
if any(p in url for p in skip_patterns):
|
||
continue
|
||
filtered.append(img)
|
||
return filtered
|
||
|
||
|
||
def _extract_cnblogs_content(html: str) -> str:
|
||
"""提取博客园文章正文(#cnblogs_post_body 或 .postBody)"""
|
||
soup = BeautifulSoup(html, 'lxml')
|
||
container = soup.select_one('#cnblogs_post_body')
|
||
if not container:
|
||
container = soup.select_one('.postBody')
|
||
if container:
|
||
return str(container)
|
||
return html
|
||
|
||
|
||
def _extract_cnblogs_title(html: str) -> Optional[str]:
|
||
"""提取博客园文章标题,从 <title> 中去掉 ' - 博客园' 后缀"""
|
||
match = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
title = match.group(1).strip()
|
||
title = re.sub(r'\s*[-–—]\s*博客园\s*$', '', title, flags=re.IGNORECASE).strip()
|
||
if len(title) > 5:
|
||
return title
|
||
return None
|
||
|
||
|
||
def _filter_cnblogs_images(images: list) -> list:
|
||
"""过滤掉博客园的 UI 图标图片"""
|
||
filtered = []
|
||
skip_patterns = [
|
||
'assets.cnblogs.com/logo',
|
||
'assets.cnblogs.com/icons',
|
||
'skins/custom/images/logo',
|
||
]
|
||
for img in images:
|
||
url = img.original_url.lower()
|
||
if any(p in url for p in skip_patterns):
|
||
continue
|
||
filtered.append(img)
|
||
return filtered
|
||
|
||
|
||
def _extract_51cto_content(html: str) -> str:
|
||
"""提取 51CTO 文章正文(.article-content 或 article)"""
|
||
soup = BeautifulSoup(html, 'lxml')
|
||
container = soup.select_one('.article-content')
|
||
if not container:
|
||
container = soup.find('article')
|
||
if container:
|
||
return str(container)
|
||
return html
|
||
|
||
|
||
def _extract_51cto_title(html: str) -> Optional[str]:
|
||
"""提取 51CTO 文章标题,从 <title> 中去掉 '-51CTO.COM' 后缀"""
|
||
match = re.search(r'<title[^>]*>(.*?)</title>', html, re.IGNORECASE | re.DOTALL)
|
||
if match:
|
||
title = match.group(1).strip()
|
||
title = re.sub(r'\s*[-–—]?\s*51CTO\.COM\s*$', '', title, flags=re.IGNORECASE).strip()
|
||
if len(title) > 5:
|
||
return title
|
||
return None
|
||
|
||
|
||
def _filter_51cto_images(images: list) -> list:
|
||
"""过滤掉 51CTO 的 UI 图标和二维码图片"""
|
||
filtered = []
|
||
skip_patterns = [
|
||
'oss/202302', # 公众号矩阵二维码
|
||
'oss/202408', # 软考二维码
|
||
'oss/202506/06', # 顶部横幅
|
||
'oss/202302/07', # APP下载图
|
||
]
|
||
for img in images:
|
||
url = img.original_url.lower()
|
||
if any(p in url for p in skip_patterns):
|
||
continue
|
||
filtered.append(img)
|
||
return filtered
|
||
|
||
|
||
@router.post("/fetch", response_model=CrawlResponse)
|
||
async def universal_fetch(request: UniversalCrawlRequest):
|
||
"""通用采集:使用 Scrapling 引擎获取文章"""
|
||
try:
|
||
url = str(request.url)
|
||
print(f"[Universal Crawl] Fetching: {url}")
|
||
|
||
# 1. 判断采集策略
|
||
use_browser = _needs_browser(url)
|
||
|
||
# 2. 使用 Scrapling 获取页面
|
||
if use_browser:
|
||
print(f"[Universal Crawl] Using StealthyFetcher (browser) for {url}")
|
||
html = await _fetch_with_stealth(url)
|
||
else:
|
||
print(f"[Universal Crawl] Using Fetcher (curl_cffi) for {url}")
|
||
html = _fetch_with_scrapling(url)
|
||
|
||
if not html or len(html) < 100:
|
||
raise HTTPException(status_code=400, detail="无法获取页面内容,页面可能为空或需要登录")
|
||
|
||
# 3. 编码修复
|
||
html = fix_encoding(html)
|
||
|
||
# 4. 微信图片防盗链处理
|
||
html = _bypass_wechat_images(html, url)
|
||
|
||
# 5. 提取标题(InfoQ / 掘金 / CSDN / 博客园 / 51CTO 使用专用提取)
|
||
is_infoq = 'infoq.cn' in url or 'infoq.com' in url
|
||
is_juejin = 'juejin.cn' in url
|
||
is_csdn = 'csdn.net' in url or 'csdn.com' in url
|
||
is_cnblogs = 'cnblogs.com' in url
|
||
is_51cto = '51cto.com' in url
|
||
if is_infoq:
|
||
title = _extract_infoq_title(html)
|
||
elif is_juejin:
|
||
title = _extract_juejin_title(html)
|
||
elif is_csdn:
|
||
title = _extract_csdn_title(html)
|
||
elif is_cnblogs:
|
||
title = _extract_cnblogs_title(html)
|
||
elif is_51cto:
|
||
title = _extract_51cto_title(html)
|
||
else:
|
||
title = None
|
||
if not title:
|
||
title = extract_title_from_html(html)
|
||
if not title:
|
||
title = "未获取到标题"
|
||
|
||
# 6. 提取正文和图片
|
||
is_wechat = 'mp.weixin.qq.com' in url
|
||
images = extract_images(html, url)
|
||
if is_csdn:
|
||
images = _filter_csdn_images(images)
|
||
elif is_cnblogs:
|
||
images = _filter_cnblogs_images(images)
|
||
elif is_51cto:
|
||
images = _filter_51cto_images(images)
|
||
|
||
# 7. 转换为 Markdown(InfoQ / 掘金 / CSDN / 博客园 / 51CTO 使用专用提取)
|
||
if is_infoq:
|
||
infoq_html = _extract_infoq_content(html)
|
||
markdown_content = html_to_markdown(infoq_html, url)
|
||
elif is_juejin:
|
||
juejin_html = _extract_juejin_content(html)
|
||
markdown_content = html_to_markdown(juejin_html, url)
|
||
elif is_csdn:
|
||
csdn_html = _extract_csdn_content(html)
|
||
markdown_content = html_to_markdown(csdn_html, url)
|
||
elif is_cnblogs:
|
||
cnblogs_html = _extract_cnblogs_content(html)
|
||
markdown_content = html_to_markdown(cnblogs_html, url)
|
||
elif is_51cto:
|
||
cto_html = _extract_51cto_content(html)
|
||
markdown_content = html_to_markdown(cto_html, url)
|
||
elif is_spa_site(html):
|
||
markdown_content = convert_spa_html_to_markdown(html, images, url)
|
||
else:
|
||
markdown_content = html_to_markdown(html, url)
|
||
|
||
# 8. 数学公式修复
|
||
markdown_content = fix_math_formulas(markdown_content)
|
||
|
||
# 9. 清理 Markdown 中的 HTML 标签
|
||
markdown_content = re.sub(r'<[^>]+>', '', markdown_content)
|
||
markdown_content = re.sub(r'\n{3,}', '\n\n', markdown_content)
|
||
markdown_content = markdown_content.strip()
|
||
|
||
return CrawlResponse(
|
||
success=True,
|
||
title=title,
|
||
content=markdown_content,
|
||
images=images,
|
||
)
|
||
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
print(f"[Universal Crawl] Error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail=f"采集失败: {str(e)}")
|
||
|
||
|
||
@router.post("/save")
|
||
async def universal_save(request: CrawlSaveRequest, db: Session = Depends(get_db)):
|
||
"""保存通用采集的文章,下载图片到本地并替换内容中的远程 URL"""
|
||
try:
|
||
title = request.title
|
||
content = request.content
|
||
excerpt = request.summary or ""
|
||
category_id = request.category_id
|
||
source_url = request.source_url
|
||
image_list = request.images
|
||
is_draft = request.is_draft
|
||
is_published = request.is_published
|
||
|
||
print(f"[Universal Crawl] Saving article: {title}, images: {len(image_list)}")
|
||
|
||
# 创建临时目录
|
||
temp_dir = tempfile.mkdtemp(dir=TEMP_DIR)
|
||
final_dir = UPLOAD_DIR / "images"
|
||
final_dir.mkdir(exist_ok=True)
|
||
thumb_dir = UPLOAD_DIR / "thumbnails"
|
||
thumb_dir.mkdir(exist_ok=True)
|
||
|
||
# 下载图片
|
||
downloaded_images = []
|
||
images_saved = 0
|
||
images_failed = 0
|
||
for img in image_list:
|
||
try:
|
||
img_url = str(img.original_url)
|
||
if not img_url:
|
||
continue
|
||
filename, filepath, success = download_image(img_url, temp_dir)
|
||
if success and filepath:
|
||
# 移动最终文件
|
||
final_filename = f"crawl_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}{os.path.splitext(filename or 'img.jpg')[1] or '.jpg'}"
|
||
final_path = final_dir / final_filename
|
||
os.rename(filepath, str(final_path))
|
||
|
||
# 生成缩略图
|
||
thumb_filename = f"thumb_{final_filename}"
|
||
thumb_path = thumb_dir / thumb_filename
|
||
thumb_path_db = None
|
||
if generate_thumbnail(str(final_path), str(thumb_path)):
|
||
thumb_path_db = f"/uploads/thumbnails/{thumb_filename}"
|
||
|
||
# 获取尺寸
|
||
width, height = get_image_dimensions(str(final_path))
|
||
|
||
# 保存到数据库
|
||
db_image = Image(
|
||
filename=final_filename,
|
||
original_filename=final_filename,
|
||
file_path=f"/uploads/images/{final_filename}",
|
||
file_size=os.path.getsize(str(final_path)),
|
||
mime_type=f"image/{os.path.splitext(final_filename)[1].lstrip('.') or 'jpeg'}",
|
||
width=width,
|
||
height=height,
|
||
thumbnail_path=thumb_path_db,
|
||
)
|
||
db.add(db_image)
|
||
db.flush()
|
||
|
||
# 替换内容中的图片 URL
|
||
new_url = f"/uploads/images/{final_filename}"
|
||
content = content.replace(img_url, new_url)
|
||
images_saved += 1
|
||
|
||
downloaded_images.append({
|
||
"id": db_image.id,
|
||
"url": new_url,
|
||
"thumb": thumb_path_db,
|
||
})
|
||
except Exception as e:
|
||
images_failed += 1
|
||
print(f"[Universal Crawl] Download image error: {e}")
|
||
continue
|
||
|
||
# 生成摘要
|
||
summary = excerpt if excerpt else content[:300] if len(content) > 300 else content
|
||
summary = re.sub(r'[#*`\[\]\(\)!]', '', summary).strip()[:300]
|
||
|
||
# 创建文章
|
||
now = datetime.now()
|
||
slug = re.sub(r'[^\w\u4e00-\u9fff-]', '-', title)[:50].strip('-')
|
||
slug = f"{slug}-{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}"
|
||
|
||
# 查找或创建分类
|
||
final_category_id = category_id
|
||
if not final_category_id:
|
||
default_cat = db.query(Category).filter(Category.name == "未分类").first()
|
||
if not default_cat:
|
||
default_cat = Category(name="未分类", slug="uncategorized")
|
||
db.add(default_cat)
|
||
db.flush()
|
||
final_category_id = default_cat.id
|
||
|
||
post = Post(
|
||
title=title,
|
||
slug=slug,
|
||
content=content,
|
||
content_type="markdown",
|
||
summary=summary,
|
||
category_id=final_category_id,
|
||
is_published=is_published,
|
||
is_draft=is_draft,
|
||
view_count=0,
|
||
created_at=now,
|
||
updated_at=now,
|
||
)
|
||
db.add(post)
|
||
db.flush()
|
||
|
||
# 处理标签
|
||
if request.tags:
|
||
for tag_name in request.tags:
|
||
tag_name = tag_name.strip()
|
||
if not tag_name:
|
||
continue
|
||
tag = db.query(Tag).filter(Tag.name == tag_name).first()
|
||
if not tag:
|
||
tag_slug = re.sub(r'[^\w\u4e00-\u9fff-]', '-', tag_name).strip('-')
|
||
tag = Tag(name=tag_name, slug=tag_slug)
|
||
db.add(tag)
|
||
db.flush()
|
||
post_tag = PostTag(post_id=post.id, tag_id=tag.id)
|
||
db.add(post_tag)
|
||
|
||
db.commit()
|
||
|
||
return {
|
||
"success": True,
|
||
"post_id": post.id,
|
||
"slug": slug,
|
||
"message": "文章保存成功",
|
||
"images_saved": images_saved,
|
||
"images_failed": images_failed,
|
||
}
|
||
|
||
except Exception as e:
|
||
db.rollback()
|
||
print(f"[Universal Crawl] Save error: {e}")
|
||
import traceback
|
||
traceback.print_exc()
|
||
raise HTTPException(status_code=500, detail=f"保存失败: {str(e)}")
|