TongcyDai

stats_updater_base.py

Dec 4th, 2025 (edited)
12
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 15.87 KB | None | 0 0
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 統計更新器共享基類
  5. 為維基詞典和維基百科統計更新器提供共享功能
  6. """
  7.  
  8. import re
  9. import time
  10. import csv
  11. import io
  12. import logging
  13. from datetime import datetime, timezone, timedelta
  14. from typing import List, Optional, Dict
  15. import requests
  16. import mwclient
  17. from abc import ABC, abstractmethod
  18.  
  19. class BaseStatsUpdater(ABC):
  20.     """統計更新器基類"""
  21.    
  22.     def __init__(self, username: str, password: str, site_domain: str,
  23.                  target_languages: List[str], csv_api_url: str,
  24.                  page_title: str, log_filename: str):
  25.         """
  26.        初始化基類
  27.        
  28.        Args:
  29.            username: 用戶名
  30.            password: 密碼
  31.            site_domain: 網站域名
  32.            target_languages: 目標語言代碼列表
  33.            csv_api_url: CSV API URL
  34.            page_title: 目標頁面標題
  35.            log_filename: 日誌文件名
  36.        """
  37.         self.username = username
  38.         self.password = password
  39.         self.site_domain = site_domain
  40.         self.site = None
  41.         self.target_languages = target_languages
  42.         self.csv_url = csv_api_url
  43.         self.page_title = page_title
  44.         self.log_filename = log_filename
  45.        
  46.         # 設置日誌
  47.         self._setup_logging()
  48.        
  49.         self.logger = logging.getLogger(f"{self.__class__.__name__}")
  50.        
  51.     def _setup_logging(self):
  52.         """設置日誌配置"""
  53.         # 清除可能存在的 handlers 避免重複
  54.         for handler in logging.root.handlers[:]:
  55.             logging.root.removeHandler(handler)
  56.            
  57.         logging.basicConfig(
  58.             level=logging.INFO,
  59.             format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
  60.             handlers=[
  61.                 logging.FileHandler(self.log_filename, encoding='utf-8'),
  62.                 logging.StreamHandler()
  63.             ]
  64.         )
  65.    
  66.     def connect_to_wiki(self) -> bool:
  67.         """連接到維基網站"""
  68.         try:
  69.             self.site = mwclient.Site(self.site_domain)
  70.             self.site.login(self.username, self.password)
  71.             self.logger.info(f"成功連接到 {self.site_domain}")
  72.             return True
  73.         except Exception as e:
  74.             self.logger.error(f"連接 {self.site_domain} 失敗: {e}")
  75.             return False
  76.    
  77.     def fetch_wikistats_data(self, max_retries: int = 5) -> Optional[List[int]]:
  78.         """
  79.        從 wikistats CSV API 獲取數據
  80.        
  81.        Args:
  82.            max_retries: 最大重試次數
  83.            
  84.        Returns:
  85.            各語言的條目數量列表,或 None(如果失敗)
  86.        """
  87.         for attempt in range(max_retries):
  88.             try:
  89.                 self.logger.info(f"第 {attempt + 1} 次嘗試從 wikistats CSV API 獲取數據...")
  90.                
  91.                 headers = {
  92.                     'User-Agent': f'{self.__class__.__name__}/1.0 (https://{self.site_domain}/wiki/User:{self.username})'
  93.                 }
  94.                 response = requests.get(self.csv_url, headers=headers, timeout=30)
  95.                 response.raise_for_status()
  96.                
  97.                 self.logger.info(f"成功獲取 CSV 數據,大小: {len(response.text)} 字符")
  98.                
  99.                 # 解析 CSV 數據
  100.                 stats_data = self._parse_csv_data(response.text)
  101.                 if stats_data:
  102.                     self.logger.info(f"成功解析統計數據: {stats_data}")
  103.                     return stats_data
  104.                 else:
  105.                     self.logger.error("無法解析CSV數據")
  106.                     if attempt < max_retries - 1:
  107.                         time.sleep(60)  # 等待1分鐘後重試
  108.                         continue
  109.                     else:
  110.                         return None
  111.                        
  112.             except Exception as e:
  113.                 self.logger.error(f"獲取數據失敗 (嘗試 {attempt + 1}): {e}")
  114.                 if attempt < max_retries - 1:
  115.                     time.sleep(60)  # 等待1分鐘後重試
  116.                    
  117.         return None
  118.    
  119.     def _parse_csv_data(self, csv_content: str) -> Optional[List[int]]:
  120.         """解析CSV數據並提取目標語言的統計數據"""
  121.         try:
  122.             self.logger.info("開始解析 CSV 數據...")
  123.            
  124.             # 解析CSV
  125.             csv_reader = csv.DictReader(io.StringIO(csv_content))
  126.            
  127.             # 記錄 CSV 欄位
  128.             fieldnames = csv_reader.fieldnames
  129.             self.logger.info(f"CSV 欄位: {fieldnames}")
  130.            
  131.             # 存儲找到的數據,按排名排序
  132.             all_entries = []
  133.            
  134.             for row in csv_reader:
  135.                 try:
  136.                     # 嘗試多種可能的欄位名稱
  137.                     prefix = None
  138.                     good_count = None
  139.                    
  140.                     # 查找語言代碼欄位
  141.                     for field in ['prefix', 'lang', 'language', 'code']:
  142.                         if field in row and row[field]:
  143.                             prefix = row[field].strip()
  144.                             break
  145.                    
  146.                     # 查找條目數量欄位  
  147.                     for field in ['good', 'articles', 'entries', 'pages']:
  148.                         if field in row and row[field]:
  149.                             try:
  150.                                 good_count = int(row[field])
  151.                                 break
  152.                             except (ValueError, TypeError):
  153.                                 continue
  154.                    
  155.                     if prefix and good_count is not None:
  156.                         all_entries.append({'prefix': prefix, 'good': good_count, 'row': row})
  157.                         self.logger.debug(f"找到條目: {prefix} = {good_count}")
  158.                        
  159.                 except Exception as e:
  160.                     self.logger.debug(f"解析行時出錯: {e}, 行數據: {row}")
  161.                     continue
  162.            
  163.             project_type = "維基詞典" if "wiktionar" in self.csv_url else "維基百科"
  164.             self.logger.info(f"共解析到 {len(all_entries)} 個{project_type}條目")
  165.            
  166.             # 按條目數量排序(降序)
  167.             all_entries.sort(key=lambda x: x['good'], reverse=True)
  168.            
  169.             # 顯示前20名以供參考(確保涵蓋所有目標語言)
  170.             display_count = min(20, len(all_entries))
  171.             self.logger.info(f"前{display_count}名{project_type}:")
  172.             for i, entry in enumerate(all_entries[:display_count]):
  173.                 self.logger.info(f"  {i+1}. {entry['prefix']}: {entry['good']:,} 條目")
  174.            
  175.             # 查找目標語言並按排名獲取
  176.             result = []
  177.             found_languages = {}
  178.            
  179.             for entry in all_entries:
  180.                 if entry['prefix'] in self.target_languages:
  181.                     found_languages[entry['prefix']] = entry['good']
  182.            
  183.             # 按照目標順序排列數據
  184.             for lang in self.target_languages:
  185.                 if lang in found_languages:
  186.                     result.append(found_languages[lang])
  187.                     self.logger.info(f"目標語言 {lang}: {found_languages[lang]:,} 條目")
  188.                 else:
  189.                     self.logger.error(f"未找到語言 {lang} 的統計數據")
  190.                     # 顯示可用的語言代碼以供調試
  191.                     available_codes = [entry['prefix'] for entry in all_entries[:30]]
  192.                     self.logger.error(f"可用的前30個語言代碼: {available_codes}")
  193.                     return None
  194.            
  195.             return result if len(result) == len(self.target_languages) else None
  196.            
  197.         except Exception as e:
  198.             self.logger.error(f"解析CSV數據時出錯: {e}")
  199.             # 顯示 CSV 內容的開頭部分以供調試
  200.             if csv_content:
  201.                 self.logger.error(f"CSV 內容開頭: {csv_content[:500]}")
  202.             return None
  203.    
  204.     def _is_data_line(self, line: str) -> bool:
  205.         """
  206.        判斷一行是否為數字數據行
  207.        數據行特徵:以|開頭,主要包含純數字,而非文字描述
  208.        """
  209.         line = line.strip()
  210.         if not line.startswith('|'):
  211.             return False
  212.        
  213.         # 移除首尾的 |,分割內容
  214.         content = line.strip('|').strip()
  215.         if not content:
  216.             return False
  217.        
  218.         # 分割各個字段
  219.         fields = [field.strip() for field in content.split('|')]
  220.        
  221.         # 檢查是否主要包含純數字
  222.         numeric_fields = 0
  223.         total_fields = len(fields)
  224.        
  225.         for field in fields:
  226.             if field.isdigit():
  227.                 numeric_fields += 1
  228.        
  229.         # 如果超過一半的字段是數字,則認為是數據行
  230.         if total_fields > 0 and numeric_fields / total_fields > 0.5:
  231.             return True
  232.        
  233.         # 額外檢查:如果行中包含明顯的中文描述詞,則不是數據行
  234.         chinese_description_patterns = ['增长', '发展', '缓慢', '稳定', '增大', '缩小']
  235.         for pattern in chinese_description_patterns:
  236.             if pattern in content:
  237.                 return False
  238.        
  239.         return False
  240.    
  241.     def update_wiki_page(self, stats: List[int]) -> bool:
  242.         """更新維基頁面"""
  243.         try:
  244.             page = self.site.pages[self.page_title]
  245.             current_content = page.text()
  246.            
  247.             # 獲取當前日期
  248.             today = datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d')
  249.            
  250.             # 構建新的統計行
  251.             stats_line = f"| {' | '.join(map(str, stats))}"
  252.            
  253.             # 查找 autoStat 模板
  254.             autostat_pattern = r'({{autoStat\s*\n.*?\n)(}})'
  255.             match = re.search(autostat_pattern, current_content, re.DOTALL)
  256.            
  257.             if not match:
  258.                 self.logger.error("未找到 autoStat 模板")
  259.                 # 提供頁面內容的調試信息
  260.                 self.logger.info("正在分析頁面內容格式...")
  261.                
  262.                 # 檢查是否有其他可能的模板格式
  263.                 possible_templates = re.findall(r'{{[^}]*}}', current_content)
  264.                 if possible_templates:
  265.                     self.logger.info(f"找到的模板: {possible_templates[:5]}")  # 只顯示前5個
  266.                 else:
  267.                     self.logger.info("頁面中未找到任何模板")
  268.                
  269.                 # 顯示頁面內容片段以供調試
  270.                 content_preview = current_content[:500] if len(current_content) > 500 else current_content
  271.                 self.logger.info(f"頁面內容預覽(前500字符):\n{content_preview}")
  272.                 return False
  273.            
  274.             template_content = match.group(1)  # autoStat 模板內容
  275.            
  276.             # 檢查統計行是否已經存在
  277.             if stats_line in template_content:
  278.                 self.logger.info(f"統計數據已存在於頁面中: {stats_line}")
  279.                 self.logger.info("跳過編輯,無需更新")
  280.                 return True
  281.            
  282.             # 檢查是否有相同數字組合但格式稍有不同的情況
  283.             # 創建用於比較的數字字符串(去除空格)
  284.             stats_numbers = ''.join(map(str, stats))
  285.             template_lines = template_content.split('\n')
  286.            
  287.             for line in template_lines:
  288.                 if line.strip().startswith('|'):
  289.                     # 提取該行的數字(去除空格和分隔符)
  290.                     line_numbers = re.sub(r'[^\d]', '', line)
  291.                     if line_numbers == stats_numbers:
  292.                         self.logger.info(f"發現相同的統計數據(格式略有不同):")
  293.                         self.logger.info(f"  頁面中已有: {line.strip()}")
  294.                         self.logger.info(f"  欲添加的: {stats_line}")
  295.                         self.logger.info("跳過編輯,無需更新")
  296.                         return True
  297.            
  298.             # 數據不存在,進行更新
  299.             self.logger.info(f"統計數據不存在,準備添加: {stats_line}")
  300.            
  301.             # 找到最後一行數字數據的位置
  302.             template_lines = template_content.split('\n')
  303.             last_data_line_index = -1
  304.            
  305.             # 從後往前搜索,找到最後一行包含純數字數據的行
  306.             for i in range(len(template_lines) - 1, -1, -1):
  307.                 line = template_lines[i].strip()
  308.                 if line.startswith('|') and self._is_data_line(line):
  309.                     last_data_line_index = i
  310.                     break
  311.            
  312.             if last_data_line_index == -1:
  313.                 self.logger.error("未找到數據行,無法插入新統計數據")
  314.                 return False
  315.            
  316.             self.logger.info(f"找到最後一行數據: {template_lines[last_data_line_index].strip()}")
  317.             self.logger.info(f"將在第 {last_data_line_index + 1} 行後插入新數據")
  318.            
  319.             # 重構模板內容:在最後一行數據後插入新行
  320.             new_template_lines = (
  321.                 template_lines[:last_data_line_index + 1] +  # 包含最後一行數據
  322.                 [stats_line] +                               # 新統計行
  323.                 template_lines[last_data_line_index + 1:]    # 其餘內容(如描述行)
  324.             )
  325.            
  326.             new_template_content = '\n'.join(new_template_lines)
  327.            
  328.             # 構建完整的新內容
  329.             before_template = current_content[:match.start()]
  330.             template_end = match.group(2)      # }}
  331.             after_template = current_content[match.end():]  # 模板之後的所有內容
  332.            
  333.             new_content = before_template + new_template_content + template_end + after_template
  334.            
  335.             self.logger.info(f"頁面更新預覽:")
  336.             self.logger.info(f"  原始內容長度: {len(current_content)} 字符")
  337.             self.logger.info(f"  新內容長度: {len(new_content)} 字符")
  338.             self.logger.info(f"  添加的統計行: {stats_line}")
  339.            
  340.             # 保存頁面
  341.             edit_summary = self._get_edit_summary(today)
  342.             page.save(new_content, summary=edit_summary, minor=True, bot=self._is_bot_account())
  343.            
  344.             self.logger.info(f"成功更新頁面,添加數據: {stats_line}")
  345.             return True
  346.            
  347.         except Exception as e:
  348.             self.logger.error(f"更新維基頁面失敗: {e}")
  349.             return False
  350.    
  351.     @abstractmethod
  352.     def _get_edit_summary(self, date: str) -> str:
  353.         """獲取編輯摘要(子類實現)"""
  354.         pass
  355.    
  356.     @abstractmethod
  357.     def _is_bot_account(self) -> bool:
  358.         """是否為機器人帳號(子類實現)"""
  359.         pass
  360.    
  361.     def run(self):
  362.         """執行主要任務"""
  363.         project_type = "維基詞典" if "wiktionary" in self.csv_url else "維基百科"
  364.         self.logger.info(f"開始執行{project_type}統計更新任務")
  365.        
  366.         # 連接到維基
  367.         if not self.connect_to_wiki():
  368.             return False
  369.        
  370.         # 獲取統計數據
  371.         stats = self.fetch_wikistats_data()
  372.         if not stats:
  373.             self.logger.error("無法獲取統計數據,任務失敗")
  374.             return False
  375.        
  376.         # 更新頁面
  377.         if self.update_wiki_page(stats):
  378.             self.logger.info("任務成功完成!")
  379.             return True
  380.         else:
  381.             self.logger.error("更新頁面失敗")
  382.             return False
Advertisement
Add Comment
Please, Sign In to add comment