Auto backup \2026-\04-\20

This commit is contained in:
Ubuntu
2026-07-08 16:00:02 +08:00
parent d285680356
commit 065fde366a
73 changed files with 174 additions and 57 deletions
+61 -1
View File
@@ -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:
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 347 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 218 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 250 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 154 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

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 @@
你是一位专业的信息图设计师。请根据用户提供的内容生成一张人物主题信息图。
设计原则:
- 整体色调:深蓝、藏青、金色渐变,体现庄重、专业、肃穆的氛围
- 主体:一位经济学家形象的剪影/侧影,与数据图表、趋势线等宏观分析元素融合
- 文字:可包含标题"高善文"以及关键词如"宏观研究""资产重估""因果推断"
- 风格:严肃、专业、有纪念意义
- 布局:人物肖像位于左侧或中央,右侧/周围环绕经济数据图表元素
- 避免过于具体的数字和统计数据,使用抽象概念表达
+1
View File
@@ -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仅支持创建草稿,不支持自动发布。所有公众号文章需在微信后台手动发布。博客同步不受此限制。
---