Files
ewoooc/database/trend_models.py
ogt 1b4f3a7bbe
Some checks failed
CD Pipeline / deploy (push) Failing after 59s
feat: EwoooC 初始化 — 完整專案推版至 Gitea
- 建立 Gitea Actions CD pipeline (.gitea/workflows/cd.yaml)
- 部署模式: rsync Python 檔案至 188 → docker restart (volume mount)
- Dockerfile/requirements 變動時自動重建 Docker image
- 部署通知: Telegram (開始/成功/失敗)
- 健康檢查: https://mo.wooo.work/health (最多 5 次重試)
- 同步最新 CLAUDE.md / ADR-008 / memory (2026-04-19)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-19 01:21:13 +08:00

418 lines
13 KiB
Python

"""
趨勢資料庫模型
包含:
- TrendRecord: 趨勢記錄表
- TrendKeyword: 趨勢關鍵字表
- TrendAnalysis: AI 趨勢分析報告表
- WebSearchCache: Web Search 結果快取表
- TelegramUser: Telegram 用戶綁定表
"""
from sqlalchemy import (
Column, Integer, String, Text, Float, Boolean, DateTime, Date,
ForeignKey, Index, UniqueConstraint, BigInteger
)
from sqlalchemy.orm import relationship
from sqlalchemy.ext.declarative import declarative_base
from datetime import datetime, date, timedelta
import hashlib
import json
# 使用與其他模型相同的 Base
from database.models import Base
class TrendRecord(Base):
"""趨勢資料記錄 - 儲存爬取的原始內容"""
__tablename__ = 'trend_records'
id = Column(Integer, primary_key=True)
# 來源識別
source = Column(String(50), nullable=False, index=True)
# 可選值: 'google_news', 'ptt', 'dcard', 'youtube', 'weather', 'ollama_web_search'
source_board = Column(String(100))
# PTT/Dcard 看板名稱,如 'Gossiping', '網路購物'
source_url = Column(String(500))
# 原始連結
source_id = Column(String(100))
# 來源平台的唯一識別碼 (用於去重)
# 內容
title = Column(String(500), nullable=False)
content = Column(Text)
# 全文內容或摘要
author = Column(String(100))
# 作者/媒體名稱
# 互動指標
popularity_score = Column(Integer, default=0)
# 熱門度分數 (推數、讚數、觀看數等)
comment_count = Column(Integer, default=0)
# 留言數
# 分類標籤
category = Column(String(100), index=True)
# 商品分類對應: '美妝', '3C', '家電', '服飾' 等
tags = Column(Text)
# JSON 格式的標籤列表
# 時間資訊
published_at = Column(DateTime)
# 原始發布時間
trend_date = Column(Date, nullable=False, index=True)
# 趨勢所屬日期 (用於聚合查詢)
created_at = Column(DateTime, default=datetime.now)
# 爬取時間
# AI 分析結果
sentiment = Column(String(20))
# 情緒分析: 'positive', 'negative', 'neutral'
ai_summary = Column(Text)
# Ollama 生成的摘要
relevance_score = Column(Float, default=0.0)
# 與商品銷售的相關性分數 (0-1)
# 索引優化
__table_args__ = (
Index('idx_trend_source_date', 'source', 'trend_date'),
Index('idx_trend_category_date', 'category', 'trend_date'),
Index('idx_trend_popularity', 'popularity_score', 'trend_date'),
UniqueConstraint('source', 'source_id', name='uq_source_record'),
)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'source': self.source,
'source_board': self.source_board,
'source_url': self.source_url,
'title': self.title,
'content': self.content[:200] if self.content else None,
'author': self.author,
'popularity_score': self.popularity_score,
'comment_count': self.comment_count,
'category': self.category,
'tags': json.loads(self.tags) if self.tags else [],
'published_at': self.published_at.isoformat() if self.published_at else None,
'trend_date': self.trend_date.isoformat() if self.trend_date else None,
'sentiment': self.sentiment,
'ai_summary': self.ai_summary,
'relevance_score': self.relevance_score,
}
class TrendKeyword(Base):
"""趨勢關鍵字 - 從文章中萃取的熱門詞彙"""
__tablename__ = 'trend_keywords'
id = Column(Integer, primary_key=True)
keyword = Column(String(100), nullable=False, index=True)
# 關鍵字
keyword_type = Column(String(50), default='general')
# 類型: 'product' (商品), 'brand' (品牌), 'event' (事件), 'general'
source = Column(String(50), nullable=False)
# 來源平台
category = Column(String(100), index=True)
# 商品分類
mention_count = Column(Integer, default=1)
# 提及次數
trend_date = Column(Date, nullable=False, index=True)
# 趨勢日期
sentiment_avg = Column(Float, default=0.0)
# 平均情緒分數 (-1 到 1)
related_keywords = Column(Text)
# JSON 格式的相關關鍵字
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
__table_args__ = (
Index('idx_keyword_date_count', 'trend_date', 'mention_count'),
UniqueConstraint('keyword', 'source', 'trend_date', name='uq_keyword_source_date'),
)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'keyword': self.keyword,
'keyword_type': self.keyword_type,
'source': self.source,
'category': self.category,
'mention_count': self.mention_count,
'trend_date': self.trend_date.isoformat() if self.trend_date else None,
'sentiment_avg': self.sentiment_avg,
'related_keywords': json.loads(self.related_keywords) if self.related_keywords else [],
}
class TrendAnalysis(Base):
"""趨勢分析報告 - Ollama AI 生成的分析結果"""
__tablename__ = 'trend_analysis'
id = Column(Integer, primary_key=True)
analysis_date = Column(Date, nullable=False, index=True)
# 分析日期
category = Column(String(100), index=True)
# 分析的商品分類 (null 表示全品類)
analysis_type = Column(String(50), nullable=False)
# 分析類型: 'daily_summary', 'weekly_trend', 'hot_topic', 'marketing_insight'
# AI 分析內容
summary = Column(Text, nullable=False)
# 摘要說明
hot_keywords = Column(Text)
# JSON: 熱門關鍵字列表
hot_topics = Column(Text)
# JSON: 熱門話題列表
consumer_insights = Column(Text)
# JSON: 消費者洞察
marketing_suggestions = Column(Text)
# JSON: 行銷建議
copywriting_hints = Column(Text)
# JSON: 文案撰寫提示
# 來源統計
source_stats = Column(Text)
# JSON: 各來源資料統計
record_count = Column(Integer, default=0)
# 分析涵蓋的記錄數
# Ollama 資訊
model_used = Column(String(50))
# 使用的模型
generation_time = Column(Float)
# 生成耗時 (秒)
created_at = Column(DateTime, default=datetime.now)
__table_args__ = (
UniqueConstraint('analysis_date', 'category', 'analysis_type', name='uq_analysis'),
)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'analysis_date': self.analysis_date.isoformat() if self.analysis_date else None,
'category': self.category,
'analysis_type': self.analysis_type,
'summary': self.summary,
'hot_keywords': json.loads(self.hot_keywords) if self.hot_keywords else [],
'hot_topics': json.loads(self.hot_topics) if self.hot_topics else [],
'consumer_insights': json.loads(self.consumer_insights) if self.consumer_insights else [],
'marketing_suggestions': json.loads(self.marketing_suggestions) if self.marketing_suggestions else [],
'copywriting_hints': json.loads(self.copywriting_hints) if self.copywriting_hints else [],
'source_stats': json.loads(self.source_stats) if self.source_stats else {},
'record_count': self.record_count,
'model_used': self.model_used,
'generation_time': self.generation_time,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
class WebSearchCache(Base):
"""Web Search 結果快取 - 避免重複查詢"""
__tablename__ = 'web_search_cache'
id = Column(Integer, primary_key=True)
# 查詢識別
query_hash = Column(String(64), nullable=False, unique=True, index=True)
# MD5(query + search_type)
query = Column(String(500), nullable=False)
# 原始查詢字串
search_type = Column(String(50), default='general')
# 搜尋類型: general, news, shopping, trends
# 結果
result_json = Column(Text, nullable=False)
# JSON 格式的完整結果
summary = Column(Text)
# AI 生成的摘要
result_count = Column(Integer, default=0)
# 結果數量
# 元資料
model_used = Column(String(50))
generation_time = Column(Float)
# 時間
created_at = Column(DateTime, default=datetime.now, index=True)
expires_at = Column(DateTime)
# 快取過期時間 (預設 24 小時)
__table_args__ = (
Index('idx_cache_query_type', 'query', 'search_type'),
Index('idx_cache_expires', 'expires_at'),
)
@staticmethod
def generate_hash(query: str, search_type: str) -> str:
"""產生查詢雜湊"""
return hashlib.md5(f"{query}:{search_type}".encode(), usedforsecurity=False).hexdigest()
def is_expired(self) -> bool:
"""檢查是否已過期"""
if not self.expires_at:
return True
return datetime.now() > self.expires_at
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'query': self.query,
'search_type': self.search_type,
'result': json.loads(self.result_json) if self.result_json else None,
'summary': self.summary,
'result_count': self.result_count,
'model_used': self.model_used,
'generation_time': self.generation_time,
'created_at': self.created_at.isoformat() if self.created_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'is_expired': self.is_expired(),
}
class TelegramUser(Base):
"""Telegram 用戶綁定表"""
__tablename__ = 'telegram_users'
id = Column(Integer, primary_key=True)
telegram_id = Column(BigInteger, unique=True, nullable=False, index=True)
# Telegram 用戶 ID
telegram_username = Column(String(100))
# Telegram 用戶名稱
user_id = Column(Integer, ForeignKey('users.id'))
# 綁定的系統用戶 ID (可選)
display_name = Column(String(100))
# 顯示名稱
is_active = Column(Boolean, default=True)
# 是否啟用
is_admin = Column(Boolean, default=False)
# 是否為管理員
# 偏好設定
notify_trends = Column(Boolean, default=True)
# 是否接收趨勢通知
notify_daily_summary = Column(Boolean, default=True)
# 是否接收每日摘要
preferred_categories = Column(Text)
# JSON: 偏好的分類列表
created_at = Column(DateTime, default=datetime.now)
last_active_at = Column(DateTime, default=datetime.now)
def to_dict(self):
"""轉換為字典"""
return {
'id': self.id,
'telegram_id': self.telegram_id,
'telegram_username': self.telegram_username,
'user_id': self.user_id,
'display_name': self.display_name,
'is_active': self.is_active,
'is_admin': self.is_admin,
'notify_trends': self.notify_trends,
'notify_daily_summary': self.notify_daily_summary,
'preferred_categories': json.loads(self.preferred_categories) if self.preferred_categories else [],
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_active_at': self.last_active_at.isoformat() if self.last_active_at else None,
}
# PTT 目標看板
PTT_BOARDS = [
'Gossiping', # 八卦板 - 熱門話題
'Lifeismoney', # 省錢板 - 優惠情報
'e-shopping', # 網購板 - 電商趨勢
'Beauty', # 美妝板 - 美妝趨勢
'MakeUp', # 化妝板 - 彩妝趨勢
'WomenTalk', # 女板 - 女性消費趨勢
'home-sale', # 房屋板 - 居家用品參考
'BabyMother', # 媽寶板 - 母嬰市場
'Tech_Job', # 科技業 - 3C 消費力
]
# Dcard 目標看板
DCARD_BOARDS = [
'網路購物', # 電商討論
'美妝', # 美妝趨勢
'穿搭', # 服飾趨勢
'3C', # 科技產品
'省錢', # 優惠情報
'生活', # 生活趨勢
'美食', # 餐飲趨勢
]
# 看板對應分類
BOARD_CATEGORY_MAPPING = {
# PTT
'Beauty': '美妝',
'MakeUp': '美妝',
'e-shopping': '電商',
'Lifeismoney': '優惠',
'home-sale': '居家',
'BabyMother': '母嬰',
'Gossiping': '熱門',
'WomenTalk': '生活',
'Tech_Job': '3C',
# Dcard
'美妝': '美妝',
'穿搭': '服飾',
'3C': '3C',
'網路購物': '電商',
'省錢': '優惠',
'生活': '生活',
'美食': '美食',
}
def get_category_for_board(board: str) -> str:
"""根據看板名稱取得商品分類"""
return BOARD_CATEGORY_MAPPING.get(board, '其他')