#!/usr/bin/env python # -*- coding: utf-8 -*- """ 古诗词阅读网站后端 API 基于 FastAPI 的 RESTful API,提供: - 诗词数据管理(CRUD) - 多类别组合筛选 - 阅读标记管理 - 统计分析 - 批量导入 """ import json import os import sqlite3 from datetime import datetime from typing import List, Optional, Dict, Any from contextlib import contextmanager from fastapi import FastAPI, HTTPException, UploadFile, File, Query from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse, JSONResponse from fastapi.middleware.cors import CORSMiddleware import uvicorn app = FastAPI(title="古诗词阅读 API", version="2.0.0") # 启用 CORS app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # 配置 BASE_DIR = os.path.dirname(os.path.abspath(__file__)) PROJECT_ROOT = os.path.dirname(BASE_DIR) DB_PATH = os.path.join(BASE_DIR, 'poems.db') FRONTEND_DIR = os.path.join(PROJECT_ROOT, 'frontend') # 分类标签体系定义 CATEGORY_SYSTEM = { "season": { "name": "季节", "tags": ["春", "夏", "秋", "冬", "四季", "无明确季节"] }, "solar_terms": { "name": "节气", "tags": ["立春", "雨水", "惊蛰", "春分", "清明", "谷雨", "立夏", "小满", "芒种", "夏至", "小暑", "大暑", "立秋", "处暑", "白露", "秋分", "寒露", "霜降", "立冬", "小雪", "大雪", "冬至", "小寒", "大寒"] }, "time_of_day": { "name": "时辰", "tags": ["清晨", "上午", "正午", "下午", "黄昏", "夜晚", "深夜", "黎明", "不明确"] }, "genre": { "name": "题材类型", "tags": ["山水田园", "边塞征战", "咏史怀古", "咏物言志", "送别怀人", "思乡怀远", "爱情闺怨", "友情赠答", "羁旅漂泊", "隐逸闲适", "讽喻时事", "节日习俗", "宴饮酬唱", "读书治学", "农耕劳作", "宗教禅理", "其他"] }, "emotion_tone": { "name": "情感基调", "tags": ["喜悦欢快", "悲伤哀愁", "愤怒激愤", "忧郁伤感", "孤独寂寞", "宁静淡泊", "豪迈激昂", "思念眷恋", "惆怅失落", "平和超脱", "复杂混合"] }, "emotions": { "name": "具体情感", "tags": ["喜", "怒", "哀", "乐", "忧", "思", "悲", "恐", "惊", "愁", "恨", "爱", "恋", "盼", "悔", "愧", "傲", "谦", "静", "躁"] }, "nature_scenery": { "name": "自然景物", "tags": ["山", "水", "云", "雨", "雪", "风", "雷", "电", "日", "月", "星", "霜", "露", "霞"] }, "plants": { "name": "植物", "tags": ["松", "竹", "梅", "兰", "菊", "荷", "柳", "桃", "李", "杏", "梨", "枫", "梧桐", "芭蕉", "其他"] }, "animals": { "name": "动物", "tags": ["鸟", "雁", "燕", "鹊", "蝉", "蛙", "鱼", "龙", "凤", "马", "牛", "羊", "犬", "其他"] }, "buildings": { "name": "建筑", "tags": ["楼", "阁", "亭", "台", "轩", "榭", "桥", "寺", "塔", "城", "关", "宫", "殿", "院", "其他"] }, "philosophy": { "name": "哲理思想", "tags": ["儒家思想", "道家思想", "佛家禅理", "人生感悟", "历史兴叹", "自然之道", "无明显哲理"] }, "life_stage": { "name": "人生阶段", "tags": ["少年", "青年", "中年", "老年", "不明确"] }, "social_role": { "name": "社会身份", "tags": ["士人", "官员", "隐士", "游子", "征人", "商贾", "农夫", "僧道", "闺中", "其他"] }, "technique": { "name": "写作手法", "tags": ["比兴", "赋", "对仗", "用典", "借景抒情", "托物言志", "虚实结合", "动静结合", "其他"] }, "rhetoric": { "name": "修辞手法", "tags": ["比喻", "拟人", "夸张", "对偶", "排比", "反复", "设问", "反问", "其他"] }, "colors": { "name": "色彩意象", "tags": ["青", "绿", "红", "白", "黄", "紫", "碧", "翠", "苍", "金"] }, "sounds": { "name": "声音意象", "tags": ["钟声", "鼓声", "笛声", "琴声", "风声", "雨声", "鸟鸣", "蝉鸣", "其他"] }, "location": { "name": "地理方位", "tags": ["江南", "塞北", "中原", "巴蜀", "关中", "岭南", "吴越", "荆楚", "其他"] }, "festival": { "name": "节日习俗", "tags": ["春节", "元宵", "清明", "端午", "七夕", "中秋", "重阳", "除夕", "无"] } } @contextmanager def get_db_connection(): """数据库连接上下文管理器""" conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row try: yield conn finally: conn.close() def init_database(): """初始化数据库表结构""" with get_db_connection() as conn: cursor = conn.cursor() # 诗词表 cursor.execute(''' CREATE TABLE IF NOT EXISTS poems ( id TEXT PRIMARY KEY, title TEXT NOT NULL, author TEXT NOT NULL, paragraphs TEXT, signature TEXT UNIQUE, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ''') # 分类标签表 cursor.execute(''' CREATE TABLE IF NOT EXISTS classifications ( id INTEGER PRIMARY KEY AUTOINCREMENT, poem_id TEXT NOT NULL, category TEXT NOT NULL, tags TEXT, FOREIGN KEY (poem_id) REFERENCES poems(id) ON DELETE CASCADE ) ''') # 阅读记录表 cursor.execute(''' CREATE TABLE IF NOT EXISTS reading_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, poem_id TEXT UNIQUE NOT NULL, is_read BOOLEAN DEFAULT FALSE, read_at TIMESTAMP, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (poem_id) REFERENCES poems(id) ON DELETE CASCADE ) ''') # 创建索引 cursor.execute('CREATE INDEX IF NOT EXISTS idx_classifications_poem ON classifications(poem_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_classifications_category ON classifications(category)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_reading_records_poem ON reading_records(poem_id)') cursor.execute('CREATE INDEX IF NOT EXISTS idx_poems_signature ON poems(signature)') conn.commit() def poem_to_dict(row: sqlite3.Row, classifications: Optional[List[sqlite3.Row]] = None, reading_record: Optional[sqlite3.Row] = None) -> Dict: """将数据库行转换为诗词字典""" result = { 'id': row['id'], 'title': row['title'], 'author': row['author'], 'paragraphs': json.loads(row['paragraphs']) if row['paragraphs'] else [], 'created_at': row['created_at'], 'updated_at': row['updated_at'] } if classifications: result['classifications'] = {} for clf in classifications: result['classifications'][clf['category']] = json.loads(clf['tags']) if reading_record: result['is_read'] = bool(reading_record['is_read']) result['read_at'] = reading_record['read_at'] else: result['is_read'] = False result['read_at'] = None return result # ===== API 端点 ===== # 挂载静态文件目录 app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static") @app.get("/") async def serve_frontend(): """提供前端页面""" return FileResponse(os.path.join(FRONTEND_DIR, 'index.html')) @app.get("/style.css") async def serve_css(): """提供 CSS 文件""" return FileResponse(os.path.join(FRONTEND_DIR, 'style.css')) @app.get("/script.js") async def serve_js(): """提供 JS 文件""" return FileResponse(os.path.join(FRONTEND_DIR, 'script.js')) @app.get("/api/categories") async def get_categories(): """获取分类标签体系""" return CATEGORY_SYSTEM @app.get("/api/stats") async def get_statistics(): """获取统计数据""" with get_db_connection() as conn: cursor = conn.cursor() # 总数 cursor.execute('SELECT COUNT(*) FROM poems') total = cursor.fetchone()[0] # 已读数量 cursor.execute('SELECT COUNT(*) FROM reading_records WHERE is_read = TRUE') read_count = cursor.fetchone()[0] # 各分类统计 cursor.execute(''' SELECT category, COUNT(*) as count FROM classifications GROUP BY category ''') category_stats = {row['category']: row['count'] for row in cursor.fetchall()} # 热门标签 cursor.execute(''' SELECT category, tags FROM classifications WHERE category = 'genre' OR category = 'emotion_tone' ''') popular_tags = {} for row in cursor.fetchall(): tags = json.loads(row['tags']) for tag in tags: if tag not in popular_tags: popular_tags[tag] = 0 popular_tags[tag] += 1 top_tags = sorted(popular_tags.items(), key=lambda x: x[1], reverse=True)[:20] return { 'total_poems': total, 'read_count': read_count, 'unread_count': total - read_count, 'category_stats': category_stats, 'top_tags': top_tags, 'reading_progress': round((read_count / total * 100) if total > 0 else 0, 1) } @app.post("/api/poems/import") async def import_poems(file: UploadFile = File(...)): """批量导入诗词(支持 JSON/JSONL)""" if not file.filename or not file.filename.endswith(('.json', '.jsonl')): raise HTTPException(status_code=400, detail="仅支持 JSON 和 JSONL 格式") content = await file.read() content_str = content.decode('utf-8').strip() if not content_str: raise HTTPException(status_code=400, detail="文件内容为空") # 解析文件 raw_items = [] try: data = json.loads(content_str) if isinstance(data, list): raw_items = data elif isinstance(data, dict): raw_items = [data] except json.JSONDecodeError: for line in content_str.splitlines(): line = line.strip() if line: try: obj = json.loads(line) if isinstance(obj, dict): raw_items.append(obj) except json.JSONDecodeError: continue # 导入数据库 imported_count = 0 skipped_count = 0 with get_db_connection() as conn: cursor = conn.cursor() for item in raw_items: try: # 验证基本格式 if not all(k in item for k in ['title', 'author', 'paragraphs']): skipped_count += 1 continue poem_id = item.get('id', f"poem_{datetime.now().timestamp()}") signature = item.get('signature', '') paragraphs = json.dumps(item['paragraphs'], ensure_ascii=False) # 检查是否已存在 if signature: cursor.execute('SELECT id FROM poems WHERE signature = ?', (signature,)) if cursor.fetchone(): skipped_count += 1 continue # 插入诗词 cursor.execute(''' INSERT OR REPLACE INTO poems (id, title, author, paragraphs, signature) VALUES (?, ?, ?, ?, ?) ''', (poem_id, item['title'], item['author'], paragraphs, signature or '')) # 插入分类 if 'llm_classification' in item or 'classifications' in item: classifications = item.get('llm_classification', item.get('classifications', {})) # 先删除旧分类 cursor.execute('DELETE FROM classifications WHERE poem_id = ?', (poem_id,)) # 插入新分类 for category, tags in classifications.items(): if isinstance(tags, list): tags_str = json.dumps(tags, ensure_ascii=False) else: tags_str = json.dumps([tags], ensure_ascii=False) cursor.execute(''' INSERT INTO classifications (poem_id, category, tags) VALUES (?, ?, ?) ''', (poem_id, category, tags_str)) # 初始化阅读记录 cursor.execute(''' INSERT OR IGNORE INTO reading_records (poem_id, is_read) VALUES (?, FALSE) ''', (poem_id,)) imported_count += 1 except Exception as e: print(f"导入失败:{e}") skipped_count += 1 continue conn.commit() return { 'message': f'成功导入 {imported_count} 首诗词', 'imported': imported_count, 'skipped': skipped_count } @app.get("/api/poems") async def get_poems( page: int = Query(1, ge=1), page_size: int = Query(20, ge=1, le=100), categories: Optional[str] = Query(None, description="多类别筛选,格式:category1:tag1,category2:tag2"), search: Optional[str] = Query(None, description="搜索关键词"), is_read: Optional[bool] = Query(None, description="阅读状态筛选") ): """获取诗词列表(支持分页、多类别筛选、搜索)""" with get_db_connection() as conn: cursor = conn.cursor() # 构建查询条件 conditions = [] params = [] # 阅读状态筛选 if is_read is not None: conditions.append('rr.is_read = ?') params.append(1 if is_read else 0) # 搜索 if search: conditions.append('(p.title LIKE ? OR p.author LIKE ?)') params.extend([f'%{search}%', f'%{search}%']) # 多类别筛选 if categories: category_conditions = [] for cat_filter in categories.split(','): if ':' in cat_filter: category, tag = cat_filter.split(':', 1) category_conditions.append(''' EXISTS ( SELECT 1 FROM classifications c WHERE c.poem_id = p.id AND c.category = ? AND c.tags LIKE ? ) ''') params.extend([category, f'%{tag}%']) if category_conditions: conditions.append(' AND '.join(category_conditions)) where_clause = ' AND '.join(conditions) if conditions else '1=1' # 查询总数 count_sql = f''' SELECT COUNT(DISTINCT p.id) FROM poems p LEFT JOIN reading_records rr ON p.id = rr.poem_id WHERE {where_clause} ''' cursor.execute(count_sql, params) total = cursor.fetchone()[0] # 查询数据 sql = f''' SELECT p.*, rr.is_read, rr.read_at FROM poems p LEFT JOIN reading_records rr ON p.id = rr.poem_id WHERE {where_clause} ORDER BY p.created_at DESC LIMIT ? OFFSET ? ''' params.extend([page_size, (page - 1) * page_size]) cursor.execute(sql, params) poems = cursor.fetchall() # 获取每首诗的分类 poem_ids = [row['id'] for row in poems] classifications = {} if poem_ids: cursor.execute(''' SELECT poem_id, category, tags FROM classifications WHERE poem_id IN ({}) '''.format(','.join('?' * len(poem_ids))), poem_ids) for clf in cursor.fetchall(): if clf['poem_id'] not in classifications: classifications[clf['poem_id']] = [] classifications[clf['poem_id']].append(clf) # 转换结果 result = [] for row in poems: poem_dict = { 'id': row['id'], 'title': row['title'], 'author': row['author'], 'paragraphs': json.loads(row['paragraphs']) if row['paragraphs'] else [], 'created_at': row['created_at'], 'updated_at': row['updated_at'], 'is_read': bool(row['is_read']), 'read_at': row['read_at'], 'classifications': {} } # 添加分类 if row['id'] in classifications: for clf in classifications[row['id']]: poem_dict['classifications'][clf['category']] = json.loads(clf['tags']) result.append(poem_dict) return { 'total': total, 'page': page, 'page_size': page_size, 'total_pages': (total + page_size - 1) // page_size, 'poems': result } @app.get("/api/poems/random") async def get_random_poem(): """获取随机一首诗词""" with get_db_connection() as conn: cursor = conn.cursor() cursor.execute('SELECT * FROM poems ORDER BY RANDOM() LIMIT 1') poem = cursor.fetchone() if not poem: raise HTTPException(status_code=404, detail="没有可用的诗词") cursor.execute('SELECT * FROM classifications WHERE poem_id = ?', (poem['id'],)) classifications = cursor.fetchall() cursor.execute('SELECT * FROM reading_records WHERE poem_id = ?', (poem['id'],)) reading_record = cursor.fetchone() return poem_to_dict(poem, classifications, reading_record) @app.get("/api/poems/{poem_id}") async def get_poem(poem_id: str): """获取单首诗词详情""" with get_db_connection() as conn: cursor = conn.cursor() # 获取诗词 cursor.execute('SELECT * FROM poems WHERE id = ?', (poem_id,)) poem = cursor.fetchone() if not poem: raise HTTPException(status_code=404, detail="诗词不存在") # 获取分类 cursor.execute('SELECT * FROM classifications WHERE poem_id = ?', (poem_id,)) classifications = cursor.fetchall() # 获取阅读记录 cursor.execute('SELECT * FROM reading_records WHERE poem_id = ?', (poem_id,)) reading_record = cursor.fetchone() return poem_to_dict(poem, classifications, reading_record) @app.put("/api/poems/{poem_id}/read") async def toggle_read_status(poem_id: str, is_read: bool = Query(...)): """切换阅读状态""" with get_db_connection() as conn: cursor = conn.cursor() # 检查诗词是否存在 cursor.execute('SELECT id FROM poems WHERE id = ?', (poem_id,)) if not cursor.fetchone(): raise HTTPException(status_code=404, detail="诗词不存在") # 更新或插入阅读记录 read_at = datetime.now().isoformat() if is_read else None cursor.execute(''' INSERT INTO reading_records (poem_id, is_read, read_at) VALUES (?, ?, ?) ON CONFLICT(poem_id) DO UPDATE SET is_read = excluded.is_read, read_at = excluded.read_at ''', (poem_id, 1 if is_read else 0, read_at)) conn.commit() return {'success': True, 'is_read': is_read, 'read_at': read_at} @app.get("/api/tags/cloud") async def get_tag_cloud(): """获取标签云数据""" with get_db_connection() as conn: cursor = conn.cursor() cursor.execute(''' SELECT category, tags FROM classifications ''') tag_counts = {} for row in cursor.fetchall(): tags = json.loads(row['tags']) for tag in tags: key = f"{row['category']}:{tag}" if key not in tag_counts: tag_counts[key] = { 'category': row['category'], 'tag': tag, 'count': 0 } tag_counts[key]['count'] += 1 return { 'tags': list(tag_counts.values()), 'categories': {k: v['name'] for k, v in CATEGORY_SYSTEM.items()} } # 启动时初始化数据库 @app.on_event("startup") async def startup_event(): init_database() print(f"\n数据库路径:{DB_PATH}") print(f"前端目录:{FRONTEND_DIR}") print("\nAPI 端点:") print(" GET / - 前端页面") print(" GET /api/categories - 分类体系") print(" GET /api/stats - 统计数据") print(" POST /api/poems/import - 批量导入") print(" GET /api/poems - 诗词列表") print(" GET /api/poems/{id} - 诗词详情") print(" PUT /api/poems/{id}/read - 切换阅读状态") print(" GET /api/poems/random - 随机诗词") print(" GET /api/tags/cloud - 标签云") print("\n按 Ctrl+C 停止服务\n") if __name__ == "__main__": uvicorn.run(app, host="0.0.0.0", port=8000)