Some checks failed
CD Pipeline / deploy (push) Failing after 59s
- 建立 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>
418 lines
13 KiB
Python
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, '其他')
|