release: v2.2.0 - 博查新闻搜索功能 (生产环境部署版)
核心功能: - 集成博查Web Search API自动获取网站相关新闻 - 智能新闻更新机制(每日首次访问触发) - 精确新闻搜索(使用引号强制匹配网站名称) - News模型扩展(source_name, source_icon字段) - 网站详情页新闻展示模块 - 新闻来源网站信息展示 - 自动去重防止重复新闻 技术实现: - NewsSearcher工具类封装博查API - 数据库迁移脚本migrate_news_fields.py - 测试脚本test_news_feature.py - 定期任务脚本fetch_news_cron.py - API路由:/api/fetch-site-news, /api/fetch-all-news 配置优化: - 修复manage.sh路径和启动命令 - 博查API配置(BOCHA_API_KEY, BOCHA_BASE_URL) - 新闻搜索参数配置 界面优化: - 详情页新闻模块(左侧主栏) - 相似推荐模块(右侧边栏) - 首页标签图标修复 - 后台添加修改密码功能 - 登录页面优化 部署信息: - 部署日期: 2025-12-30 - 部署方式: 手动上传文件 - 数据库: 已迁移(添加source_name和source_icon字段)
This commit is contained in:
278
app.py
278
app.py
@@ -9,6 +9,7 @@ from config import config
|
||||
from models import db, Site, Tag, Admin as AdminModel, News, site_tags, PromptTemplate
|
||||
from utils.website_fetcher import WebsiteFetcher
|
||||
from utils.tag_generator import TagGenerator
|
||||
from utils.news_searcher import NewsSearcher
|
||||
|
||||
def create_app(config_name='default'):
|
||||
"""应用工厂函数"""
|
||||
@@ -115,6 +116,70 @@ def create_app(config_name='default'):
|
||||
site.view_count += 1
|
||||
db.session.commit()
|
||||
|
||||
# 智能新闻更新:检查今天是否已更新过新闻
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
|
||||
# 检查该网站最新一条新闻的创建时间
|
||||
latest_news = News.query.filter_by(
|
||||
site_id=site.id
|
||||
).order_by(News.created_at.desc()).first()
|
||||
|
||||
# 判断是否需要更新新闻
|
||||
need_update = False
|
||||
if not latest_news:
|
||||
# 没有任何新闻,需要获取
|
||||
need_update = True
|
||||
elif latest_news.created_at.date() < today:
|
||||
# 最新新闻不是今天创建的,需要更新
|
||||
need_update = True
|
||||
|
||||
# 如果需要更新,自动获取最新新闻
|
||||
if need_update:
|
||||
api_key = app.config.get('BOCHA_API_KEY')
|
||||
if api_key:
|
||||
try:
|
||||
# 创建新闻搜索器
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 获取新闻(限制3条,一周内的)
|
||||
news_items = searcher.search_site_news(
|
||||
site_name=site.name,
|
||||
site_url=site.url,
|
||||
count=3,
|
||||
freshness='oneWeek'
|
||||
)
|
||||
|
||||
# 保存新闻到数据库
|
||||
if news_items:
|
||||
for item in news_items:
|
||||
# 检查是否已存在(根据URL去重)
|
||||
existing = News.query.filter_by(
|
||||
site_id=site.id,
|
||||
url=item['url']
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
news = News(
|
||||
site_id=site.id,
|
||||
title=item['title'],
|
||||
content=item.get('summary') or item.get('snippet', ''),
|
||||
url=item['url'],
|
||||
source_name=item.get('site_name', ''),
|
||||
source_icon=item.get('site_icon', ''),
|
||||
published_at=item.get('published_at'),
|
||||
news_type='Search Result',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(news)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
except Exception as e:
|
||||
# 获取新闻失败,不影响页面显示
|
||||
print(f"自动获取新闻失败:{str(e)}")
|
||||
db.session.rollback()
|
||||
|
||||
# 获取该网站的相关新闻(最多显示5条)
|
||||
news_list = News.query.filter_by(
|
||||
site_id=site.id,
|
||||
@@ -442,6 +507,205 @@ def create_app(config_name='default'):
|
||||
'message': f'生成失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# ========== 新闻获取路由 ==========
|
||||
@app.route('/api/fetch-site-news', methods=['POST'])
|
||||
@login_required
|
||||
def fetch_site_news():
|
||||
"""为指定网站获取最新新闻"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
site_id = data.get('site_id')
|
||||
count = data.get('count', app.config.get('NEWS_SEARCH_COUNT', 10))
|
||||
freshness = data.get('freshness', app.config.get('NEWS_SEARCH_FRESHNESS', 'oneMonth'))
|
||||
|
||||
if not site_id:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '请提供网站ID'
|
||||
}), 400
|
||||
|
||||
# 获取网站信息
|
||||
site = Site.query.get(site_id)
|
||||
if not site:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '网站不存在'
|
||||
}), 404
|
||||
|
||||
# 检查博查API配置
|
||||
api_key = app.config.get('BOCHA_API_KEY')
|
||||
if not api_key:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '博查API未配置,请在.env文件中设置BOCHA_API_KEY'
|
||||
}), 500
|
||||
|
||||
# 创建新闻搜索器
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 搜索新闻
|
||||
news_items = searcher.search_site_news(
|
||||
site_name=site.name,
|
||||
site_url=site.url,
|
||||
count=count,
|
||||
freshness=freshness
|
||||
)
|
||||
|
||||
if not news_items:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '未找到相关新闻'
|
||||
}), 404
|
||||
|
||||
# 保存新闻到数据库
|
||||
saved_count = 0
|
||||
for item in news_items:
|
||||
# 检查新闻是否已存在(根据URL判断)
|
||||
existing_news = News.query.filter_by(
|
||||
site_id=site_id,
|
||||
url=item['url']
|
||||
).first()
|
||||
|
||||
if not existing_news:
|
||||
# 创建新闻记录
|
||||
news = News(
|
||||
site_id=site_id,
|
||||
title=item['title'],
|
||||
content=item.get('summary') or item.get('snippet', ''),
|
||||
url=item['url'],
|
||||
source_name=item.get('site_name', ''),
|
||||
source_icon=item.get('site_icon', ''),
|
||||
published_at=item.get('published_at'),
|
||||
news_type='Search Result',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(news)
|
||||
saved_count += 1
|
||||
|
||||
# 提交事务
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'成功获取并保存 {saved_count} 条新闻',
|
||||
'total_found': len(news_items),
|
||||
'saved': saved_count,
|
||||
'news_items': searcher.format_news_for_display(news_items)
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'获取失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
@app.route('/api/fetch-all-news', methods=['POST'])
|
||||
@login_required
|
||||
def fetch_all_news():
|
||||
"""批量为所有网站获取新闻"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
count_per_site = data.get('count', 5) # 每个网站获取的新闻数量
|
||||
freshness = data.get('freshness', app.config.get('NEWS_SEARCH_FRESHNESS', 'oneMonth'))
|
||||
limit = data.get('limit', 10) # 限制处理的网站数量
|
||||
|
||||
# 检查博查API配置
|
||||
api_key = app.config.get('BOCHA_API_KEY')
|
||||
if not api_key:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '博查API未配置,请在.env文件中设置BOCHA_API_KEY'
|
||||
}), 500
|
||||
|
||||
# 获取启用的网站(按更新时间排序,优先处理旧的)
|
||||
sites = Site.query.filter_by(is_active=True).order_by(Site.updated_at).limit(limit).all()
|
||||
|
||||
if not sites:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': '没有可用的网站'
|
||||
}), 404
|
||||
|
||||
# 创建新闻搜索器
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 统计信息
|
||||
total_saved = 0
|
||||
total_found = 0
|
||||
processed_sites = []
|
||||
|
||||
# 为每个网站获取新闻
|
||||
for site in sites:
|
||||
try:
|
||||
# 搜索新闻
|
||||
news_items = searcher.search_site_news(
|
||||
site_name=site.name,
|
||||
site_url=site.url,
|
||||
count=count_per_site,
|
||||
freshness=freshness
|
||||
)
|
||||
|
||||
site_saved = 0
|
||||
for item in news_items:
|
||||
# 检查是否已存在
|
||||
existing_news = News.query.filter_by(
|
||||
site_id=site.id,
|
||||
url=item['url']
|
||||
).first()
|
||||
|
||||
if not existing_news:
|
||||
news = News(
|
||||
site_id=site.id,
|
||||
title=item['title'],
|
||||
content=item.get('summary') or item.get('snippet', ''),
|
||||
url=item['url'],
|
||||
source_name=item.get('site_name', ''),
|
||||
source_icon=item.get('site_icon', ''),
|
||||
published_at=item.get('published_at'),
|
||||
news_type='Search Result',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(news)
|
||||
site_saved += 1
|
||||
|
||||
total_found += len(news_items)
|
||||
total_saved += site_saved
|
||||
|
||||
processed_sites.append({
|
||||
'id': site.id,
|
||||
'name': site.name,
|
||||
'found': len(news_items),
|
||||
'saved': site_saved
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
# 单个网站失败不影响其他网站
|
||||
processed_sites.append({
|
||||
'id': site.id,
|
||||
'name': site.name,
|
||||
'error': str(e)
|
||||
})
|
||||
continue
|
||||
|
||||
# 提交事务
|
||||
db.session.commit()
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': f'批量获取完成,共处理 {len(processed_sites)} 个网站',
|
||||
'total_found': total_found,
|
||||
'total_saved': total_saved,
|
||||
'processed_sites': processed_sites
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': f'批量获取失败: {str(e)}'
|
||||
}), 500
|
||||
|
||||
# ========== 批量导入路由 ==========
|
||||
@app.route('/admin/batch-import', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -908,9 +1172,9 @@ def create_app(config_name='default'):
|
||||
# 显示操作列
|
||||
column_display_actions = True
|
||||
|
||||
column_list = ['id', 'site', 'title', 'news_type', 'published_at', 'is_active']
|
||||
column_searchable_list = ['title', 'content']
|
||||
column_filters = ['site', 'news_type', 'is_active', 'published_at']
|
||||
column_list = ['id', 'site', 'title', 'source_name', 'news_type', 'published_at', 'is_active']
|
||||
column_searchable_list = ['title', 'content', 'source_name']
|
||||
column_filters = ['site', 'news_type', 'source_name', 'is_active', 'published_at']
|
||||
column_labels = {
|
||||
'id': 'ID',
|
||||
'site': '关联网站',
|
||||
@@ -918,16 +1182,19 @@ def create_app(config_name='default'):
|
||||
'content': '新闻内容',
|
||||
'news_type': '新闻类型',
|
||||
'url': '新闻链接',
|
||||
'source_name': '来源网站',
|
||||
'source_icon': '来源图标',
|
||||
'published_at': '发布时间',
|
||||
'is_active': '是否启用',
|
||||
'created_at': '创建时间',
|
||||
'updated_at': '更新时间'
|
||||
}
|
||||
form_columns = ['site', 'title', 'content', 'news_type', 'url', 'published_at', 'is_active']
|
||||
form_columns = ['site', 'title', 'content', 'news_type', 'url', 'source_name', 'source_icon', 'published_at', 'is_active']
|
||||
|
||||
# 可选的新闻类型
|
||||
form_choices = {
|
||||
'news_type': [
|
||||
('Search Result', 'Search Result'),
|
||||
('Product Update', 'Product Update'),
|
||||
('Industry News', 'Industry News'),
|
||||
('Company News', 'Company News'),
|
||||
@@ -935,6 +1202,9 @@ def create_app(config_name='default'):
|
||||
]
|
||||
}
|
||||
|
||||
# 默认排序
|
||||
column_default_sort = ('published_at', True) # 按发布时间倒序排列
|
||||
|
||||
# Prompt模板管理视图
|
||||
class PromptAdmin(SecureModelView):
|
||||
can_edit = True
|
||||
|
||||
14
config.py
14
config.py
@@ -42,6 +42,20 @@ class Config:
|
||||
MAX_CONTENT_LENGTH = 5 * 1024 * 1024 # 5MB
|
||||
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp'}
|
||||
|
||||
# DeepSeek API配置
|
||||
DEEPSEEK_API_KEY = os.environ.get('DEEPSEEK_API_KEY')
|
||||
DEEPSEEK_BASE_URL = os.environ.get('DEEPSEEK_BASE_URL') or 'https://api.deepseek.com'
|
||||
|
||||
# 博查 Web Search API配置
|
||||
BOCHA_API_KEY = os.environ.get('BOCHA_API_KEY')
|
||||
BOCHA_BASE_URL = os.environ.get('BOCHA_BASE_URL') or 'https://api.bocha.cn'
|
||||
BOCHA_SEARCH_ENDPOINT = '/v1/web-search'
|
||||
|
||||
# 新闻搜索配置
|
||||
NEWS_SEARCH_COUNT = 10 # 每次搜索返回的新闻数量
|
||||
NEWS_SEARCH_FRESHNESS = 'oneMonth' # 默认搜索一个月内的新闻
|
||||
NEWS_SEARCH_SUMMARY = True # 是否显示摘要
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
"""开发环境配置"""
|
||||
DEBUG = True
|
||||
|
||||
167
fetch_news_cron.py
Normal file
167
fetch_news_cron.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
定期新闻获取任务脚本
|
||||
用途:定期为网站批量获取最新新闻
|
||||
使用:python fetch_news_cron.py [options]
|
||||
|
||||
可以通过crontab定时执行:
|
||||
# 每天早上8点执行,获取10个网站的新闻
|
||||
0 8 * * * cd /path/to/zjpb && /path/to/venv/bin/python fetch_news_cron.py --limit 10 >> logs/news_fetch.log 2>&1
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
# 添加项目根目录到Python路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import create_app
|
||||
from models import db, Site, News
|
||||
from utils.news_searcher import NewsSearcher
|
||||
|
||||
|
||||
def fetch_news_for_sites(limit=10, count_per_site=5, freshness='oneMonth'):
|
||||
"""
|
||||
批量为网站获取新闻
|
||||
|
||||
Args:
|
||||
limit: 处理的网站数量限制
|
||||
count_per_site: 每个网站获取的新闻数量
|
||||
freshness: 新闻时间范围
|
||||
"""
|
||||
# 创建Flask应用上下文
|
||||
app = create_app(os.getenv('FLASK_ENV', 'production'))
|
||||
|
||||
with app.app_context():
|
||||
# 检查博查API配置
|
||||
api_key = app.config.get('BOCHA_API_KEY')
|
||||
if not api_key:
|
||||
print(f"[{datetime.now()}] 错误:未配置BOCHA_API_KEY")
|
||||
return False
|
||||
|
||||
# 获取启用的网站(按更新时间排序,优先处理旧的)
|
||||
sites = Site.query.filter_by(is_active=True).order_by(Site.updated_at).limit(limit).all()
|
||||
|
||||
if not sites:
|
||||
print(f"[{datetime.now()}] 没有可处理的网站")
|
||||
return False
|
||||
|
||||
print(f"[{datetime.now()}] 开始批量获取新闻,共 {len(sites)} 个网站")
|
||||
print(f"配置:每个网站 {count_per_site} 条新闻,时间范围:{freshness}")
|
||||
print("-" * 60)
|
||||
|
||||
# 创建新闻搜索器
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 统计信息
|
||||
total_saved = 0
|
||||
total_found = 0
|
||||
success_count = 0
|
||||
error_count = 0
|
||||
|
||||
# 为每个网站获取新闻
|
||||
for i, site in enumerate(sites, 1):
|
||||
print(f"[{i}/{len(sites)}] 处理网站: {site.name}")
|
||||
|
||||
try:
|
||||
# 搜索新闻
|
||||
news_items = searcher.search_site_news(
|
||||
site_name=site.name,
|
||||
site_url=site.url,
|
||||
count=count_per_site,
|
||||
freshness=freshness
|
||||
)
|
||||
|
||||
if not news_items:
|
||||
print(f" └─ 未找到新闻")
|
||||
continue
|
||||
|
||||
site_saved = 0
|
||||
for item in news_items:
|
||||
# 检查是否已存在
|
||||
existing_news = News.query.filter_by(
|
||||
site_id=site.id,
|
||||
url=item['url']
|
||||
).first()
|
||||
|
||||
if not existing_news:
|
||||
news = News(
|
||||
site_id=site.id,
|
||||
title=item['title'],
|
||||
content=item.get('summary') or item.get('snippet', ''),
|
||||
url=item['url'],
|
||||
source_name=item.get('site_name', ''),
|
||||
source_icon=item.get('site_icon', ''),
|
||||
published_at=item.get('published_at'),
|
||||
news_type='Search Result',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(news)
|
||||
site_saved += 1
|
||||
|
||||
# 提交该网站的新闻
|
||||
db.session.commit()
|
||||
|
||||
total_found += len(news_items)
|
||||
total_saved += site_saved
|
||||
success_count += 1
|
||||
|
||||
print(f" └─ 找到 {len(news_items)} 条,保存 {site_saved} 条新闻")
|
||||
|
||||
except Exception as e:
|
||||
error_count += 1
|
||||
print(f" └─ 错误: {str(e)}")
|
||||
db.session.rollback()
|
||||
continue
|
||||
|
||||
print("-" * 60)
|
||||
print(f"[{datetime.now()}] 批量获取完成")
|
||||
print(f"成功: {success_count} 个网站, 失败: {error_count} 个网站")
|
||||
print(f"共找到 {total_found} 条新闻,保存 {total_saved} 条新新闻")
|
||||
print("=" * 60)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
"""主函数"""
|
||||
parser = argparse.ArgumentParser(description='定期新闻获取任务')
|
||||
parser.add_argument('--limit', type=int, default=10, help='处理的网站数量限制(默认:10)')
|
||||
parser.add_argument('--count', type=int, default=5, help='每个网站获取的新闻数量(默认:5)')
|
||||
parser.add_argument('--freshness', type=str, default='oneMonth',
|
||||
choices=['noLimit', 'oneDay', 'oneWeek', 'oneMonth', 'oneYear'],
|
||||
help='新闻时间范围(默认:oneMonth)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 60)
|
||||
print(f"定期新闻获取任务 - 开始时间: {datetime.now()}")
|
||||
print("=" * 60)
|
||||
|
||||
try:
|
||||
success = fetch_news_for_sites(
|
||||
limit=args.limit,
|
||||
count_per_site=args.count,
|
||||
freshness=args.freshness
|
||||
)
|
||||
|
||||
if success:
|
||||
print(f"\n任务执行成功!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print(f"\n任务执行失败!")
|
||||
sys.exit(1)
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n[{datetime.now()}] 严重错误: {str(e)}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -4,7 +4,7 @@
|
||||
# 用法: ./manage.sh [start|stop|restart|status|logs]
|
||||
|
||||
APP_NAME="zjpb"
|
||||
APP_DIR="/www/wwwroot/zjpb"
|
||||
APP_DIR="/opt/1panel/apps/zjpb"
|
||||
VENV_DIR="$APP_DIR/venv"
|
||||
PID_FILE="$APP_DIR/logs/gunicorn.pid"
|
||||
|
||||
@@ -14,7 +14,7 @@ case "$1" in
|
||||
start)
|
||||
echo "启动 $APP_NAME..."
|
||||
source $VENV_DIR/bin/activate
|
||||
gunicorn -c gunicorn_config.py app:app
|
||||
gunicorn -c gunicorn_config.py wsgi:app
|
||||
echo "$APP_NAME 已启动"
|
||||
;;
|
||||
|
||||
|
||||
99
migrate_news_fields.py
Normal file
99
migrate_news_fields.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""
|
||||
数据库迁移脚本 - 为News表添加source_name和source_icon字段
|
||||
版本:v2.2.0
|
||||
日期:2025-01-30
|
||||
"""
|
||||
import pymysql
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
def migrate():
|
||||
"""执行数据库迁移"""
|
||||
# 数据库配置
|
||||
db_config = {
|
||||
'host': os.environ.get('DB_HOST', 'localhost'),
|
||||
'port': int(os.environ.get('DB_PORT', 3306)),
|
||||
'user': os.environ.get('DB_USER', 'root'),
|
||||
'password': os.environ.get('DB_PASSWORD', ''),
|
||||
'database': os.environ.get('DB_NAME', 'ai_nav'),
|
||||
'charset': 'utf8mb4'
|
||||
}
|
||||
|
||||
try:
|
||||
# 连接数据库
|
||||
connection = pymysql.connect(**db_config)
|
||||
cursor = connection.cursor()
|
||||
|
||||
print("=" * 60)
|
||||
print("开始执行数据库迁移 v2.2.0")
|
||||
print("=" * 60)
|
||||
|
||||
# 检查字段是否已存在
|
||||
cursor.execute("""
|
||||
SELECT COLUMN_NAME
|
||||
FROM INFORMATION_SCHEMA.COLUMNS
|
||||
WHERE TABLE_SCHEMA = %s
|
||||
AND TABLE_NAME = 'news'
|
||||
AND COLUMN_NAME IN ('source_name', 'source_icon')
|
||||
""", (db_config['database'],))
|
||||
|
||||
existing_columns = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
# 添加 source_name 字段
|
||||
if 'source_name' not in existing_columns:
|
||||
print("\n1. 添加 source_name 字段...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE news
|
||||
ADD COLUMN source_name VARCHAR(100)
|
||||
COMMENT '新闻来源网站名称'
|
||||
AFTER url
|
||||
""")
|
||||
print(">>> source_name 字段添加成功")
|
||||
else:
|
||||
print("\n1. source_name 字段已存在,跳过")
|
||||
|
||||
# 添加 source_icon 字段
|
||||
if 'source_icon' not in existing_columns:
|
||||
print("\n2. 添加 source_icon 字段...")
|
||||
cursor.execute("""
|
||||
ALTER TABLE news
|
||||
ADD COLUMN source_icon VARCHAR(500)
|
||||
COMMENT '新闻来源网站图标URL'
|
||||
AFTER source_name
|
||||
""")
|
||||
print(">>> source_icon 字段添加成功")
|
||||
else:
|
||||
print("\n2. source_icon 字段已存在,跳过")
|
||||
|
||||
# 提交事务
|
||||
connection.commit()
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(">>> 数据库迁移完成!")
|
||||
print("=" * 60)
|
||||
|
||||
# 显示表结构
|
||||
print("\n当前 news 表结构:")
|
||||
cursor.execute("DESCRIBE news")
|
||||
for row in cursor.fetchall():
|
||||
print(f" - {row[0]}: {row[1]} {row[2]}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n>>> 迁移失败:{str(e)}")
|
||||
if 'connection' in locals():
|
||||
connection.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
if 'cursor' in locals():
|
||||
cursor.close()
|
||||
if 'connection' in locals():
|
||||
connection.close()
|
||||
print("\n数据库连接已关闭")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
migrate()
|
||||
@@ -90,6 +90,8 @@ class News(db.Model):
|
||||
content = db.Column(db.Text, comment='新闻内容')
|
||||
news_type = db.Column(db.String(50), default='Industry News', comment='新闻类型')
|
||||
url = db.Column(db.String(500), comment='新闻链接')
|
||||
source_name = db.Column(db.String(100), comment='新闻来源网站名称')
|
||||
source_icon = db.Column(db.String(500), comment='新闻来源网站图标URL')
|
||||
published_at = db.Column(db.DateTime, default=datetime.now, comment='发布时间')
|
||||
is_active = db.Column(db.Boolean, default=True, comment='是否启用')
|
||||
created_at = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
|
||||
|
||||
@@ -85,6 +85,12 @@
|
||||
<span class="nav-text">批量导入</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('change_password') }}" class="nav-link">
|
||||
<span class="material-symbols-outlined nav-icon">lock_reset</span>
|
||||
<span class="nav-text">修改密码</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a href="{{ url_for('index') }}" class="nav-link" target="_blank">
|
||||
<span class="material-symbols-outlined nav-icon">open_in_new</span>
|
||||
|
||||
@@ -1,67 +1,97 @@
|
||||
{% extends "base.html" %}
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>管理员登录 - ZJPB 焦提示词</title>
|
||||
|
||||
{% block title %}管理员登录 - ZJPB 焦提示词{% endblock %}
|
||||
<!-- Google Fonts -->
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@300;400;500;600;700&family=Noto+Sans:wght@400;500;700&display=swap" rel="stylesheet">
|
||||
|
||||
{% block extra_css %}
|
||||
<style>
|
||||
.glass-panel {
|
||||
background: rgba(22, 33, 37, 0.7);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
.tech-bg-grid {
|
||||
background-image: radial-gradient(circle at center, rgba(37, 192, 244, 0.05) 0%, transparent 70%),
|
||||
linear-gradient(rgba(37, 192, 244, 0.03) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(37, 192, 244, 0.03) 1px, transparent 1px);
|
||||
background-size: 100% 100%, 40px 40px, 40px 40px;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
<!-- Material Symbols -->
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
|
||||
|
||||
{% block content %}
|
||||
<!-- Override default layout -->
|
||||
</div> <!-- Close main wrapper -->
|
||||
|
||||
<body class="bg-background-dark font-display text-white min-h-screen flex flex-col items-center justify-center relative overflow-hidden selection:bg-primary/30">
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
"primary": "#0ea5e9", // Sky 500
|
||||
"primary-dark": "#0284c7", // Sky 600
|
||||
"background": "#f8fafc", // Slate 50
|
||||
"surface": "#ffffff",
|
||||
"input-bg": "#ffffff",
|
||||
"input-border": "#e2e8f0", // Slate 200
|
||||
"text-main": "#0f172a", // Slate 900
|
||||
"text-secondary": "#334155", // Slate 700
|
||||
"text-muted": "#64748b", // Slate 500
|
||||
},
|
||||
fontFamily: {
|
||||
"display": ["Space Grotesk", "sans-serif"],
|
||||
"body": ["Noto Sans", "sans-serif"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
<style>
|
||||
.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.75);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
}
|
||||
.tech-bg-grid {
|
||||
background-image: radial-gradient(circle at center, rgba(14, 165, 233, 0.04) 0%, transparent 60%),
|
||||
linear-gradient(rgba(14, 165, 233, 0.05) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(14, 165, 233, 0.05) 1px, transparent 1px);
|
||||
background-size: 100% 100%, 40px 40px, 40px 40px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-[#f8fafc] font-display text-[#0f172a] min-h-screen flex flex-col items-center justify-center relative overflow-hidden selection:bg-sky-500/20 selection:text-sky-700">
|
||||
<!-- Ambient Background -->
|
||||
<div class="absolute inset-0 z-0 tech-bg-grid pointer-events-none"></div>
|
||||
<div class="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-primary/10 rounded-full blur-[120px] pointer-events-none"></div>
|
||||
<div class="absolute bottom-[-10%] left-[-10%] w-[500px] h-[500px] bg-purple-600/10 rounded-full blur-[100px] pointer-events-none"></div>
|
||||
<div class="absolute top-[-20%] right-[-10%] w-[600px] h-[600px] bg-sky-200/40 rounded-full blur-[120px] pointer-events-none mix-blend-multiply"></div>
|
||||
<div class="absolute bottom-[-10%] left-[-10%] w-[500px] h-[500px] bg-indigo-100/60 rounded-full blur-[100px] pointer-events-none mix-blend-multiply"></div>
|
||||
|
||||
<!-- Main Content Wrapper -->
|
||||
<div class="w-full max-w-[480px] px-4 z-10 flex flex-col gap-6">
|
||||
<!-- Back Navigation -->
|
||||
<a class="group flex items-center gap-2 text-gray-400 hover:text-white transition-colors w-fit" href="{{ url_for('index') }}">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full border border-border-dark bg-surface-dark group-hover:border-primary/50 transition-colors">
|
||||
<a class="group flex items-center gap-2 text-[#64748b] hover:text-[#0f172a] transition-colors w-fit" href="{{ url_for('index') }}">
|
||||
<div class="flex items-center justify-center w-8 h-8 rounded-full border border-[#e2e8f0] bg-white group-hover:border-sky-500/50 group-hover:bg-sky-500/5 transition-all shadow-sm">
|
||||
<span class="material-symbols-outlined text-sm">arrow_back</span>
|
||||
</div>
|
||||
<span class="text-sm font-medium">返回首页</span>
|
||||
</a>
|
||||
|
||||
<!-- Login Card -->
|
||||
<div class="glass-panel border border-white/5 dark:border-border-dark/50 rounded-xl shadow-2xl p-8 md:p-10 relative overflow-hidden">
|
||||
<div class="glass-panel border border-white rounded-2xl shadow-[0_4px_30px_rgba(0,0,0,0.03)] p-8 md:p-12 relative overflow-hidden ring-1 ring-black/5">
|
||||
<!-- Decorative Accent -->
|
||||
<div class="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-primary to-transparent opacity-70"></div>
|
||||
<div class="absolute top-0 left-0 w-full h-[2px] bg-gradient-to-r from-transparent via-sky-500/50 to-transparent"></div>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col gap-2 mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<div class="p-2 rounded-lg bg-primary/10 text-primary">
|
||||
<div class="p-2 rounded-lg bg-sky-500/10 text-sky-500">
|
||||
<span class="material-symbols-outlined text-2xl">shield_person</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold tracking-widest uppercase text-primary/80">System Access</span>
|
||||
<span class="text-xs font-bold tracking-widest uppercase text-sky-500">System Access</span>
|
||||
</div>
|
||||
<h1 class="text-3xl font-black tracking-tight text-white leading-tight">管理员登录</h1>
|
||||
<p class="text-gray-400 text-base font-normal">输入您的登录凭据以访问后台管理系统</p>
|
||||
<h1 class="text-3xl font-black tracking-tight text-[#0f172a] leading-tight">管理员登录</h1>
|
||||
<p class="text-[#64748b] text-base font-normal">输入您的登录凭据以访问后台管理系统</p>
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="mb-6 p-3 rounded-lg bg-red-500/10 border border-red-500/20 flex items-start gap-3">
|
||||
<span class="material-symbols-outlined text-red-400 text-sm mt-0.5">error</span>
|
||||
<p class="text-red-400 text-sm">{{ message }}</p>
|
||||
<div class="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 flex items-start gap-3">
|
||||
<span class="material-symbols-outlined text-red-500 text-sm mt-0.5">error</span>
|
||||
<p class="text-red-600 text-sm">{{ message }}</p>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
@@ -71,16 +101,16 @@
|
||||
<form method="POST" action="{{ url_for('admin_login') }}" class="flex flex-col gap-5">
|
||||
<!-- Username Field -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-white text-sm font-medium leading-normal" for="username">用户名</label>
|
||||
<label class="text-[#334155] text-sm font-semibold leading-normal" for="username">用户名</label>
|
||||
<div class="relative group">
|
||||
<input class="form-input flex w-full h-14 pl-12 pr-4 rounded-lg text-white focus:outline-0 focus:ring-0 border border-border-dark bg-surface-dark focus:border-primary placeholder:text-gray-500 text-base font-normal leading-normal transition-colors"
|
||||
<input class="form-input flex w-full h-12 pl-11 pr-4 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm"
|
||||
id="username"
|
||||
name="username"
|
||||
placeholder="输入您的用户名或邮箱"
|
||||
type="text"
|
||||
required
|
||||
autofocus/>
|
||||
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors flex items-center justify-center pointer-events-none">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none">
|
||||
<span class="material-symbols-outlined text-[20px]">person</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -88,34 +118,56 @@
|
||||
|
||||
<!-- Password Field -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-white text-sm font-medium leading-normal" for="password">密码</label>
|
||||
<div class="flex justify-between items-end">
|
||||
<label class="text-[#334155] text-sm font-semibold leading-normal" for="password">密码</label>
|
||||
</div>
|
||||
<div class="flex w-full items-stretch rounded-lg group relative">
|
||||
<input class="form-input flex w-full h-14 pl-12 pr-12 rounded-lg text-white focus:outline-0 focus:ring-0 border border-border-dark bg-surface-dark focus:border-primary placeholder:text-gray-500 text-base font-normal leading-normal transition-colors z-10"
|
||||
<input class="form-input flex w-full h-12 pl-11 pr-11 rounded-lg text-[#0f172a] focus:outline-0 focus:ring-2 focus:ring-sky-500/20 border border-[#e2e8f0] bg-white focus:border-sky-500 placeholder:text-slate-400 text-base font-normal leading-normal transition-all shadow-sm z-10"
|
||||
id="password"
|
||||
name="password"
|
||||
placeholder="输入您的密码"
|
||||
type="password"
|
||||
required/>
|
||||
<div class="absolute left-4 top-1/2 -translate-y-1/2 text-gray-400 group-focus-within:text-primary transition-colors flex items-center justify-center pointer-events-none z-20">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 group-focus-within:text-sky-500 transition-colors flex items-center justify-center pointer-events-none z-20">
|
||||
<span class="material-symbols-outlined text-[20px]">lock</span>
|
||||
</div>
|
||||
<button class="absolute right-0 top-0 h-full px-3 text-slate-400 hover:text-[#334155] transition-colors flex items-center justify-center z-20 focus:outline-none"
|
||||
type="button"
|
||||
onclick="togglePassword()">
|
||||
<span class="material-symbols-outlined text-[20px]" id="toggleIcon">visibility_off</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Button -->
|
||||
<button class="mt-4 flex w-full h-12 cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-primary text-black text-base font-bold leading-normal tracking-[0.015em] hover:bg-primary/90 transition-all active:scale-[0.98] shadow-[0_0_20px_rgba(37,192,244,0.3)] hover:shadow-[0_0_30px_rgba(37,192,244,0.5)]"
|
||||
<button class="mt-4 flex w-full h-12 cursor-pointer items-center justify-center overflow-hidden rounded-lg bg-sky-500 text-white text-base font-bold leading-normal tracking-wide hover:bg-sky-600 transition-all active:scale-[0.98] shadow-lg shadow-sky-500/25 hover:shadow-sky-500/40"
|
||||
type="submit">
|
||||
<span class="truncate">登录</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Footer Meta -->
|
||||
<div class="mt-8 flex justify-center gap-6 border-t border-border-dark/30 pt-6">
|
||||
<p class="text-gray-400 text-xs text-center">
|
||||
<div class="mt-8 flex justify-center gap-6 border-t border-slate-100 pt-6">
|
||||
<p class="text-[#64748b] text-xs text-center font-medium">
|
||||
ZJPB - 焦提示词 管理系统
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function togglePassword() {
|
||||
const passwordInput = document.getElementById('password');
|
||||
const toggleIcon = document.getElementById('toggleIcon');
|
||||
|
||||
if (passwordInput.type === 'password') {
|
||||
passwordInput.type = 'text';
|
||||
toggleIcon.textContent = 'visibility';
|
||||
} else {
|
||||
passwordInput.type = 'password';
|
||||
toggleIcon.textContent = 'visibility_off';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
{% endblock %}
|
||||
</html>
|
||||
|
||||
@@ -628,15 +628,51 @@
|
||||
</h2>
|
||||
{% for news in news_list %}
|
||||
<div class="news-item">
|
||||
<span class="news-badge">{{ news.news_type }}</span>
|
||||
<h4>{{ news.title }}</h4>
|
||||
<p>{{ news.content[:200] }}...</p>
|
||||
<div class="news-date">{{ news.published_at.strftime('%b %d, %Y') }}</div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px;">
|
||||
<span class="news-badge">{{ news.news_type }}</span>
|
||||
{% if news.source_name %}
|
||||
<div style="display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-muted);">
|
||||
{% if news.source_icon %}
|
||||
<img src="{{ news.source_icon }}" alt="{{ news.source_name }}" style="width: 16px; height: 16px; border-radius: 2px;">
|
||||
{% endif %}
|
||||
<span>{{ news.source_name }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
<h4>
|
||||
{% if news.url %}
|
||||
<a href="{{ news.url }}" target="_blank" rel="noopener noreferrer" style="color: var(--text-primary); text-decoration: none;">
|
||||
{{ news.title }}
|
||||
</a>
|
||||
{% else %}
|
||||
{{ news.title }}
|
||||
{% endif %}
|
||||
</h4>
|
||||
{% if news.content %}
|
||||
<p>{{ news.content[:200] }}{% if news.content|length > 200 %}...{% endif %}</p>
|
||||
{% endif %}
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div class="news-date">
|
||||
{% if news.published_at %}
|
||||
{{ news.published_at.strftime('%Y年%m月%d日') }}
|
||||
{% else %}
|
||||
未知日期
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if news.url %}
|
||||
<a href="{{ news.url }}" target="_blank" rel="noopener noreferrer" style="font-size: 12px; color: var(--primary-blue); text-decoration: none;">
|
||||
阅读全文 ↗
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar-column">
|
||||
<!-- Similar Recommendations -->
|
||||
{% if recommended_sites %}
|
||||
<div class="content-block">
|
||||
@@ -644,35 +680,22 @@
|
||||
<span>✨</span>
|
||||
相似推荐
|
||||
</h2>
|
||||
<div class="recommendations-grid">
|
||||
{% for rec_site in recommended_sites %}
|
||||
<a href="/site/{{ rec_site.code }}" class="recommendation-card">
|
||||
{% if rec_site.logo %}
|
||||
<img src="{{ rec_site.logo }}" alt="{{ rec_site.name }}" class="rec-logo">
|
||||
{% else %}
|
||||
<div class="rec-logo" style="background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%);"></div>
|
||||
{% endif %}
|
||||
<div class="rec-info">
|
||||
<h4>{{ rec_site.name }}</h4>
|
||||
<p>{{ rec_site.short_desc or rec_site.description }}</p>
|
||||
<div class="rec-tags">
|
||||
{% for tag in rec_site.tags[:2] %}
|
||||
<span class="rec-tag">{{ tag.name }}</span>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="arrow-icon">↗</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% for rec_site in recommended_sites %}
|
||||
<a href="/site/{{ rec_site.code }}" class="recommendation-card" style="display: flex; gap: 12px; padding: 16px; border: 1px solid var(--border-color); border-radius: 12px; margin-bottom: 12px; text-decoration: none; transition: all 0.2s;">
|
||||
{% if rec_site.logo %}
|
||||
<img src="{{ rec_site.logo }}" alt="{{ rec_site.name }}" style="width: 48px; height: 48px; border-radius: 8px; flex-shrink: 0;">
|
||||
{% else %}
|
||||
<div style="width: 48px; height: 48px; border-radius: 8px; background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%); flex-shrink: 0;"></div>
|
||||
{% endif %}
|
||||
<div style="flex: 1; min-width: 0;">
|
||||
<h4 style="font-size: 14px; font-weight: 600; margin: 0 0 4px 0; color: var(--text-primary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ rec_site.name }}</h4>
|
||||
<p style="font-size: 12px; color: var(--text-secondary); margin: 0; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;">{{ rec_site.short_desc or rec_site.description }}</p>
|
||||
</div>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- 侧边栏 -->
|
||||
<div class="sidebar-column">
|
||||
<!-- 预留侧边栏位置,可以后续添加其他模块 -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -41,6 +41,7 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.category-tab {
|
||||
@@ -58,6 +59,74 @@
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.category-tab .tag-count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
background: rgba(100, 116, 139, 0.1);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
border-radius: 10px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.category-tab.active .tag-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-tab:hover .tag-count {
|
||||
background: rgba(59, 130, 246, 0.15);
|
||||
color: var(--primary-blue);
|
||||
}
|
||||
|
||||
.category-tab.active:hover .tag-count {
|
||||
background: rgba(255, 255, 255, 0.35);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-tab.hidden-tag {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.category-tab.show-all .hidden-tag {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.more-tags-btn {
|
||||
padding: 10px 20px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 50px;
|
||||
background: var(--bg-white);
|
||||
color: var(--primary-blue);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.more-tags-btn:hover {
|
||||
border-color: var(--primary-blue);
|
||||
background: rgba(59, 130, 246, 0.05);
|
||||
}
|
||||
|
||||
.more-tags-btn .expand-icon {
|
||||
font-size: 14px;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.more-tags-btn.expanded .expand-icon {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.category-tab:hover {
|
||||
border-color: var(--primary-blue);
|
||||
color: var(--primary-blue);
|
||||
@@ -69,8 +138,8 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.category-tab .material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
.category-tab .icon {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 工具网格 */
|
||||
@@ -187,10 +256,6 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.tool-views .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* 分页 */
|
||||
.pagination {
|
||||
display: flex;
|
||||
@@ -243,7 +308,7 @@
|
||||
padding: 80px 20px;
|
||||
}
|
||||
|
||||
.empty-state .material-symbols-outlined {
|
||||
.empty-state .icon {
|
||||
font-size: 64px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 16px;
|
||||
@@ -286,25 +351,32 @@
|
||||
|
||||
<!-- 分类过滤 -->
|
||||
<div class="categories" id="categories">
|
||||
<div class="category-tabs">
|
||||
<div class="category-tabs" id="categoryTabs">
|
||||
<a href="/" class="category-tab {% if not selected_tag %}active{% endif %}">
|
||||
All Tools
|
||||
</a>
|
||||
{% for tag in tags %}
|
||||
<a href="/?tag={{ tag.slug }}" class="category-tab {% if selected_tag and selected_tag.id == tag.id %}active{% endif %}">
|
||||
<span class="material-symbols-outlined">
|
||||
{% if 'Chat' in tag.name or 'GPT' in tag.name %}chat
|
||||
{% elif 'Image' in tag.name or '图' in tag.name %}image
|
||||
{% elif 'Video' in tag.name or '视频' in tag.name %}videocam
|
||||
{% elif 'Writing' in tag.name or '写作' in tag.name %}edit_note
|
||||
{% elif 'Coding' in tag.name or '代码' in tag.name %}code
|
||||
{% elif 'Audio' in tag.name or '音频' in tag.name %}graphic_eq
|
||||
{% elif '3D' in tag.name %}view_in_ar
|
||||
{% else %}label{% endif %}
|
||||
<a href="/?tag={{ tag.slug }}" class="category-tab {% if selected_tag and selected_tag.id == tag.id %}active{% endif %} {% if loop.index > 10 %}hidden-tag{% endif %}" data-tag-index="{{ loop.index }}">
|
||||
<span>
|
||||
{% if 'Chat' in tag.name or 'GPT' in tag.name or '对话' in tag.name %}💬
|
||||
{% elif 'Image' in tag.name or '图' in tag.name %}🖼️
|
||||
{% elif 'Video' in tag.name or '视频' in tag.name %}🎬
|
||||
{% elif 'Writing' in tag.name or '写作' in tag.name %}✍️
|
||||
{% elif 'Coding' in tag.name or '代码' in tag.name %}💻
|
||||
{% elif 'Audio' in tag.name or '音频' in tag.name %}🎵
|
||||
{% elif '3D' in tag.name %}🎨
|
||||
{% else %}🏷️{% endif %}
|
||||
</span>
|
||||
{{ tag.name }}
|
||||
<span class="tag-count">{{ tag_counts.get(tag.id, 0) }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
{% if tags|length > 10 %}
|
||||
<div class="more-tags-btn" id="moreTagsBtn">
|
||||
<span>更多</span>
|
||||
<span class="expand-icon">▼</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -313,7 +385,7 @@
|
||||
{% if search_query %}
|
||||
<div style="margin-bottom: 24px; padding: 16px; background: #f1f5f9; border-radius: 8px;">
|
||||
<p style="margin: 0; color: #64748b; font-size: 14px;">
|
||||
<span class="material-symbols-outlined" style="font-size: 18px; vertical-align: middle;">search</span>
|
||||
<span style="font-size: 18px; vertical-align: middle;">🔍</span>
|
||||
搜索 "{{ search_query }}" 的结果:找到 {{ pagination.total if pagination else sites|length }} 个工具
|
||||
<a href="/" style="margin-left: 12px; color: #0ea5e9; text-decoration: none;">清除搜索</a>
|
||||
</p>
|
||||
@@ -329,7 +401,7 @@
|
||||
{% else %}
|
||||
<div class="tool-logo" style="background: linear-gradient(135deg, #0ea5e9 0%, #8b5cf6 100%);"></div>
|
||||
{% endif %}
|
||||
<span class="material-symbols-outlined tool-link-icon">north_east</span>
|
||||
<span class="tool-link-icon">↗</span>
|
||||
</div>
|
||||
<h3 class="tool-name">{{ site.name }}</h3>
|
||||
<p class="tool-description">{{ site.short_desc or site.description }}</p>
|
||||
@@ -340,7 +412,7 @@
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="tool-views">
|
||||
<span class="material-symbols-outlined">visibility</span>
|
||||
<span>👁</span>
|
||||
<span>{% if site.view_count >= 1000 %}{{ (site.view_count / 1000) | round(1) }}k{% else %}{{ site.view_count | default(0) }}{% endif %}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,11 +426,11 @@
|
||||
<!-- 上一页 -->
|
||||
{% if pagination.has_prev %}
|
||||
<a href="?page={{ pagination.prev_num }}{% if selected_tag %}&tag={{ selected_tag.slug }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">
|
||||
<span class="material-symbols-outlined">chevron_left</span>
|
||||
<span>◀</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="disabled">
|
||||
<span class="material-symbols-outlined">chevron_left</span>
|
||||
<span>◀</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
@@ -391,22 +463,52 @@
|
||||
<!-- 下一页 -->
|
||||
{% if pagination.has_next %}
|
||||
<a href="?page={{ pagination.next_num }}{% if selected_tag %}&tag={{ selected_tag.slug }}{% endif %}{% if search_query %}&q={{ search_query }}{% endif %}">
|
||||
<span class="material-symbols-outlined">chevron_right</span>
|
||||
<span>▶</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="disabled">
|
||||
<span class="material-symbols-outlined">chevron_right</span>
|
||||
<span>▶</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<span class="material-symbols-outlined">search_off</span>
|
||||
<span class="icon">🔍</span>
|
||||
<h3>暂无工具</h3>
|
||||
<p>{% if selected_tag %}该分类下还没有工具{% else %}还没有添加任何工具{% endif %}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// 标签展开/收起功能
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const moreTagsBtn = document.getElementById('moreTagsBtn');
|
||||
if (moreTagsBtn) {
|
||||
moreTagsBtn.addEventListener('click', function() {
|
||||
const categoryTabs = document.getElementById('categoryTabs');
|
||||
const hiddenTags = categoryTabs.querySelectorAll('.hidden-tag');
|
||||
const isExpanded = moreTagsBtn.classList.contains('expanded');
|
||||
|
||||
if (isExpanded) {
|
||||
// 收起
|
||||
hiddenTags.forEach(tag => {
|
||||
tag.style.display = 'none';
|
||||
});
|
||||
moreTagsBtn.classList.remove('expanded');
|
||||
moreTagsBtn.querySelector('span:first-child').textContent = '更多';
|
||||
} else {
|
||||
// 展开
|
||||
hiddenTags.forEach(tag => {
|
||||
tag.style.display = 'inline-flex';
|
||||
});
|
||||
moreTagsBtn.classList.add('expanded');
|
||||
moreTagsBtn.querySelector('span:first-child').textContent = '收起';
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
142
test_news_feature.py
Normal file
142
test_news_feature.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
测试v2.2新闻功能 - 完整流程测试
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from dotenv import load_dotenv
|
||||
|
||||
# 加载环境变量
|
||||
load_dotenv()
|
||||
|
||||
# 添加项目路径
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
from app import create_app
|
||||
from models import db, Site, News
|
||||
from utils.news_searcher import NewsSearcher
|
||||
|
||||
def test_news_feature():
|
||||
"""测试新闻功能"""
|
||||
print("=" * 60)
|
||||
print("v2.2 新闻功能测试")
|
||||
print("=" * 60)
|
||||
|
||||
# 创建应用上下文
|
||||
app = create_app('development')
|
||||
|
||||
with app.app_context():
|
||||
# 1. 测试API配置
|
||||
print("\n[1/4] 检查API配置...")
|
||||
api_key = app.config.get('BOCHA_API_KEY')
|
||||
if not api_key:
|
||||
print(">>> 错误:BOCHA_API_KEY未配置")
|
||||
return False
|
||||
print(f">>> API Key: {api_key[:20]}...")
|
||||
|
||||
# 2. 测试数据库连接
|
||||
print("\n[2/4] 检查数据库...")
|
||||
try:
|
||||
site_count = Site.query.filter_by(is_active=True).count()
|
||||
print(f">>> 找到 {site_count} 个启用的网站")
|
||||
|
||||
if site_count == 0:
|
||||
print(">>> 警告:没有可用的网站")
|
||||
return False
|
||||
|
||||
except Exception as e:
|
||||
print(f">>> 数据库错误:{e}")
|
||||
return False
|
||||
|
||||
# 3. 测试新闻搜索
|
||||
print("\n[3/4] 测试新闻搜索...")
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 获取第一个网站
|
||||
site = Site.query.filter_by(is_active=True).first()
|
||||
print(f">>> 测试网站:{site.name}")
|
||||
|
||||
try:
|
||||
news_items = searcher.search_site_news(
|
||||
site_name=site.name,
|
||||
site_url=site.url,
|
||||
count=3,
|
||||
freshness='oneWeek'
|
||||
)
|
||||
|
||||
print(f">>> 找到 {len(news_items)} 条新闻")
|
||||
|
||||
if news_items:
|
||||
print("\n新闻列表:")
|
||||
for i, item in enumerate(news_items, 1):
|
||||
print(f" {i}. {item['title'][:50]}...")
|
||||
print(f" 来源:{item.get('site_name', '未知')}")
|
||||
print(f" URL:{item['url'][:60]}...")
|
||||
|
||||
except Exception as e:
|
||||
print(f">>> 搜索失败:{e}")
|
||||
return False
|
||||
|
||||
# 4. 测试保存到数据库
|
||||
print(f"\n[4/4] 测试保存到数据库...")
|
||||
|
||||
if not news_items:
|
||||
print(">>> 没有新闻可保存")
|
||||
return True
|
||||
|
||||
try:
|
||||
saved_count = 0
|
||||
for item in news_items[:2]: # 只保存前2条作为测试
|
||||
# 检查是否已存在
|
||||
existing = News.query.filter_by(
|
||||
site_id=site.id,
|
||||
url=item['url']
|
||||
).first()
|
||||
|
||||
if not existing:
|
||||
news = News(
|
||||
site_id=site.id,
|
||||
title=item['title'],
|
||||
content=item.get('summary') or item.get('snippet', ''),
|
||||
url=item['url'],
|
||||
source_name=item.get('site_name', ''),
|
||||
source_icon=item.get('site_icon', ''),
|
||||
published_at=item.get('published_at'),
|
||||
news_type='Search Result',
|
||||
is_active=True
|
||||
)
|
||||
db.session.add(news)
|
||||
saved_count += 1
|
||||
|
||||
db.session.commit()
|
||||
print(f">>> 成功保存 {saved_count} 条新闻")
|
||||
|
||||
# 验证保存
|
||||
total_news = News.query.filter_by(site_id=site.id).count()
|
||||
print(f">>> 该网站共有 {total_news} 条新闻记录")
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f">>> 保存失败:{e}")
|
||||
return False
|
||||
|
||||
print("\n" + "=" * 60)
|
||||
print(">>> 所有测试通过!")
|
||||
print("=" * 60)
|
||||
|
||||
# 提供下一步建议
|
||||
print("\n下一步操作:")
|
||||
print(f"1. 访问网站详情页查看新闻:http://localhost:5000/site/{site.code}")
|
||||
print(f"2. 访问后台新闻管理:http://localhost:5000/admin/newsadmin/")
|
||||
print(f"3. 运行定期任务脚本:python fetch_news_cron.py --limit 5")
|
||||
|
||||
return True
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
success = test_news_feature()
|
||||
sys.exit(0 if success else 1)
|
||||
except Exception as e:
|
||||
print(f"\n严重错误:{e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
271
utils/news_searcher.py
Normal file
271
utils/news_searcher.py
Normal file
@@ -0,0 +1,271 @@
|
||||
"""
|
||||
新闻搜索工具 - 使用博查 Web Search API
|
||||
"""
|
||||
import requests
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
class NewsSearcher:
|
||||
"""博查新闻搜索器"""
|
||||
|
||||
def __init__(self, api_key: str, base_url: str = 'https://api.bocha.cn'):
|
||||
"""
|
||||
初始化新闻搜索器
|
||||
|
||||
Args:
|
||||
api_key: 博查API密钥
|
||||
base_url: API基础URL
|
||||
"""
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
self.endpoint = f"{base_url}/v1/web-search"
|
||||
|
||||
def search_news(
|
||||
self,
|
||||
query: str,
|
||||
count: int = 10,
|
||||
freshness: str = 'oneMonth',
|
||||
summary: bool = True,
|
||||
include: Optional[str] = None,
|
||||
exclude: Optional[str] = None
|
||||
) -> Dict:
|
||||
"""
|
||||
搜索新闻
|
||||
|
||||
Args:
|
||||
query: 搜索关键词
|
||||
count: 返回结果数量(1-50)
|
||||
freshness: 时间范围(noLimit/oneDay/oneWeek/oneMonth/oneYear)
|
||||
summary: 是否显示摘要
|
||||
include: 指定搜索的网站范围(多个域名用|或,分隔)
|
||||
exclude: 排除搜索的网站范围(多个域名用|或,分隔)
|
||||
|
||||
Returns:
|
||||
搜索结果字典
|
||||
"""
|
||||
headers = {
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
payload = {
|
||||
'query': query,
|
||||
'count': count,
|
||||
'freshness': freshness,
|
||||
'summary': summary
|
||||
}
|
||||
|
||||
# 添加可选参数
|
||||
if include:
|
||||
payload['include'] = include
|
||||
if exclude:
|
||||
payload['exclude'] = exclude
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
self.endpoint,
|
||||
headers=headers,
|
||||
data=json.dumps(payload),
|
||||
timeout=30
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
'success': False,
|
||||
'error': str(e),
|
||||
'code': getattr(response, 'status_code', None) if 'response' in locals() else None
|
||||
}
|
||||
|
||||
def parse_news_items(self, search_result: Dict) -> List[Dict]:
|
||||
"""
|
||||
解析搜索结果为新闻列表
|
||||
|
||||
Args:
|
||||
search_result: 博查API返回的搜索结果
|
||||
|
||||
Returns:
|
||||
新闻列表,每个新闻包含:title, url, snippet, summary, site_name, published_at等
|
||||
"""
|
||||
news_items = []
|
||||
|
||||
# 检查返回数据格式
|
||||
if 'data' not in search_result:
|
||||
return news_items
|
||||
|
||||
data = search_result['data']
|
||||
if 'webPages' not in data or 'value' not in data['webPages']:
|
||||
return news_items
|
||||
|
||||
# 解析每条新闻
|
||||
for item in data['webPages']['value']:
|
||||
news_item = {
|
||||
'title': item.get('name', ''),
|
||||
'url': item.get('url', ''),
|
||||
'snippet': item.get('snippet', ''),
|
||||
'summary': item.get('summary', ''),
|
||||
'site_name': item.get('siteName', ''),
|
||||
'site_icon': item.get('siteIcon', ''),
|
||||
'published_at': self._parse_date(item.get('datePublished')),
|
||||
'display_url': item.get('displayUrl', ''),
|
||||
'language': item.get('language', ''),
|
||||
}
|
||||
news_items.append(news_item)
|
||||
|
||||
return news_items
|
||||
|
||||
def search_site_news(
|
||||
self,
|
||||
site_name: str,
|
||||
site_url: Optional[str] = None,
|
||||
count: int = 10,
|
||||
freshness: str = 'oneMonth'
|
||||
) -> List[Dict]:
|
||||
"""
|
||||
搜索特定网站的相关新闻
|
||||
|
||||
Args:
|
||||
site_name: 网站名称(用于搜索关键词)
|
||||
site_url: 网站URL(可选,用于排除网站自身)
|
||||
count: 返回结果数量
|
||||
freshness: 时间范围
|
||||
|
||||
Returns:
|
||||
新闻列表
|
||||
"""
|
||||
# 构建搜索关键词:网站名称 + "最新" + "新闻"
|
||||
query = f'"{site_name}" 新闻'
|
||||
|
||||
# 如果提供了网站URL,排除网站自身的结果
|
||||
exclude = None
|
||||
if site_url:
|
||||
# 提取域名
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
parsed = urlparse(site_url)
|
||||
domain = parsed.netloc or parsed.path
|
||||
# 移除 www. 前缀
|
||||
domain = domain.replace('www.', '')
|
||||
exclude = domain
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 执行搜索
|
||||
search_result = self.search_news(
|
||||
query=query,
|
||||
count=count,
|
||||
freshness=freshness,
|
||||
summary=True,
|
||||
exclude=exclude
|
||||
)
|
||||
|
||||
# 解析结果
|
||||
return self.parse_news_items(search_result)
|
||||
|
||||
def _parse_date(self, date_str: Optional[str]) -> Optional[datetime]:
|
||||
"""
|
||||
解析日期字符串
|
||||
|
||||
Args:
|
||||
date_str: 日期字符串(例如:2025-02-23T08:18:30+08:00)
|
||||
|
||||
Returns:
|
||||
datetime对象,如果解析失败返回None
|
||||
"""
|
||||
if not date_str:
|
||||
return None
|
||||
|
||||
try:
|
||||
# 尝试解析 ISO 8601 格式
|
||||
# 博查API返回格式:2025-02-23T08:18:30+08:00
|
||||
if '+' in date_str or 'Z' in date_str:
|
||||
# 使用 fromisoformat(Python 3.7+)
|
||||
return datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||||
else:
|
||||
# 简单格式
|
||||
return datetime.strptime(date_str, '%Y-%m-%dT%H:%M:%S')
|
||||
except Exception:
|
||||
# 如果解析失败,返回None
|
||||
return None
|
||||
|
||||
def format_news_for_display(self, news_items: List[Dict]) -> List[Dict]:
|
||||
"""
|
||||
格式化新闻用于前端展示
|
||||
|
||||
Args:
|
||||
news_items: 新闻列表
|
||||
|
||||
Returns:
|
||||
格式化后的新闻列表
|
||||
"""
|
||||
formatted_news = []
|
||||
|
||||
for item in news_items:
|
||||
formatted_item = {
|
||||
'title': item['title'],
|
||||
'url': item['url'],
|
||||
'description': item.get('summary') or item.get('snippet', ''),
|
||||
'source': item.get('site_name', '未知来源'),
|
||||
'published_date': self._format_date(item.get('published_at')),
|
||||
'icon': item.get('site_icon', '')
|
||||
}
|
||||
formatted_news.append(formatted_item)
|
||||
|
||||
return formatted_news
|
||||
|
||||
def _format_date(self, dt: Optional[datetime]) -> str:
|
||||
"""
|
||||
格式化日期用于显示
|
||||
|
||||
Args:
|
||||
dt: datetime对象
|
||||
|
||||
Returns:
|
||||
格式化的日期字符串
|
||||
"""
|
||||
if not dt:
|
||||
return '未知日期'
|
||||
|
||||
try:
|
||||
# 返回格式:2025-01-30
|
||||
return dt.strftime('%Y-%m-%d')
|
||||
except Exception:
|
||||
return '未知日期'
|
||||
|
||||
|
||||
# 测试代码
|
||||
if __name__ == '__main__':
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# 从环境变量获取API密钥
|
||||
api_key = os.environ.get('BOCHA_API_KEY')
|
||||
if not api_key:
|
||||
print("错误:未设置BOCHA_API_KEY环境变量")
|
||||
exit(1)
|
||||
|
||||
# 创建搜索器
|
||||
searcher = NewsSearcher(api_key)
|
||||
|
||||
# 测试搜索
|
||||
print("正在搜索:ChatGPT 最新新闻...")
|
||||
news_items = searcher.search_site_news(
|
||||
site_name='ChatGPT',
|
||||
count=5,
|
||||
freshness='oneWeek'
|
||||
)
|
||||
|
||||
# 显示结果
|
||||
print(f"\n找到 {len(news_items)} 条新闻:\n")
|
||||
for i, news in enumerate(news_items, 1):
|
||||
print(f"{i}. {news['title']}")
|
||||
print(f" 来源:{news['site_name']}")
|
||||
print(f" 日期:{searcher._format_date(news['published_at'])}")
|
||||
print(f" URL:{news['url']}")
|
||||
print(f" 摘要:{news.get('summary', news.get('snippet', ''))[:100]}...")
|
||||
print()
|
||||
Reference in New Issue
Block a user