Auto backup \2026-\04-\20
@@ -675,7 +675,67 @@ def download_image(url: str, temp_dir: str) -> tuple:
|
|||||||
return filename, filepath, True
|
return filename, filepath, True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"下载图片失败 {url}: {e}")
|
print(f"下载图片失败 {url}: {e}")
|
||||||
return None, None, False
|
|
||||||
|
# CDN 子域名回退(如 eewimg.cn 的 66 子域名在部分网络无法解析)
|
||||||
|
if 'eewimg.cn' in url and url.startswith('http'):
|
||||||
|
fallback_result = _try_eewimg_cdn_fallback(url, temp_dir)
|
||||||
|
if fallback_result and fallback_result[2]:
|
||||||
|
return fallback_result
|
||||||
|
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
|
|
||||||
|
# eewimg.cn CDN 可解析的子域名(66 子域名在海外/部分网络 NXDOMAIN)
|
||||||
|
_EEWIMG_FALLBACK_SUBDOMAINS = ["8", "2", "10", "11", "12", "3", "1"]
|
||||||
|
|
||||||
|
|
||||||
|
def _try_eewimg_cdn_fallback(url: str, temp_dir: str) -> tuple:
|
||||||
|
"""尝试用其他 eewimg.cn 子域名下载图片"""
|
||||||
|
# 去掉 imageView2 等图片处理参数,获取原始图片
|
||||||
|
clean_url = re.sub(r'\?.*$', '', url)
|
||||||
|
|
||||||
|
for sub in _EEWIMG_FALLBACK_SUBDOMAINS:
|
||||||
|
try:
|
||||||
|
# 替换子域名(数字开头的子域名)
|
||||||
|
fallback_url = re.sub(r'https?://\d+\.eewimg\.cn', f'https://{sub}.eewimg.cn', clean_url)
|
||||||
|
if fallback_url == clean_url:
|
||||||
|
continue # 没替换成功,跳过
|
||||||
|
|
||||||
|
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': 'image/avif,image/webp,image/apng,image/*,*/*;q=0.8',
|
||||||
|
'Referer': 'https://www.eeworld.com.cn/',
|
||||||
|
}
|
||||||
|
response = requests.get(fallback_url, headers=headers, timeout=15, stream=True)
|
||||||
|
response.raise_for_status()
|
||||||
|
|
||||||
|
content_type = response.headers.get('content-type', '')
|
||||||
|
ext = '.jpg'
|
||||||
|
if 'png' in content_type:
|
||||||
|
ext = '.png'
|
||||||
|
elif 'gif' in content_type:
|
||||||
|
ext = '.gif'
|
||||||
|
elif 'webp' in content_type:
|
||||||
|
ext = '.webp'
|
||||||
|
else:
|
||||||
|
parsed = urlparse(url)
|
||||||
|
path_ext = os.path.splitext(parsed.path)[1]
|
||||||
|
if path_ext:
|
||||||
|
ext = path_ext
|
||||||
|
|
||||||
|
filename = f"crawl_{datetime.now().strftime('%Y%m%d_%H%M%S')}_{uuid.uuid4().hex[:8]}{ext}"
|
||||||
|
filepath = os.path.join(temp_dir, filename)
|
||||||
|
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
for chunk in response.iter_content(chunk_size=8192):
|
||||||
|
f.write(chunk)
|
||||||
|
|
||||||
|
print(f"[CDN fallback] 成功通过 {sub}.eewimg.cn 下载: {url}")
|
||||||
|
return filename, filepath, True
|
||||||
|
except Exception as e:
|
||||||
|
continue
|
||||||
|
|
||||||
|
return None, None, False
|
||||||
|
|
||||||
|
|
||||||
def is_spa_site(html: str) -> bool:
|
def is_spa_site(html: str) -> bool:
|
||||||
|
|||||||
@@ -40,6 +40,20 @@ from .crawl import (
|
|||||||
CrawlImage,
|
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 .images import generate_thumbnail, get_image_dimensions
|
||||||
|
|
||||||
@@ -71,6 +85,7 @@ def _needs_browser(url: str) -> bool:
|
|||||||
'infoq.cn',
|
'infoq.cn',
|
||||||
'infoq.com',
|
'infoq.com',
|
||||||
'51cto.com',
|
'51cto.com',
|
||||||
|
'eeworld.com.cn', # JS WAF 防护,必须用浏览器渲染
|
||||||
]
|
]
|
||||||
return any(d in domain for d in browser_domains)
|
return any(d in domain for d in browser_domains)
|
||||||
|
|
||||||
@@ -466,29 +481,19 @@ async def universal_fetch(request: UniversalCrawlRequest):
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/save")
|
@router.post("/save")
|
||||||
async def universal_save(
|
async def universal_save(request: CrawlSaveRequest, db: Session = Depends(get_db)):
|
||||||
title: str,
|
"""保存通用采集的文章,下载图片到本地并替换内容中的远程 URL"""
|
||||||
content: str,
|
|
||||||
excerpt: Optional[str] = None,
|
|
||||||
category_id: Optional[int] = None,
|
|
||||||
source_url: Optional[str] = None,
|
|
||||||
images: Optional[str] = None,
|
|
||||||
db: Session = Depends(get_db)
|
|
||||||
):
|
|
||||||
"""保存通用采集的文章(复用 crawl.py 的保存逻辑)"""
|
|
||||||
try:
|
try:
|
||||||
print(f"[Universal Crawl] Saving article: {title}")
|
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)}")
|
||||||
image_list = []
|
|
||||||
if images:
|
|
||||||
try:
|
|
||||||
import json
|
|
||||||
image_data = json.loads(images)
|
|
||||||
for img in image_data:
|
|
||||||
image_list.append(CrawlImage(**img))
|
|
||||||
except Exception as e:
|
|
||||||
print(f"[Universal Crawl] Parse images error: {e}")
|
|
||||||
|
|
||||||
# 创建临时目录
|
# 创建临时目录
|
||||||
temp_dir = tempfile.mkdtemp(dir=TEMP_DIR)
|
temp_dir = tempfile.mkdtemp(dir=TEMP_DIR)
|
||||||
@@ -499,12 +504,17 @@ async def universal_save(
|
|||||||
|
|
||||||
# 下载图片
|
# 下载图片
|
||||||
downloaded_images = []
|
downloaded_images = []
|
||||||
|
images_saved = 0
|
||||||
|
images_failed = 0
|
||||||
for img in image_list:
|
for img in image_list:
|
||||||
try:
|
try:
|
||||||
filename, filepath, success = download_image(str(img.url), temp_dir)
|
img_url = str(img.original_url)
|
||||||
|
if not img_url:
|
||||||
|
continue
|
||||||
|
filename, filepath, success = download_image(img_url, temp_dir)
|
||||||
if success and filepath:
|
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)[1]}"
|
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
|
final_path = final_dir / final_filename
|
||||||
os.rename(filepath, str(final_path))
|
os.rename(filepath, str(final_path))
|
||||||
|
|
||||||
@@ -521,7 +531,7 @@ async def universal_save(
|
|||||||
# 保存到数据库
|
# 保存到数据库
|
||||||
db_image = Image(
|
db_image = Image(
|
||||||
filename=final_filename,
|
filename=final_filename,
|
||||||
original_filename=img.alt or final_filename,
|
original_filename=final_filename,
|
||||||
file_path=f"/uploads/images/{final_filename}",
|
file_path=f"/uploads/images/{final_filename}",
|
||||||
file_size=os.path.getsize(str(final_path)),
|
file_size=os.path.getsize(str(final_path)),
|
||||||
mime_type=f"image/{os.path.splitext(final_filename)[1].lstrip('.') or 'jpeg'}",
|
mime_type=f"image/{os.path.splitext(final_filename)[1].lstrip('.') or 'jpeg'}",
|
||||||
@@ -533,9 +543,9 @@ async def universal_save(
|
|||||||
db.flush()
|
db.flush()
|
||||||
|
|
||||||
# 替换内容中的图片 URL
|
# 替换内容中的图片 URL
|
||||||
original_url = str(img.url)
|
|
||||||
new_url = f"/uploads/images/{final_filename}"
|
new_url = f"/uploads/images/{final_filename}"
|
||||||
content = content.replace(original_url, new_url)
|
content = content.replace(img_url, new_url)
|
||||||
|
images_saved += 1
|
||||||
|
|
||||||
downloaded_images.append({
|
downloaded_images.append({
|
||||||
"id": db_image.id,
|
"id": db_image.id,
|
||||||
@@ -543,25 +553,19 @@ async def universal_save(
|
|||||||
"thumb": thumb_path_db,
|
"thumb": thumb_path_db,
|
||||||
})
|
})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
images_failed += 1
|
||||||
print(f"[Universal Crawl] Download image error: {e}")
|
print(f"[Universal Crawl] Download image error: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 清理 excerpt
|
# 生成摘要
|
||||||
if excerpt:
|
summary = excerpt if excerpt else content[:300] if len(content) > 300 else content
|
||||||
excerpt = re.sub(r'<[^>]+>', '', excerpt)
|
summary = re.sub(r'[#*`\[\]\(\)!]', '', summary).strip()[:300]
|
||||||
excerpt = excerpt.replace('\n', ' ').strip()[:200]
|
|
||||||
else:
|
|
||||||
excerpt = content.replace('\n', ' ').strip()[:200]
|
|
||||||
|
|
||||||
# 创建文章
|
# 创建文章
|
||||||
now = datetime.now()
|
now = datetime.now()
|
||||||
slug = re.sub(r'[^\w\u4e00-\u9fff-]', '-', title)[:50].strip('-')
|
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]}"
|
slug = f"{slug}-{now.strftime('%Y%m%d%H%M%S')}-{uuid.uuid4().hex[:4]}"
|
||||||
|
|
||||||
# 生成摘要
|
|
||||||
summary = content[:300] if len(content) > 300 else content
|
|
||||||
summary = re.sub(r'[#*`\[\]\(\)!]', '', summary)
|
|
||||||
|
|
||||||
# 查找或创建分类
|
# 查找或创建分类
|
||||||
final_category_id = category_id
|
final_category_id = category_id
|
||||||
if not final_category_id:
|
if not final_category_id:
|
||||||
@@ -576,28 +580,42 @@ async def universal_save(
|
|||||||
title=title,
|
title=title,
|
||||||
slug=slug,
|
slug=slug,
|
||||||
content=content,
|
content=content,
|
||||||
excerpt=excerpt,
|
content_type="markdown",
|
||||||
summary=summary,
|
summary=summary,
|
||||||
category_id=final_category_id,
|
category_id=final_category_id,
|
||||||
author_id=1,
|
is_published=is_published,
|
||||||
status="draft",
|
is_draft=is_draft,
|
||||||
view_count=0,
|
view_count=0,
|
||||||
is_original=False,
|
|
||||||
source_url=source_url,
|
|
||||||
created_at=now,
|
created_at=now,
|
||||||
updated_at=now,
|
updated_at=now,
|
||||||
published_at=None,
|
|
||||||
)
|
)
|
||||||
db.add(post)
|
db.add(post)
|
||||||
db.flush()
|
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()
|
db.commit()
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"status": "success",
|
"success": True,
|
||||||
"post_id": post.id,
|
"post_id": post.id,
|
||||||
|
"slug": slug,
|
||||||
"message": "文章保存成功",
|
"message": "文章保存成功",
|
||||||
"images": downloaded_images,
|
"images_saved": images_saved,
|
||||||
|
"images_failed": images_failed,
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 347 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 159 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 218 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 150 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 224 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 184 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
After Width: | Height: | Size: 169 KiB |
|
After Width: | Height: | Size: 250 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 154 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 39 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 52 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 29 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 18 KiB |
@@ -6,7 +6,6 @@ import { authApi } from '../api/auth'
|
|||||||
import { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter } from '../components-preline/Card'
|
import { Card, CardHeader, CardTitle, CardDescription, CardBody, CardFooter } from '../components-preline/Card'
|
||||||
import { Button } from '../components-preline/Button'
|
import { Button } from '../components-preline/Button'
|
||||||
import { Input, Textarea, Label, FormError } from '../components-preline/Input'
|
import { Input, Textarea, Label, FormError } from '../components-preline/Input'
|
||||||
import { postsApi } from '../api/posts'
|
|
||||||
import { categoriesApi } from '../api/categories'
|
import { categoriesApi } from '../api/categories'
|
||||||
import { tagsApi } from '../api/tags'
|
import { tagsApi } from '../api/tags'
|
||||||
import { API_BASE_URL } from '../api/config'
|
import { API_BASE_URL } from '../api/config'
|
||||||
@@ -208,17 +207,47 @@ export function UniversalCrawlEdit() {
|
|||||||
|
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
try {
|
try {
|
||||||
const data = {
|
const token = localStorage.getItem('token')
|
||||||
...form,
|
// 将 tag_ids 转换为 tag 名称数组
|
||||||
category_id: form.category_id || null,
|
const tagNames = form.tag_ids.map(tid => {
|
||||||
is_draft: isDraft,
|
const tag = tags.find(t => t.id === Number(tid))
|
||||||
is_published: !isDraft
|
return tag ? tag.name : ''
|
||||||
|
}).filter(Boolean)
|
||||||
|
|
||||||
|
const response = await fetch(`${API_BASE_URL}/universal-crawl/save`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token ? { 'Authorization': `Bearer ${token}` } : {})
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
title: form.title,
|
||||||
|
content: form.content,
|
||||||
|
content_html: '',
|
||||||
|
images: crawledImages,
|
||||||
|
summary: form.summary,
|
||||||
|
category_id: form.category_id || null,
|
||||||
|
is_draft: isDraft,
|
||||||
|
is_published: !isDraft,
|
||||||
|
tags: tagNames,
|
||||||
|
source_url: crawlUrl
|
||||||
|
}),
|
||||||
|
credentials: 'include'
|
||||||
|
})
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (!data.success) {
|
||||||
|
throw new Error(data.detail || data.message || '保存失败')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提示图片下载情况
|
||||||
|
if (data.images_failed > 0) {
|
||||||
|
alert(`文章已保存成功!\n图片:${data.images_saved} 张下载成功,${data.images_failed} 张下载失败(保留原始链接)。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
await postsApi.createPost(data)
|
|
||||||
navigate('/admin/posts')
|
navigate('/admin/posts')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
alert('保存失败: ' + (error.response?.data?.detail || '未知错误'))
|
alert('保存失败: ' + (error.message || error.response?.data?.detail || '未知错误'))
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false)
|
setSaving(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
你是一位专业的信息图设计师。请根据用户提供的内容生成一张人物主题信息图。
|
||||||
|
|
||||||
|
设计原则:
|
||||||
|
- 整体色调:深蓝、藏青、金色渐变,体现庄重、专业、肃穆的氛围
|
||||||
|
- 主体:一位经济学家形象的剪影/侧影,与数据图表、趋势线等宏观分析元素融合
|
||||||
|
- 文字:可包含标题"高善文"以及关键词如"宏观研究""资产重估""因果推断"
|
||||||
|
- 风格:严肃、专业、有纪念意义
|
||||||
|
- 布局:人物肖像位于左侧或中央,右侧/周围环绕经济数据图表元素
|
||||||
|
- 避免过于具体的数字和统计数据,使用抽象概念表达
|
||||||
@@ -273,6 +273,7 @@ python3 baoyu_with_image_gen.py markdown /home/ubuntu/test_ai_images.md --genera
|
|||||||
5. **凭证安全**:API凭证已加密存储
|
5. **凭证安全**:API凭证已加密存储
|
||||||
6. **AI图片生成**:使用豆包Seedream API,需要网络连接,生成时间约30-60秒/张
|
6. **AI图片生成**:使用豆包Seedream API,需要网络连接,生成时间约30-60秒/张
|
||||||
7. **Frontmatter格式**:使用标准YAML格式,注意缩进
|
7. **Frontmatter格式**:使用标准YAML格式,注意缩进
|
||||||
|
8. **API 48001 限制**:微信API仅支持创建草稿,不支持自动发布。所有公众号文章需在微信后台手动发布。博客同步不受此限制。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||