Auto backup \2026-\04-\20
@@ -675,7 +675,67 @@ def download_image(url: str, temp_dir: str) -> tuple:
|
||||
return filename, filepath, True
|
||||
except Exception as 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:
|
||||
|
||||
@@ -40,6 +40,20 @@ from .crawl import (
|
||||
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
|
||||
|
||||
@@ -71,6 +85,7 @@ def _needs_browser(url: str) -> bool:
|
||||
'infoq.cn',
|
||||
'infoq.com',
|
||||
'51cto.com',
|
||||
'eeworld.com.cn', # JS WAF 防护,必须用浏览器渲染
|
||||
]
|
||||
return any(d in domain for d in browser_domains)
|
||||
|
||||
@@ -466,29 +481,19 @@ async def universal_fetch(request: UniversalCrawlRequest):
|
||||
|
||||
|
||||
@router.post("/save")
|
||||
async def universal_save(
|
||||
title: str,
|
||||
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 的保存逻辑)"""
|
||||
async def universal_save(request: CrawlSaveRequest, db: Session = Depends(get_db)):
|
||||
"""保存通用采集的文章,下载图片到本地并替换内容中的远程 URL"""
|
||||
try:
|
||||
print(f"[Universal Crawl] Saving article: {title}")
|
||||
|
||||
# 解析图片列表
|
||||
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}")
|
||||
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)
|
||||
@@ -499,12 +504,17 @@ async def universal_save(
|
||||
|
||||
# 下载图片
|
||||
downloaded_images = []
|
||||
images_saved = 0
|
||||
images_failed = 0
|
||||
for img in image_list:
|
||||
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:
|
||||
# 移动最终文件
|
||||
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
|
||||
os.rename(filepath, str(final_path))
|
||||
|
||||
@@ -521,7 +531,7 @@ async def universal_save(
|
||||
# 保存到数据库
|
||||
db_image = Image(
|
||||
filename=final_filename,
|
||||
original_filename=img.alt or 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'}",
|
||||
@@ -533,9 +543,9 @@ async def universal_save(
|
||||
db.flush()
|
||||
|
||||
# 替换内容中的图片 URL
|
||||
original_url = str(img.url)
|
||||
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({
|
||||
"id": db_image.id,
|
||||
@@ -543,25 +553,19 @@ async def universal_save(
|
||||
"thumb": thumb_path_db,
|
||||
})
|
||||
except Exception as e:
|
||||
images_failed += 1
|
||||
print(f"[Universal Crawl] Download image error: {e}")
|
||||
continue
|
||||
|
||||
# 清理 excerpt
|
||||
if excerpt:
|
||||
excerpt = re.sub(r'<[^>]+>', '', excerpt)
|
||||
excerpt = excerpt.replace('\n', ' ').strip()[:200]
|
||||
else:
|
||||
excerpt = content.replace('\n', ' ').strip()[:200]
|
||||
# 生成摘要
|
||||
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]}"
|
||||
|
||||
# 生成摘要
|
||||
summary = content[:300] if len(content) > 300 else content
|
||||
summary = re.sub(r'[#*`\[\]\(\)!]', '', summary)
|
||||
|
||||
# 查找或创建分类
|
||||
final_category_id = category_id
|
||||
if not final_category_id:
|
||||
@@ -576,28 +580,42 @@ async def universal_save(
|
||||
title=title,
|
||||
slug=slug,
|
||||
content=content,
|
||||
excerpt=excerpt,
|
||||
content_type="markdown",
|
||||
summary=summary,
|
||||
category_id=final_category_id,
|
||||
author_id=1,
|
||||
status="draft",
|
||||
is_published=is_published,
|
||||
is_draft=is_draft,
|
||||
view_count=0,
|
||||
is_original=False,
|
||||
source_url=source_url,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
published_at=None,
|
||||
)
|
||||
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 {
|
||||
"status": "success",
|
||||
"success": True,
|
||||
"post_id": post.id,
|
||||
"slug": slug,
|
||||
"message": "文章保存成功",
|
||||
"images": downloaded_images,
|
||||
"images_saved": images_saved,
|
||||
"images_failed": images_failed,
|
||||
}
|
||||
|
||||
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 { Button } from '../components-preline/Button'
|
||||
import { Input, Textarea, Label, FormError } from '../components-preline/Input'
|
||||
import { postsApi } from '../api/posts'
|
||||
import { categoriesApi } from '../api/categories'
|
||||
import { tagsApi } from '../api/tags'
|
||||
import { API_BASE_URL } from '../api/config'
|
||||
@@ -200,25 +199,55 @@ export function UniversalCrawlEdit() {
|
||||
// 保存文章
|
||||
const handleSubmit = async (e, isDraft = false) => {
|
||||
e.preventDefault()
|
||||
|
||||
|
||||
if (!form.title.trim() || !form.content.trim()) {
|
||||
alert('请填写标题和内容')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
setSaving(true)
|
||||
try {
|
||||
const data = {
|
||||
...form,
|
||||
category_id: form.category_id || null,
|
||||
is_draft: isDraft,
|
||||
is_published: !isDraft
|
||||
const token = localStorage.getItem('token')
|
||||
// 将 tag_ids 转换为 tag 名称数组
|
||||
const tagNames = form.tag_ids.map(tid => {
|
||||
const tag = tags.find(t => t.id === Number(tid))
|
||||
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 || '保存失败')
|
||||
}
|
||||
|
||||
await postsApi.createPost(data)
|
||||
|
||||
// 提示图片下载情况
|
||||
if (data.images_failed > 0) {
|
||||
alert(`文章已保存成功!\n图片:${data.images_saved} 张下载成功,${data.images_failed} 张下载失败(保留原始链接)。`)
|
||||
}
|
||||
|
||||
navigate('/admin/posts')
|
||||
} catch (error) {
|
||||
alert('保存失败: ' + (error.response?.data?.detail || '未知错误'))
|
||||
alert('保存失败: ' + (error.message || error.response?.data?.detail || '未知错误'))
|
||||
} finally {
|
||||
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凭证已加密存储
|
||||
6. **AI图片生成**:使用豆包Seedream API,需要网络连接,生成时间约30-60秒/张
|
||||
7. **Frontmatter格式**:使用标准YAML格式,注意缩进
|
||||
8. **API 48001 限制**:微信API仅支持创建草稿,不支持自动发布。所有公众号文章需在微信后台手动发布。博客同步不受此限制。
|
||||
|
||||
---
|
||||
|
||||
|
||||