diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..2ad3d6c --- /dev/null +++ b/.env.development @@ -0,0 +1,100 @@ +# ============================================================================== +# 开发环境配置文件 +# ============================================================================== + +# ---------- 环境标识 ---------- +ENV=development + +# ---------- AdsPower浏览器配置 ---------- +# AdsPower API地址(本地默认端口50325) +ADSPOWER_API_URL=http://127.0.0.1:50325 + +# AdsPower用户ID(登录AdsPower后台获取) +ADSPOWER_USER_ID=user_h235l72 + +# AdsPower API密钥(可选,某些版本需要) +ADSPOWER_API_KEY=e5afd5a4cead5589247febbeabc39bcb + +# ---------- 服务配置 ---------- +# 服务监听地址(0.0.0.0为允许外部访问) +SERVER_HOST=127.0.0.1 + +# 服务监听端口 +SERVER_PORT=5000 + +# ---------- 点击策略配置 ---------- +# 每个站点每日最少点击次数 +MIN_CLICK_COUNT=1 + +# 每个站点每日最多点击次数 +MAX_CLICK_COUNT=3 + +# 同一站点两次点击之间的间隔(分钟) +CLICK_INTERVAL_MINUTES=5 + +# 不同站点任务间最小间隔(分钟) +MIN_TASK_INTERVAL_MINUTES=1 + +# 不同站点任务间最大间隔(分钟) +MAX_TASK_INTERVAL_MINUTES=1 + +# 最大并发执行任务数(1为串行执行) +MAX_CONCURRENT_WORKERS=2 + +# 工作开始时间(小时,24小时制) +WORK_START_HOUR=9 + +# 工作结束时间(小时,24小时制) +WORK_END_HOUR=23 + +# 回复等待超时时间(秒) +REPLY_WAIT_TIMEOUT=10 + +# ---------- 爬虫调度配置 ---------- +# 是否启用爬虫定时任务(True/False) +CRAWLER_ENABLED=False + +# 爬虫执行时间(HH:MM格式,24小时制) +CRAWLER_SCHEDULE_TIME=02:00 + +# 每次爬取的任务数量 +CRAWLER_BATCH_SIZE=10 + +# ---------- 数据存储路径 ---------- +# 数据目录(开发环境与生产环境分离) +DATA_DIR=./data_dev + +# 日志目录(开发环境与生产环境分离) +LOG_DIR=./logs_dev + +# Query挖掘上传目录 +QUERY_UPLOAD_DIR=./query_upload + +# ---------- 调试配置 ---------- +# 是否开启调试模式(True/False) +DEBUG=True + +# ---------- 测试配置 ---------- +# 测试完成后是否自动关闭浏览器(True/False) +AUTO_CLOSE_BROWSER=True + +# ---------- MySQL数据库配置 ---------- +# 数据库主机地址 +MYSQL_HOST=localhost + +# 数据库端口 +MYSQL_PORT=3306 + +# 数据库用户名 +MYSQL_USER=root + +# 数据库密码 +MYSQL_PASSWORD=JKjk20011115 + +# 数据库名称 +MYSQL_DATABASE=ai_article + + +QWEN_API_KEY=sk-6d22dd845a624d9c92a821d24a50e2e8 + +QWEN_API_URL=https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..5f0f5ff --- /dev/null +++ b/.env.production @@ -0,0 +1,100 @@ +# ============================================================================== +# 生产环境配置文件 +# ============================================================================== + +# ---------- 环境标识 ---------- +ENV=production + +# ---------- AdsPower浏览器配置 ---------- +# AdsPower API地址(本地默认端口50325) +ADSPOWER_API_URL=http://127.0.0.1:50325 + +# AdsPower用户ID(登录AdsPower后台获取) +ADSPOWER_USER_ID=user_h23kr4w + +# AdsPower API密钥(可选,某些版本需要) +ADSPOWER_API_KEY=4f0329bfdfe85c48370c9970bab9d684 + +# ---------- 服务配置 ---------- +# 服务监听地址(0.0.0.0为允许外部访问) +SERVER_HOST=0.0.0.0 + +# 服务监听端口 +SERVER_PORT=8090 + +# ---------- 点击策略配置 ---------- +# 每个站点每日最少点击次数 +MIN_CLICK_COUNT=1 + +# 每个站点每日最多点击次数 +MAX_CLICK_COUNT=3 + +# 同一站点两次点击之间的间隔(分钟) +CLICK_INTERVAL_MINUTES=30 + +# 不同站点任务间最小间隔(分钟) +MIN_TASK_INTERVAL_MINUTES=3 + +# 不同站点任务间最大间隔(分钟) +MAX_TASK_INTERVAL_MINUTES=5 + +# 最大并发执行任务数(1为串行执行) +MAX_CONCURRENT_WORKERS=3 + +# 工作开始时间(小时,24小时制) +WORK_START_HOUR=9 + +# 工作结束时间(小时,24小时制) +WORK_END_HOUR=21 + +# 回复等待超时时间(秒) +REPLY_WAIT_TIMEOUT=30 + +# ---------- 爬虫调度配置 ---------- +# 是否启用爬虫定时任务(True/False) +CRAWLER_ENABLED=False + +# 爬虫执行时间(HH:MM格式,24小时制) +CRAWLER_SCHEDULE_TIME=02:00 + +# 每次爬取的任务数量 +CRAWLER_BATCH_SIZE=10 + +# ---------- 数据存储路径 ---------- +# 数据目录(生产环境) +DATA_DIR=/home/work/ai_mip/data + +# 日志目录(生产环境) +LOG_DIR=/home/work/ai_mip/logs + +# Query挖掘上传目录 +QUERY_UPLOAD_DIR=/home/work/ai_mip/query_upload + +# ---------- 调试配置 ---------- +# 是否开启调试模式(True/False) +DEBUG=False + +# ---------- 测试配置 ---------- +# 测试完成后是否自动关闭浏览器(True/False) +AUTO_CLOSE_BROWSER=True + +# ---------- MySQL数据库配置 ---------- +# 数据库主机地址 +MYSQL_HOST=8.149.233.36 + +# 数据库端口 +MYSQL_PORT=3306 + +# 数据库用户名 +MYSQL_USER=ai_article_read + +# 数据库密码 +MYSQL_PASSWORD=7aK_H2yvokVumr84lLNDt8fDBp6P + +# 数据库名称 +MYSQL_DATABASE=ai_article + +# ---------- 分布式部署配置 ---------- +# 远程点击服务地址(仅Web服务需要配置) +# 为空表示本地模式,设置URL表示远程模式 +CLICK_SERVICE_URL=http://60.205.132.82:8090 diff --git a/ad_automation.py b/ad_automation.py index abe2a19..0e5597b 100644 --- a/ad_automation.py +++ b/ad_automation.py @@ -1,7 +1,10 @@ import time import random -from typing import Optional, Tuple, List -from playwright.sync_api import Page, ElementHandle +import json +import re +import requests +from typing import Optional, Tuple, List, Dict +from playwright.sync_api import Page, ElementHandle, Response from loguru import logger from config import Config from pathlib import Path @@ -20,18 +23,393 @@ class MIPAdAutomation: "最近很不舒服,也说不出来全部的症状,能不能直接对话医生?" ] + # Ada平台API端点 + ADA_HEARTBEAT_API = 'ada.baidu.com/gateway/message/heartbeat' + ADA_RECOMMEND_API = 'ada.baidu.com/imlp-extend/agent/getRecommendContent' + def __init__(self, page: Page, task_index: int = None): self.page = page self.site_id = None # 当前站点ID self.click_id = None # 当前点击ID self.task_folder = None # 任务日志目录 + # 医生回复相关 + self.doctor_replies: List[Dict] = [] # 存储医生回复 + self.recommend_replies: List[Dict] = [] # 存储推荐回复 + self._response_listener_active = False # 响应监听器状态 + + # 聊天历史(用于AI对话上下文) + self.chat_history: List[Dict] = [] + + # 浮窗状态 + self._overlay_injected = False + self.task_index = task_index + # 创建任务日志目录 if task_index: timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') self.task_folder = Path("./test") / f"task_{task_index}_{timestamp}" self.task_folder.mkdir(parents=True, exist_ok=True) logger.info(f"任务日志目录: {self.task_folder}") + + def _inject_overlay(self): + """注入浮窗到页面""" + if self._overlay_injected: + return + + try: + js_code = """ + (function() { + // 检查是否已存在 + if (document.getElementById('mip-progress-overlay')) return; + + // 创建浮窗容器 + var overlay = document.createElement('div'); + overlay.id = 'mip-progress-overlay'; + overlay.style.cssText = ` + position: fixed; + top: 10px; + right: 10px; + width: 280px; + background: rgba(0, 0, 0, 0.85); + color: #fff; + padding: 12px 15px; + border-radius: 8px; + font-family: 'Microsoft YaHei', sans-serif; + font-size: 13px; + z-index: 999999; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + line-height: 1.6; + `; + + // 标题 + var title = document.createElement('div'); + title.style.cssText = 'font-weight: bold; font-size: 14px; margin-bottom: 8px; color: #4CAF50; border-bottom: 1px solid #444; padding-bottom: 6px;'; + title.innerHTML = '🤖 自动化进度'; + overlay.appendChild(title); + + // 状态内容 + var content = document.createElement('div'); + content.id = 'mip-progress-content'; + content.innerHTML = '初始化中...'; + overlay.appendChild(content); + + document.body.appendChild(overlay); + })(); + """ + self.page.evaluate(js_code) + self._overlay_injected = True + logger.debug("浮窗已注入页面") + except Exception as e: + logger.debug(f"注入浮窗失败: {str(e)}") + + def _update_overlay(self, status: str, details: str = ""): + """ + 更新浮窗显示内容 + + Args: + status: 当前状态 + details: 详细信息 + """ + try: + self._inject_overlay() + + task_info = f"任务 #{self.task_index}" if self.task_index else "任务" + doctor_count = len(self.doctor_replies) + recommend_count = len(self.recommend_replies) + + html = f""" +
{task_info}
+
📍 {status}
+
{details}
+
+ 医生消息: {doctor_count} | 推荐回复: {recommend_count} +
+ """ + + js_code = f""" + (function() {{ + var content = document.getElementById('mip-progress-content'); + if (content) {{ + content.innerHTML = `{html}`; + }} + }})(); + """ + self.page.evaluate(js_code) + except Exception as e: + logger.debug(f"更新浮窗失败: {str(e)}") + + def _remove_overlay(self): + """移除浮窗""" + try: + js_code = """ + (function() { + var overlay = document.getElementById('mip-progress-overlay'); + if (overlay) overlay.remove(); + })(); + """ + self.page.evaluate(js_code) + self._overlay_injected = False + except: + pass + + def _human_click(self, element, description: str = ""): + """ + 模拟真人点击(随机偏移、随机延迟) + + Args: + element: 要点击的元素 + description: 描述信息 + """ + try: + box = element.bounding_box() + if not box: + element.click() + return + + # 随机偏移(不要点在正中心) + offset_x = random.uniform(-box['width'] * 0.3, box['width'] * 0.3) + offset_y = random.uniform(-box['height'] * 0.3, box['height'] * 0.3) + + click_x = box['x'] + box['width'] / 2 + offset_x + click_y = box['y'] + box['height'] / 2 + offset_y + + # 先移动鼠标到目标位置(模拟真人移动) + self.page.mouse.move(click_x, click_y, steps=random.randint(5, 15)) + + # 随机延迟后点击 + time.sleep(random.uniform(0.1, 0.3)) + self.page.mouse.click(click_x, click_y) + + if description: + logger.debug(f"真人点击: {description} ({click_x:.0f}, {click_y:.0f})") + + except Exception as e: + logger.debug(f"真人点击失败,使用普通点击: {str(e)}") + element.click() + + def _human_type(self, message: str): + """ + 模拟真人输入(逐字符输入,随机延迟) + + Args: + message: 要输入的消息 + """ + try: + for char in message: + # 随机输入延迟(50-200ms) + delay = random.uniform(0.05, 0.2) + self.page.keyboard.type(char, delay=0) + time.sleep(delay) + + # 偶尔停顿一下(模拟思考) + if random.random() < 0.05: + time.sleep(random.uniform(0.3, 0.8)) + + except Exception as e: + logger.debug(f"真人输入失败,使用普通输入: {str(e)}") + self.page.keyboard.type(message, delay=30) + + def _clean_message_content(self, content: str) -> Optional[str]: + """ + 清理消息内容,过滤HTML标签和JSON命令 + + Args: + content: 原始消息内容 + + Returns: + 清理后的内容,如果是无效消息返回None + """ + if not content: + return None + + # 过滤JSON命令消息 + if content.strip().startswith('{') and '"type":"cmd"' in content: + return None + + # 移除HTML标签 + clean_content = re.sub(r'<[^>]+>', '', content) + + # 去除多余空白 + clean_content = clean_content.strip() + + # 如果清理后为空,返回None + if not clean_content: + return None + + return clean_content + + def _setup_response_listener(self): + """设置API响应监听器,监听heartbeat和recommend接口""" + if self._response_listener_active: + return + + def handle_response(response: Response): + try: + url = response.url + + # 调试:打印所有 ada.baidu.com 的请求 + if 'ada.baidu.com' in url: + logger.debug(f"[API请求] {url[:100]}...") + + # 监听heartbeat API - 获取医生回复 + if self.ADA_HEARTBEAT_API in url and response.status == 200: + try: + data = response.json() + logger.debug(f"[Heartbeat] 响应: {json.dumps(data, ensure_ascii=False)[:500]}") + + if data.get('status') == 0 and data.get('data', {}).get('talk'): + talks = data['data']['talk'] + for talk in talks: + # 检查是否是医生/客服消息 + if talk.get('source') == 'service' or talk.get('msgFrom') == 'service': + raw_content = talk.get('content', '') + msg_id = talk.get('messageId', '') + msg_time = talk.get('messageTime', '') + + # 清理消息内容(过滤HTML和JSON命令) + content = self._clean_message_content(raw_content) + if not content: + logger.debug(f"[Heartbeat] 跳过无效消息: {raw_content[:50]}...") + continue + + # 检查是否是新消息(避免重复) + if not any(r.get('messageId') == msg_id for r in self.doctor_replies): + reply_info = { + 'content': content, + 'messageId': msg_id, + 'messageTime': msg_time, + 'source': 'service', + 'received_at': datetime.now().isoformat() + } + self.doctor_replies.append(reply_info) + logger.info(f"[医生回复] 收到新消息: {content[:100]}...") + except Exception as e: + logger.debug(f"[Heartbeat] 解析响应失败: {str(e)}") + + # 监听recommend API - 获取推荐回复 + elif self.ADA_RECOMMEND_API in url and response.status == 200: + try: + data = response.json() + logger.debug(f"[Recommend] 响应: {json.dumps(data, ensure_ascii=False)[:500]}") + + if data.get('status') == 200 and data.get('data', {}).get('suggestInfo'): + suggest_info = data['data']['suggestInfo'] + for suggest in suggest_info: + replies = suggest.get('suggestReply', []) + for reply in replies: + reply_id = reply.get('replyId', '') + # 避免重复 + if not any(r.get('replyId') == reply_id for r in self.recommend_replies): + self.recommend_replies.append({ + 'replyId': reply_id, + 'text': reply.get('text', ''), + 'value': reply.get('value', ''), + 'type': reply.get('type', 'text'), + 'received_at': datetime.now().isoformat() + }) + if self.recommend_replies: + logger.info(f"[推荐回复] 收到 {len(replies)} 条推荐: {[r['text'] for r in self.recommend_replies[-len(replies):]]}") + except Exception as e: + logger.debug(f"[Recommend] 解析响应失败: {str(e)}") + + except Exception as e: + logger.debug(f"处理响应异常: {str(e)}") + + self.page.on('response', handle_response) + self._response_listener_active = True + logger.info("已启用Ada平台API响应监听") + + def _clear_reply_data(self): + """清空回复数据""" + self.doctor_replies = [] + self.recommend_replies = [] + self.chat_history = [] + self.sent_messages = [] # 记录所有发送的消息 + + def _call_qwen_api(self, doctor_message: str) -> Optional[str]: + """ + 调用千问大模型生成回复 + + Args: + doctor_message: 医生/客服的消息 + + Returns: + 生成的回复内容,失败返回None + """ + try: + # 构建系统提示词 + system_prompt = """你是一个正在咨询医疗问题的普通患者。请根据对方(医生/客服)的回复,以患者身份自然地继续对话。 + +要求: +1. 回复要简短自然,像真实患者一样说话 +2. 可以询问价格、时间、医生资质等问题 +3. 表现出对治疗的关心和一些担忧 +4. 不要暴露自己是AI +5. 回复长度控制在20-50字以内 +6. 使用口语化的表达 +7. 绝对不要提及任何联系方式,包括:微信、电话、QQ、手机号、邮箱等 +8. 如果对方索要联系方式,委婉拒绝或转移话题""" + + # 添加医生消息到历史 + self.chat_history.append({ + "role": "user", # 在API调用中,医生消息作为user角色 + "content": f"[医生/客服说]: {doctor_message}" + }) + + # 构建消息列表 + messages = [{"role": "system", "content": system_prompt}] + messages.extend(self.chat_history) + + # 调用API + headers = { + "Authorization": f"Bearer {Config.QWEN_API_KEY}", + "Content-Type": "application/json" + } + + payload = { + "model": "qwen-turbo", + "messages": messages, + "temperature": 0.8, + "max_tokens": 150 + } + + logger.info(f"调用千问API,医生消息: {doctor_message[:50]}...") + response = requests.post( + Config.QWEN_API_URL, + headers=headers, + json=payload, + timeout=30 + ) + + if response.status_code == 200: + result = response.json() + reply = result.get('choices', [{}])[0].get('message', {}).get('content', '') + if reply: + # 检查回复是否包含敏感词(联系方式相关) + sensitive_words = ['微信', 'wx', 'WeChat', '电话', '手机', 'QQ', '邮箱', '@', '加我', '联系方式'] + if any(word.lower() in reply.lower() for word in sensitive_words): + logger.warning(f"AI回复包含敏感词,已过滤: {reply}") + # 返回一个安全的默认回复 + reply = "好的,我了解了,还想问一下治疗大概需要多长时间呢?" + + # 添加AI回复到历史 + self.chat_history.append({ + "role": "assistant", + "content": reply + }) + logger.info(f"千问API回复: {reply}") + return reply + else: + logger.warning("千问API返回空内容") + return None + else: + logger.error(f"千问API调用失败: {response.status_code} - {response.text}") + return None + + except Exception as e: + logger.error(f"调用千问API异常: {str(e)}") + return None def check_and_click_ad(self, url: str, site_id: int = None) -> Tuple[bool, bool]: """ @@ -46,10 +424,17 @@ class MIPAdAutomation: """ self.site_id = site_id + # 清空之前的回复数据 + self._clear_reply_data() + + # 启用API响应监听 + self._setup_response_listener() + try: # 访问链接(带重试机制) max_retries = 2 page_loaded = False + self._update_overlay("访问页面", url[:50] + "...") for attempt in range(max_retries): try: logger.info(f"访问链接: {url} (第{attempt+1}次尝试)") @@ -81,20 +466,24 @@ class MIPAdAutomation: time.sleep(3) # 检查是否存在商业广告 + self._update_overlay("检测广告", "扫描页面中...") has_ad, ad_elements = self._detect_commercial_ad() if not has_ad: logger.info("未检测到商业广告,跳过该链接") + self._update_overlay("未检测到广告", "跳过该链接") # 记录无广告 self._record_click_failure(url, "未检测到商业广告") return False, False # 逐个尝试点击广告,直到成功 + self._update_overlay("点击广告", f"检测到 {len(ad_elements)} 个广告") logger.info(f"检测到商业广告,准备点击(共 {len(ad_elements)} 个)") click_success = False for idx, ad_element in enumerate(ad_elements, 1): logger.info(f"尝试点击第 {idx}/{len(ad_elements)} 个广告...") + self._update_overlay("点击广告", f"尝试第 {idx}/{len(ad_elements)} 个") if self._click_advertisement(ad_element): logger.info(f"✅ 第 {idx} 个广告点击成功") click_success = True @@ -106,6 +495,7 @@ class MIPAdAutomation: if not click_success: logger.warning("所有广告均点击失败") + self._update_overlay("点击失败", "所有广告均点击失败") # 记录点击失败 self._record_click_failure(url, f"所有广告({len(ad_elements)}个)均点击失败") return False, False @@ -113,20 +503,48 @@ class MIPAdAutomation: # 记录点击到数据库 self._record_click(url) - # 发送咨询消息 - message_sent = self._send_consultation_message() + # 等待聊天页面加载 + self._update_overlay("进入聊天", "等待页面加载...") + logger.info("等待聊天页面加载...") + time.sleep(3) - # 等待并检查回复 - has_reply = self._wait_for_reply() + # 检查是否跳转到非聊天页面 + non_chat_domains = [ + 'sp.vejianzhan.com', # 微建站落地页 + # 可以在这里添加更多需要跳过的域名 + ] + current_url = self.page.url.lower() + for domain in non_chat_domains: + if domain in current_url: + logger.info(f"检测到非聊天页面({domain}),判定为点击失败") + self._update_overlay("非聊天页面", "非聊天页面,跳过") + self._record_click_failure(url, f"跳转到非聊天页面({domain})") + return False, False + + # 直接开始交互(API响应需要页面交互才会触发) + # 优先尝试点击页面上可见的推荐按钮,没有则发送初始消息 + logger.info("开始首次交互...") + if not self._try_click_visible_recommend(): + self._send_initial_message() + + # 执行自动聊天交互(3-5轮,每轮间隔30-90秒) + self._update_overlay("自动聊天", "开始交互...") + logger.info("开始自动聊天交互流程...") + interaction_rounds = self._auto_chat_interaction() + + # 检查是否收到回复 + has_reply = len(self.doctor_replies) > 0 # 记录互动到数据库 - if message_sent: - self._record_interaction(has_reply) + self._record_interaction(has_reply) + self._update_overlay("完成", f"交互 {interaction_rounds} 轮,回复: {has_reply}") + logger.info(f"交互结束,完成 {interaction_rounds} 轮,收到回复: {has_reply}") return True, has_reply except Exception as e: logger.error(f"处理链接异常: {str(e)}") + self._update_overlay("异常", str(e)[:30]) # 记录异常 try: self._record_click_failure(url, f"异常: {str(e)}") @@ -134,6 +552,9 @@ class MIPAdAutomation: pass return False, False finally: + # 等待一会再移除浮窗 + time.sleep(2) + self._remove_overlay() # 尝试关闭当前标签页,返回主窗口 self._close_current_tab() @@ -185,6 +606,61 @@ class MIPAdAutomation: logger.error(f"检测广告异常: {str(e)}") return False, [] + def _get_ad_info(self, ad_element) -> str: + """ + 获取广告元素的详细信息 + + Args: + ad_element: 广告元素 + + Returns: + 广告信息字符串 + """ + try: + info_parts = [] + + # 获取广告文本内容 + try: + text = ad_element.inner_text() + if text: + # 清理文本,只取前100字符 + text = text.strip().replace('\n', ' ')[:100] + info_parts.append(f"文本: {text}") + except: + pass + + # 获取广告链接 + try: + href = ad_element.get_attribute('href') + if href: + info_parts.append(f"链接: {href[:80]}") + except: + pass + + # 获取广告标题 + try: + title = ad_element.get_attribute('title') + if title: + info_parts.append(f"标题: {title}") + except: + pass + + # 尝试获取内部链接 + if not any('链接' in p for p in info_parts): + try: + link = ad_element.locator('a').first + if link: + href = link.get_attribute('href') + if href: + info_parts.append(f"内链: {href[:80]}") + except: + pass + + return ' | '.join(info_parts) if info_parts else "无详细信息" + + except Exception as e: + return f"获取信息失败: {str(e)}" + def _click_advertisement(self, ad_element: ElementHandle) -> bool: """ 点击广告元素(当前页面导航) @@ -198,6 +674,10 @@ class MIPAdAutomation: try: original_url = self.page.url + # 获取广告详细信息 + ad_info = self._get_ad_info(ad_element) + logger.info(f"广告信息: {ad_info}") + # 滚动到广告元素可见 ad_element.scroll_into_view_if_needed() time.sleep(1) @@ -217,19 +697,15 @@ class MIPAdAutomation: if self.page.url != original_url: logger.info(f"✅ 页面已导航(耗时{i+1}秒): {original_url} -> {self.page.url}") - # 等待页面加载完成(最多15秒) + # 尝试等待页面加载完成,但不强制要求 try: - logger.info("等待页面加载完成...") - self.page.wait_for_load_state('domcontentloaded', timeout=15000) + logger.info("等待页面加载...") + self.page.wait_for_load_state('domcontentloaded', timeout=10000) logger.info("✅ 页面加载完成") except Exception as load_err: - logger.warning(f"⚠️ 页面加载超时,尝试刷新页面...") - try: - self.page.reload(wait_until='domcontentloaded', timeout=15000) - logger.info("✅ 页面刷新成功") - except Exception as refresh_err: - logger.error(f"❌ 页面刷新失败: {str(refresh_err)}") - return False + # 页面加载超时不判定为失败,继续执行 + # 因为聊天页面可能已经可用(API响应已经在接收) + logger.warning(f"⚠️ 页面加载超时,但URL已跳转,继续执行...") break else: @@ -358,8 +834,8 @@ class MIPAdAutomation: continue if clicked: - # 直接输入消息 - self.page.keyboard.type(message, delay=50) + # 使用真人模拟输入 + self._human_type(message) logger.info("✅ 已输入消息(兜底)") # 直接按回车发送 @@ -382,8 +858,8 @@ class MIPAdAutomation: input_element.click() time.sleep(0.5) - # 输入消息 - input_element.fill(message) + # 使用真人模拟输入 + self._human_type(message) logger.info("✅ 已输入消息") time.sleep(1) @@ -512,7 +988,7 @@ class MIPAdAutomation: logger.error(f"记录失败异常: {str(e)}") def _record_interaction(self, response_received: bool): - """记录互动到数据库""" + """记录互动到数据库(包含医生回复内容)""" try: if not self.site_id: logger.warning("未设置 site_id,跳过互动记录") @@ -521,6 +997,12 @@ class MIPAdAutomation: from db_manager import InteractionManager interaction_mgr = InteractionManager() + # 获取完整聊天记录 + full_chat_log = self._get_full_chat_log() + if full_chat_log: + logger.info(f"完整聊天记录 ({len(self.doctor_replies)}条医生消息):") + logger.debug(full_chat_log[:500]) + interaction_id = interaction_mgr.record_interaction( site_id=self.site_id, click_id=self.click_id, @@ -528,15 +1010,22 @@ class MIPAdAutomation: reply_content=getattr(self, 'sent_message', None), is_successful=True, response_received=response_received, - response_content=None # 可以后续添加提取回复内容 + response_content=full_chat_log # 保存完整聊天记录 ) logger.info(f"已记录互动: interaction_id={interaction_id}, response={response_received}") + + # 记录详细的回复信息到日志 + if self.doctor_replies: + logger.info(f"本次共收到 {len(self.doctor_replies)} 条医生回复:") + for idx, reply in enumerate(self.doctor_replies, 1): + logger.info(f" [{idx}] {reply.get('content', '')[:100]}") + except Exception as e: logger.error(f"记录互动失败: {str(e)}") def _wait_for_reply(self) -> bool: """ - 等待广告主回复 + 等待广告主回复(通过监听heartbeat API) Returns: 是否收到回复 @@ -544,25 +1033,33 @@ class MIPAdAutomation: try: logger.info(f"等待广告主回复(最多{Config.REPLY_WAIT_TIMEOUT}秒)") - # 检查是否已经自动发送消息 - time.sleep(2) + # 记录等待开始时的回复数量 + initial_reply_count = len(self.doctor_replies) # 等待并检查回复 start_time = time.time() timeout = Config.REPLY_WAIT_TIMEOUT - - # 根据实际页面结构调整回复检测逻辑 - # 这里使用轮询方式检查是否有新消息 - initial_msg_count = self._count_messages() + check_interval = 2 # 每2秒检查一次 while time.time() - start_time < timeout: - time.sleep(2) - current_msg_count = self._count_messages() + time.sleep(check_interval) - # 如果消息数量增加,说明收到了回复 - if current_msg_count > initial_msg_count: - logger.info("收到广告主回复") + # 检查是否有新的医生回复(通过heartbeat API监听获取) + if len(self.doctor_replies) > initial_reply_count: + new_replies = self.doctor_replies[initial_reply_count:] + logger.info(f"收到 {len(new_replies)} 条医生回复") + for reply in new_replies: + logger.info(f" - {reply.get('content', '')[:100]}") + + # 尝试发送推荐回复进行二次互动 + self._try_click_recommend_reply() + return True + + # 打印等待进度 + elapsed = int(time.time() - start_time) + if elapsed % 10 == 0 and elapsed > 0: + logger.info(f"等待中... ({elapsed}/{timeout}秒)") logger.info("未收到广告主回复(超时)") return False @@ -571,6 +1068,831 @@ class MIPAdAutomation: logger.error(f"等待回复异常: {str(e)}") return False + def _try_click_recommend_reply(self) -> bool: + """ + 尝试点击推荐回复按钮 + + Returns: + 是否点击成功 + """ + try: + # 检查是否有推荐回复 + if not self.recommend_replies: + logger.info("暂无推荐回复") + return False + + # 需要过滤的关键词(电话相关) + filter_keywords = ['电话', '拨打', '致电', '来电', '通话', '微信', '加微', 'wx', 'WeChat', '满意度', '评价', '好评', '差评'] + + # 获取推荐回复文本列表(过滤电话相关) + recommend_texts = [] + for recommend in self.recommend_replies: + text = recommend.get('text', '') or recommend.get('value', '') + if text: + # 过滤电话相关 + if any(kw in text for kw in filter_keywords): + logger.debug(f"跳过电话相关推荐: {text}") + continue + recommend_texts.append(text) + + if not recommend_texts: + logger.warning("推荐回复内容为空(或全是电话相关)") + return False + + logger.info(f"查找推荐回复按钮: {recommend_texts}") + + # 推荐回复按钮的选择器 + button_selectors = [ + # 常见的推荐回复按钮选择器 + "div[class*='suggest'] button", + "div[class*='suggest'] div[class*='item']", + "div[class*='recommend'] button", + "div[class*='recommend'] div[class*='item']", + "div[class*='quick'] button", + "div[class*='quick'] div[class*='reply']", + "button[class*='suggest']", + "button[class*='recommend']", + "div[class*='bubble'] span", + "div[class*='reply-item']", + "span[class*='suggest']", + ] + + # 遍历选择器查找按钮 + for selector in button_selectors: + try: + elements = self.page.locator(selector).all() + for elem in elements: + if elem.is_visible(): + try: + elem_text = elem.inner_text().strip() + # 检查按钮文本是否匹配推荐回复 + for recommend_text in recommend_texts: + if recommend_text in elem_text or elem_text in recommend_text: + logger.info(f"找到推荐回复按钮: {elem_text}") + elem.click() + logger.info(f"✅ 已点击推荐回复: {elem_text}") + self.sent_recommend_reply = elem_text + self.sent_message = f"[推荐回复] {elem_text}" + if not hasattr(self, 'sent_messages'): + self.sent_messages = [] + self.sent_messages.append({'role': '我方(推荐回复)', 'content': elem_text}) + time.sleep(1) + return True + except: + continue + except Exception as e: + logger.debug(f"选择器 {selector} 失败: {str(e)}") + continue + + # 如果没有找到匹配的按钮,尝试通过文本内容直接查找 + logger.info("尝试通过文本内容查找推荐回复按钮...") + for recommend_text in recommend_texts: + try: + # 使用XPath通过文本内容查找 + xpath_selectors = [ + f"//*[contains(text(), '{recommend_text[:10]}')]", + f"//button[contains(text(), '{recommend_text[:10]}')]", + f"//span[contains(text(), '{recommend_text[:10]}')]", + f"//div[contains(text(), '{recommend_text[:10]}')]", + ] + + for xpath in xpath_selectors: + try: + elements = self.page.locator(f"xpath={xpath}").all() + for elem in elements: + if elem.is_visible(): + # 检查元素是否可点击(不是整个容器) + box = elem.bounding_box() + if box and box['width'] < 500 and box['height'] < 100: + logger.info(f"找到推荐回复元素: {recommend_text[:20]}") + elem.click() + logger.info(f"✅ 已点击推荐回复: {recommend_text}") + self.sent_recommend_reply = recommend_text + self.sent_message = f"[推荐回复] {recommend_text}" + if not hasattr(self, 'sent_messages'): + self.sent_messages = [] + self.sent_messages.append({'role': '我方(推荐回复)', 'content': recommend_text}) + time.sleep(1) + return True + except: + continue + except Exception as e: + logger.debug(f"文本查找失败: {str(e)}") + continue + + logger.warning("未找到可点击的推荐回复按钮") + return False + + except Exception as e: + logger.error(f"点击推荐回复异常: {str(e)}") + return False + + def _try_click_visible_recommend(self) -> bool: + """ + 遍历每条带推荐回复的消息,从中随机选一个点击 + + Returns: + 是否点击成功 + """ + try: + # 需要过滤的关键词 + filter_keywords = ['电话', '拨打', '致电', '来电', '通话', '微信', '加微', 'wx', 'WeChat', '满意度', '评价', '好评', '差评'] + + # 查找所有推荐回复组(每组对应一条消息的推荐) + recommend_group_selectors = [ + "div.gt-jmy-h5-c-msg-tag", + "div[class*='msg-tag']", + "div[class*='suggest-reply']", + "div[class*='quick-reply']", + "div[class*='recommend-reply']", + ] + + clicked_count = 0 + + for group_selector in recommend_group_selectors: + try: + groups = self.page.locator(group_selector).all() + for group in groups: + if not group.is_visible(): + continue + + # 获取该组内的所有推荐选项 + options = group.locator("span.content-text").all() + if not options: + options = group.locator("span").all() + if not options: + options = group.locator("button").all() + if not options: + options = group.locator("div[class*='item']").all() + + # 收集该组内可用的选项 + available_options = [] + for opt in options: + try: + if opt.is_visible(): + text = opt.inner_text().strip() + # 过滤敏感词 + if any(kw in text for kw in filter_keywords): + continue + if text and len(text) < 30: + available_options.append({'elem': opt, 'text': text}) + except: + continue + + # 从该组中随机选一个点击 + if available_options: + selected = random.choice(available_options) + logger.info(f"推荐选项: {[o['text'] for o in available_options]},选择: {selected['text']}") + self._human_click(selected['elem'], f"推荐回复: {selected['text']}") + logger.info(f"✅ 已点击推荐: {selected['text']}") + self.sent_recommend_reply = selected['text'] + self.sent_message = f"[推荐回复] {selected['text']}" # 记录到sent_message + if not hasattr(self, 'sent_messages'): + self.sent_messages = [] + self.sent_messages.append({'role': '我方(推荐回复)', 'content': selected['text']}) + clicked_count += 1 + # 随机延迟2-5秒,模拟真人操作 + delay = random.uniform(2, 5) + time.sleep(delay) + except: + continue + + if clicked_count > 0: + logger.info(f"共点击 {clicked_count} 个推荐回复") + return True + + # 兜底:使用通用选择器查找 + button_selectors = [ + "div[class*='suggest'] span", + "div[class*='recommend'] span", + "div[class*='quick-reply'] span", + "button[class*='suggest']", + "div[class*='reply-item']", + "span[class*='reply']", + ] + + available_buttons = [] + for selector in button_selectors: + try: + elements = self.page.locator(selector).all() + for elem in elements: + if elem.is_visible(): + try: + text = elem.inner_text().strip() + if any(kw in text for kw in filter_keywords): + continue + if text and len(text) < 30: + box = elem.bounding_box() + if box and box['width'] < 200 and box['height'] < 60: + if not any(b['text'] == text for b in available_buttons): + available_buttons.append({'elem': elem, 'text': text}) + except: + continue + except: + continue + + if available_buttons: + selected = random.choice(available_buttons) + logger.info(f"找到 {len(available_buttons)} 个推荐按钮,选择: {selected['text']}") + # 随机延迟1-3秒后点击 + time.sleep(random.uniform(1, 3)) + self._human_click(selected['elem'], f"推荐按钮: {selected['text']}") + logger.info(f"✅ 已点击推荐按钮: {selected['text']}") + self.sent_recommend_reply = selected['text'] + if not hasattr(self, 'sent_messages'): + self.sent_messages = [] + self.sent_messages.append({'role': '我方(推荐回复)', 'content': selected['text']}) + # 点击后随机延迟2-4秒 + time.sleep(random.uniform(2, 4)) + return True + + logger.debug("未找到可见的推荐按钮") + return False + + except Exception as e: + logger.error(f"查找推荐按钮异常: {str(e)}") + return False + + def _count_dom_recommend_buttons(self) -> int: + """ + 统计页面DOM中的推荐按钮数量 + + Returns: + 推荐按钮数量 + """ + try: + count = 0 + # 基于实际页面结构的选择器 + selectors = [ + "div.gt-jmy-h5-c-msg-tag span.content-text", + "div[class*='msg-tag'] span.content-text", + ] + + for selector in selectors: + try: + elements = self.page.locator(selector).all() + for elem in elements: + if elem.is_visible(): + count += 1 + except: + continue + + return count + except: + return 0 + + def _get_latest_doctor_message_from_dom(self) -> Optional[str]: + """ + 从DOM获取最新的医生消息,并添加到doctor_replies + + Returns: + 最新医生消息内容 + """ + try: + # 基于实际页面结构的选择器 + selectors = [ + "div.msg-container-normal div.mip-sjh-text", + "div[class*='msg-container'] div.mip-sjh-text", + "div[class*='bot-msg'] div.mip-sjh-text", + ] + + for selector in selectors: + try: + elements = self.page.locator(selector).all() + if elements: + # 获取最后一个(最新的)消息 + last_elem = elements[-1] + if last_elem.is_visible(): + text = last_elem.inner_text().strip() + if text: + # 检查是否已存在(避免重复) + if not any(r.get('content') == text for r in self.doctor_replies): + self.doctor_replies.append({ + 'content': text, + 'messageId': f'dom_{len(self.doctor_replies)}', + 'source': 'dom', + 'received_at': datetime.now().isoformat() + }) + logger.debug(f"[DOM] 添加医生消息: {text[:50]}...") + return text + except: + continue + + return None + except: + return None + + def _send_initial_message(self): + """发送初始咨询消息""" + initial_message = random.choice(self.CONSULTATION_MESSAGES) + if self._send_message_to_chat(initial_message): + logger.info(f"✅ 已发送初始消息: {initial_message}") + self.sent_message = initial_message + else: + logger.warning("初始消息发送失败") + + def _send_message_to_chat(self, message: str) -> bool: + """ + 在聊天页面发送消息 + + Args: + message: 要发送的消息 + + Returns: + 是否发送成功 + """ + try: + # 查找输入框(多种选择器,按优先级排列) + input_selectors = [ + # 基于实际页面结构(自定义输入框组件) + "div.gt-jmy-h5-bot-text-input", + "div.text-input", + "div.input-area", + "div.fake-input", + # 基于class名称 + "textarea.chat-input", + "textarea[class*='input']", + "textarea[class*='textarea']", + "div[class*='input'] textarea", + "div[class*='chat'] textarea", + # 基于placeholder + "textarea[placeholder*='消息']", + "textarea[placeholder*='问题']", + "textarea[placeholder*='输入']", + "textarea[placeholder*='说点']", + "textarea[placeholder*='描述']", + "input[type='text'][placeholder*='消息']", + "input[type='text'][placeholder*='输入']", + # 基于contenteditable + "div[contenteditable='true']", + # 通用兜底 + "textarea", + "input[type='text']" + ] + + # 最多重试3次 + for retry in range(3): + input_element = None + is_custom_input = False # 标记是否是自定义输入框 + + for selector in input_selectors: + try: + elements = self.page.locator(selector).all() + for elem in elements: + if elem.is_visible(): + box = elem.bounding_box() + if box and box['height'] > 20: + input_element = elem + # 检查是否是自定义输入框 + if 'gt-jmy' in selector or 'fake-input' in selector or 'text-input' in selector: + is_custom_input = True + logger.debug(f"找到输入框: {selector}, 自定义: {is_custom_input}") + break + if input_element: + break + except: + continue + + if input_element: + break + + # 没找到,等待后重试 + if retry < 2: + logger.info(f"未找到输入框,等待2秒后重试... ({retry+1}/3)") + time.sleep(2) + try: + self.page.evaluate("window.scrollTo(0, document.body.scrollHeight)") + except: + pass + + # 兜底方案:点击页面底部中心位置激活输入框 + if not input_element: + logger.info("尝试兜底方案:点击页面底部中心位置...") + try: + # 获取页面尺寸 + viewport = self.page.viewport_size + if viewport: + # 点击底部中心上方一点的位置(大约底部往上100px) + click_x = viewport['width'] // 2 + click_y = viewport['height'] - 100 + self.page.mouse.click(click_x, click_y) + logger.info(f"点击位置: ({click_x}, {click_y})") + time.sleep(0.5) + + # 使用真人模拟输入 + self._human_type(message) + time.sleep(0.3) + self.page.keyboard.press('Enter') + self.sent_message = message # 记录发送内容 + if not hasattr(self, 'sent_messages'): + self.sent_messages = [] + self.sent_messages.append({'role': '我方', 'content': message}) + logger.info(f"✅ 已发送消息(兜底方案): {message[:50]}...") + time.sleep(1) + return True + except Exception as e: + logger.warning(f"兜底方案失败: {str(e)}") + + logger.warning("所有方案均失败") + return False + + # 点击输入框获取焦点 + input_element.click() + time.sleep(0.5) + + # 使用真人模拟输入 + logger.debug("使用真人模拟输入...") + self._human_type(message) + + time.sleep(0.5) + + # 发送消息:先尝试点击发送按钮,再尝试按回车 + sent = False + + # 方法1:点击发送按钮 + send_btn_selectors = [ + "div.send-btn", + "div.icon.send-btn", + "button.send-btn", + "span.send-btn", + "div[class*='send']", + "button[class*='send']", + ] + + for btn_selector in send_btn_selectors: + try: + btn = self.page.locator(btn_selector).first + if btn and btn.is_visible(): + btn.click() + logger.debug(f"点击发送按钮: {btn_selector}") + sent = True + break + except: + continue + + # 方法2:按回车键 + if not sent: + try: + self.page.keyboard.press('Enter') + sent = True + except: + pass + + if sent: + self.sent_message = message # 记录发送内容 + if not hasattr(self, 'sent_messages'): + self.sent_messages = [] + self.sent_messages.append({'role': '我方', 'content': message}) + logger.info(f"✅ 已发送消息: {message[:50]}...") + time.sleep(1) + return True + else: + logger.warning("发送消息失败") + return False + + except Exception as e: + logger.error(f"发送消息异常: {str(e)}") + return False + + def _wait_for_new_doctor_reply(self, timeout: int = 60) -> Optional[str]: + """ + 等待新的医生回复 + + Args: + timeout: 等待超时时间(秒) + + Returns: + 新的医生回复内容,超时返回None + """ + try: + initial_count = len(self.doctor_replies) + start_time = time.time() + + while time.time() - start_time < timeout: + time.sleep(2) + + # 检查是否有新回复 + if len(self.doctor_replies) > initial_count: + # 获取最新的回复 + new_reply = self.doctor_replies[-1] + content = new_reply.get('content', '') + if content: + logger.info(f"收到新医生回复: {content[:50]}...") + return content + + # 打印等待进度 + elapsed = int(time.time() - start_time) + if elapsed % 15 == 0 and elapsed > 0: + logger.info(f"等待医生回复... ({elapsed}/{timeout}秒)") + + logger.info("等待医生回复超时") + return None + + except Exception as e: + logger.error(f"等待医生回复异常: {str(e)}") + return None + + def _auto_chat_interaction(self) -> int: + """ + 自动聊天交互(3-5轮) + + 流程: + 1. 持续监控API响应,收到推荐回复立即点击 + 2. 如果没有推荐回复但有医生回复,使用AI生成回复 + 3. 重复3-5轮,每轮间隔30-90秒 + + Returns: + 实际完成的交互轮数 + """ + try: + # 随机决定交互轮数(3-5轮) + target_rounds = random.randint(3, 5) + completed_rounds = 0 + + logger.info(f"开始自动聊天交互,目标轮数: {target_rounds}") + + for round_num in range(1, target_rounds + 1): + logger.info(f"=== 第 {round_num}/{target_rounds} 轮交互 ===") + self._update_overlay("聊天交互", f"第 {round_num}/{target_rounds} 轮") + + # 记录本轮开始时的状态 + initial_recommend_count = len(self.recommend_replies) + initial_doctor_count = len(self.doctor_replies) + initial_dom_button_count = self._count_dom_recommend_buttons() + round_completed = False + last_doctor_reply = None + no_recommend_count = 0 # 连续找不到推荐按钮的次数 + + # 持续监控,最多等待60秒 + start_time = time.time() + timeout = 60 + + while time.time() - start_time < timeout and not round_completed: + time.sleep(1) # 每秒检查一次 + + # 每次循环都尝试点击可见的推荐按钮(遍历所有消息) + clicked = self._try_click_visible_recommend() + if clicked: + logger.info(f"✅ 第 {round_num} 轮点击了推荐回复") + completed_rounds += 1 + round_completed = True + no_recommend_count = 0 + # 更新计数 + initial_recommend_count = len(self.recommend_replies) + initial_dom_button_count = self._count_dom_recommend_buttons() + continue + else: + no_recommend_count += 1 + + # 检查API是否有新的推荐回复 + if len(self.recommend_replies) > initial_recommend_count: + logger.info("[API] 检测到新推荐回复...") + initial_recommend_count = len(self.recommend_replies) + no_recommend_count = 0 + # 尝试点击 + if self._try_click_visible_recommend() or self._try_click_recommend_reply(): + logger.info(f"✅ 第 {round_num} 轮使用推荐回复完成") + completed_rounds += 1 + round_completed = True + + # 检查DOM是否有新的推荐按钮 + if not round_completed: + current_dom_count = self._count_dom_recommend_buttons() + if current_dom_count > initial_dom_button_count: + logger.info(f"[DOM] 检测到新推荐按钮 ({initial_dom_button_count} -> {current_dom_count})...") + initial_dom_button_count = current_dom_count + no_recommend_count = 0 + if self._try_click_visible_recommend(): + logger.info(f"✅ 第 {round_num} 轮通过DOM点击推荐完成") + completed_rounds += 1 + round_completed = True + + # 记录医生回复(用于AI生成) + if len(self.doctor_replies) > initial_doctor_count: + last_doctor_reply = self.doctor_replies[-1].get('content', '') + initial_doctor_count = len(self.doctor_replies) + + if not last_doctor_reply: + dom_doctor_msg = self._get_latest_doctor_message_from_dom() + if dom_doctor_msg: + last_doctor_reply = dom_doctor_msg + + # 如果收到医生回复但连续10秒找不到推荐按钮,提前使用AI + if not round_completed and last_doctor_reply and no_recommend_count >= 10: + logger.info(f"已收到医生回复但连续{no_recommend_count}秒无推荐按钮,使用AI回复...") + ai_reply = self._call_qwen_api(last_doctor_reply) + if ai_reply and self._send_message_to_chat(ai_reply): + logger.info(f"✅ 第 {round_num} 轮使用AI回复完成") + completed_rounds += 1 + round_completed = True + else: + logger.warning("AI回复发送失败,继续等待推荐按钮...") + no_recommend_count = 0 # 重置计数,继续尝试 + + # 如果连续25秒没有任何响应(无推荐按钮也无医生回复),主动发消息 + if not round_completed and not last_doctor_reply and no_recommend_count >= 25: + # 尝试获取历史医生消息 + history_doctor_msg = None + if self.doctor_replies: + history_doctor_msg = self.doctor_replies[-1].get('content', '') + if not history_doctor_msg: + history_doctor_msg = self._get_latest_doctor_message_from_dom() + + if history_doctor_msg: + logger.info(f"连续{no_recommend_count}秒无新响应,使用历史消息生成AI回复...") + ai_reply = self._call_qwen_api(history_doctor_msg) + if ai_reply and self._send_message_to_chat(ai_reply): + logger.info(f"✅ 第 {round_num} 轮使用AI回复历史消息完成") + completed_rounds += 1 + round_completed = True + else: + no_recommend_count = 0 + else: + # 完全没有历史消息,发送激活消息 + logger.info(f"连续{no_recommend_count}秒无响应且无历史消息,发送激活消息...") + fallback_messages = [ + "您好,请问还在吗?", + "想咨询一下具体情况", + "请问医生什么时候有空呢?", + "我想了解一下治疗方案" + ] + fallback_msg = random.choice(fallback_messages) + if self._send_message_to_chat(fallback_msg): + logger.info(f"✅ 第 {round_num} 轮发送激活消息: {fallback_msg}") + completed_rounds += 1 + round_completed = True + else: + no_recommend_count = 0 + + # 打印等待进度 + elapsed = int(time.time() - start_time) + if elapsed % 15 == 0 and elapsed > 0: + logger.info(f"等待中... ({elapsed}/{timeout}秒)") + + # 如果本轮没有通过推荐回复完成,尝试使用AI + if not round_completed: + if last_doctor_reply: + logger.info("无可用推荐回复,使用千问AI生成回复...") + ai_reply = self._call_qwen_api(last_doctor_reply) + if ai_reply and self._send_message_to_chat(ai_reply): + logger.info(f"✅ 第 {round_num} 轮使用AI回复完成") + completed_rounds += 1 + round_completed = True + else: + logger.warning(f"第 {round_num} 轮AI回复发送失败,尝试点击推荐按钮...") + # 再次尝试点击推荐按钮 + if self._try_click_visible_recommend(): + logger.info(f"✅ 第 {round_num} 轮通过点击推荐按钮完成") + completed_rounds += 1 + round_completed = True + else: + logger.warning(f"第 {round_num} 轮失败,继续下一轮...") + else: + # 当前轮没有收到新回复,尝试用历史消息生成回复 + logger.warning(f"第 {round_num} 轮未收到新回复,尝试使用历史消息...") + + # 先尝试点击推荐按钮 + if self._try_click_visible_recommend(): + logger.info(f"✅ 第 {round_num} 轮通过点击推荐按钮完成") + completed_rounds += 1 + round_completed = True + else: + # 尝试获取历史医生消息 + history_doctor_msg = None + if self.doctor_replies: + history_doctor_msg = self.doctor_replies[-1].get('content', '') + if not history_doctor_msg: + history_doctor_msg = self._get_latest_doctor_message_from_dom() + + if history_doctor_msg: + logger.info("使用历史消息生成AI回复...") + ai_reply = self._call_qwen_api(history_doctor_msg) + if ai_reply and self._send_message_to_chat(ai_reply): + logger.info(f"✅ 第 {round_num} 轮使用AI回复历史消息完成") + completed_rounds += 1 + round_completed = True + else: + # 完全没有历史消息,发送激活消息 + fallback_messages = [ + "您好,请问还在吗?", + "想咨询一下具体情况", + "请问医生什么时候有空呢?", + "我想了解一下治疗方案" + ] + fallback_msg = random.choice(fallback_messages) + if self._send_message_to_chat(fallback_msg): + logger.info(f"✅ 第 {round_num} 轮发送激活消息: {fallback_msg}") + completed_rounds += 1 + round_completed = True + else: + logger.warning(f"第 {round_num} 轮发送消息失败") + + # 如果还有下一轮,随机等待30-90秒 + if round_num < target_rounds: + wait_seconds = random.randint(30, 90) + logger.info(f"等待 {wait_seconds} 秒后进行下一轮...") + time.sleep(wait_seconds) + + logger.info(f"自动聊天交互完成,共 {completed_rounds}/{target_rounds} 轮") + return completed_rounds + + except Exception as e: + logger.error(f"自动聊天交互异常: {str(e)}") + return completed_rounds if 'completed_rounds' in locals() else 0 + + def _get_doctor_reply_content(self) -> Optional[str]: + """获取医生回复内容(合并所有回复)""" + if not self.doctor_replies: + return None + + # 合并所有回复内容 + contents = [r.get('content', '') for r in self.doctor_replies if r.get('content')] + return '\n'.join(contents) if contents else None + + def _get_full_chat_log(self) -> Optional[str]: + """ + 获取完整聊天记录(格式化为文本) + + 数据来源: + 1. doctor_replies - API监听/DOM采集的医生消息 + 2. chat_history - 与千问API的对话历史(user=医生, assistant=我方AI回复) + 3. sent_messages - 所有发送的消息(包括推荐回复和AI回复) + 4. sent_recommend_reply - 点击的推荐回复(兼容旧代码) + 5. sent_message - 最后发送的消息(兼容旧代码) + + Returns: + 格式化的聊天记录文本 + """ + try: + chat_lines = [] + seen_contents = set() # 用于去重 + + # 1. 从 chat_history 中提取对话(包含医生消息和AI回复) + for i, msg in enumerate(self.chat_history): + role = msg.get('role', '') + content = msg.get('content', '') + + if not content: + continue + + if role == 'user': + # 医生消息,去掉前缀 "[医生/客服说]: " + if content.startswith('[医生/客服说]:'): + content = content.replace('[医生/客服说]:', '').strip() + elif content.startswith('[医生/客服说]'): + content = content.replace('[医生/客服说]', '').strip() + + if content and content not in seen_contents: + chat_lines.append(f"[医生] {content}") + seen_contents.add(content) + + elif role == 'assistant': + # 我方AI回复 + if content and content not in seen_contents: + chat_lines.append(f"[我方(AI)] {content}") + seen_contents.add(content) + + # 2. 补充 doctor_replies 中的消息(可能有些消息没进入 chat_history) + for reply in self.doctor_replies: + content = reply.get('content', '') + if content and content not in seen_contents: + chat_lines.append(f"[医生] {content}") + seen_contents.add(content) + + # 3. 从 sent_messages 获取所有发送的消息 + if hasattr(self, 'sent_messages') and self.sent_messages: + for msg in self.sent_messages: + content = msg.get('content', '') + role = msg.get('role', '我方') + if content and content not in seen_contents: + chat_lines.append(f"[{role}] {content}") + seen_contents.add(content) + + # 4. 添加发送的推荐回复(兼容旧代码) + if hasattr(self, 'sent_recommend_reply') and self.sent_recommend_reply: + content = self.sent_recommend_reply + if content not in seen_contents: + chat_lines.append(f"[我方(推荐回复)] {content}") + seen_contents.add(content) + + # 5. 添加最后发送的消息(兼容旧代码) + if hasattr(self, 'sent_message') and self.sent_message: + content = self.sent_message + if not content.startswith('[推荐回复]') and content not in seen_contents: + chat_lines.append(f"[我方] {content}") + seen_contents.add(content) + + result = '\n'.join(chat_lines) if chat_lines else None + + # 调试日志 + logger.info(f"聊天记录汇总: chat_history={len(self.chat_history)}条, doctor_replies={len(self.doctor_replies)}条, sent_messages={len(getattr(self, 'sent_messages', []))}条, 输出={len(chat_lines)}行") + + return result + + except Exception as e: + logger.error(f"获取完整聊天记录失败: {str(e)}") + return None + def _count_messages(self) -> int: """ 统计当前页面的消息数量 diff --git a/adspower_client.py b/adspower_client.py index fff5562..38dbaa8 100644 --- a/adspower_client.py +++ b/adspower_client.py @@ -477,9 +477,10 @@ class AdsPowerClient: use_proxy: 是否使用代理 Returns: - 浏览器信息 + 浏览器信息,如果使用代理会包含 proxy_id 字段 """ target_user_id = user_id or self.user_id + proxy_id = None if use_proxy: # 1. 获取大麦IP代理 @@ -507,10 +508,18 @@ class AdsPowerClient: # 3. 更新 Profile 使用新代理 if not self.update_profile_proxy(target_user_id, proxy_id): logger.warning("更新 Profile 代理失败,将不使用代理启动浏览器") + # 删除刚创建的代理 + self.delete_proxy(proxy_id) return self.start_browser(user_id=target_user_id) # 4. 启动浏览器 - return self.start_browser(user_id=target_user_id) + result = self.start_browser(user_id=target_user_id) + + # 5. 如果启动成功且有代理,在返回结果中添加 proxy_id + if result and proxy_id: + result['proxy_id'] = proxy_id + + return result def start_browser(self, user_id: str = None) -> Optional[Dict]: """ @@ -542,7 +551,11 @@ class AdsPowerClient: # 准备请求体 payload = { "profile_id": target_user_id, - "launch_args": [], # 可以根据需要添加启动参数 + "launch_args": [ + "--no-sandbox", # 禁用沙箱(root用户必需) + "--disable-setuid-sandbox", # 禁用setuid沙箱 + "--disable-dev-shm-usage" # 避免/dev/shm空间不足 + ], "headless": "0", "last_opened_tabs": "1", "proxy_detection": "1", @@ -619,53 +632,31 @@ class AdsPowerClient: Playwright Browser 实例 """ try: - import asyncio - import sys - - # 检测是否在 asyncio 事件循环中 - try: - loop = asyncio.get_running_loop() - logger.warning("检测到 asyncio 事件循环,将在新线程中执行 Playwright") - - # 在新线程中执行 Playwright 同步 API - import threading - result_container = {'browser': None, 'error': None} - - def run_playwright(): - try: - # 获取 CDP WebSocket 端点 - ws_endpoint = browser_info['data']['ws']['puppeteer'] - - # 创建新的 Playwright 实例 - playwright = sync_playwright().start() - - # 通过 CDP 连接到浏览器 - browser = playwright.chromium.connect_over_cdp(ws_endpoint) - logger.info("成功通过 CDP 连接到 AdsPower 浏览器") - - # 保存引用 - self.playwright = playwright - self.browser = browser - result_container['browser'] = browser - except Exception as e: - result_container['error'] = str(e) - - thread = threading.Thread(target=run_playwright) - thread.start() - thread.join(timeout=30) - - if result_container['error']: - raise Exception(result_container['error']) - - return result_container['browser'] - - except RuntimeError: - # 没有运行中的事件循环,正常执行 - pass - # 获取 CDP WebSocket 端点 ws_endpoint = browser_info['data']['ws']['puppeteer'] + # 检查是否在 asyncio 事件循环中 + import asyncio + try: + loop = asyncio.get_running_loop() + # 如果有运行中的循环,使用线程来执行同步代码 + import concurrent.futures + with concurrent.futures.ThreadPoolExecutor() as executor: + future = executor.submit(self._connect_browser_sync, ws_endpoint) + return future.result(timeout=30) + except RuntimeError: + # 没有运行中的循环,直接执行 + return self._connect_browser_sync(ws_endpoint) + + except Exception as e: + logger.error(f"CDP 连接失败: {str(e)}") + import traceback + traceback.print_exc() + return None + + def _connect_browser_sync(self, ws_endpoint: str) -> Optional[Browser]: + """同步执行浏览器连接""" + try: # 创建新的 Playwright 实例 playwright = sync_playwright().start() @@ -678,11 +669,8 @@ class AdsPowerClient: self.browser = browser return browser - except Exception as e: - logger.error(f"CDP 连接失败: {str(e)}") - import traceback - traceback.print_exc() + logger.error(f"CDP 连接失败(sync): {str(e)}") return None def get_page(self, browser: Browser) -> Optional[Page]: @@ -804,6 +792,18 @@ class AdsPowerClient: logger.error(f"停止浏览器异常: {str(e)}") return False + def close_browser(self, profile_id: str = None) -> bool: + """ + 关闭浏览器(stop_browser的别名) + + Args: + profile_id: Profile ID + + Returns: + 是否成功关闭 + """ + return self.stop_browser(user_id=profile_id) + def get_damai_proxy(self) -> Optional[Dict]: """ 从大麦IP代理池获取代理 @@ -996,6 +996,67 @@ class AdsPowerClient: logger.error(f"查询代理列表异常: {str(e)}") return None + def delete_proxy(self, proxy_id: str) -> bool: + """ + 删除代理 + 使用 AdsPower API v2 + + Args: + proxy_id: 代理ID + + Returns: + 是否成功删除 + """ + try: + url = f"{self.api_url}/api/v2/proxy-list/delete" + + # 准备请求头 + headers = { + 'Content-Type': 'application/json' + } + if self.api_key: + headers['Authorization'] = f'Bearer {self.api_key}' + + # 准备请求体(数组格式) + payload = { + "proxy_id": [proxy_id] + } + + logger.info("\n" + "="*70) + logger.info("删除 AdsPower 代理") + logger.info("="*70) + logger.info(f"URL: {url}") + logger.info(f"Method: POST") + logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}") + + response = requests.post(url, json=payload, headers=headers, timeout=30) + + logger.info("\n" + "-"*70) + logger.info("响应信息") + logger.info("-"*70) + logger.info(f"Status Code: {response.status_code}") + + try: + response_json = response.json() + logger.info(f"Response Body: {json.dumps(response_json, indent=2, ensure_ascii=False)}") + except: + logger.info(f"Response Body (Raw): {response.text}") + + logger.info("="*70 + "\n") + + result = response_json if 'response_json' in locals() else response.json() + + if result.get('code') == 0: + logger.success(f"成功删除代理,ID: {proxy_id}") + return True + else: + logger.error(f"删除代理失败: {result.get('msg')}") + return False + + except Exception as e: + logger.error(f"删除代理异常: {str(e)}") + return False + def check_browser_status(self, user_id: str = None) -> Optional[Dict]: """ 检查浏览器状态 @@ -1127,9 +1188,16 @@ class AdsPowerClient: if result and result.get('code') == 0: groups = result.get('data', {}).get('list', []) if groups: - group_id = groups[0].get('group_id') - logger.success(f"获取到分组ID: {group_id}") - return group_id + # 精确匹配分组名称(API返回的可能包含多个包含关键词的分组) + for group in groups: + if group.get('group_name') == group_name: + group_id = group.get('group_id') + logger.success(f"获取到分组ID: {group_id} (名称: {group_name})") + return group_id + + # 如果没有精确匹配,记录警告 + logger.warning(f"未找到精确匹配的分组 '{group_name}',返回的分组: {[g.get('group_name') for g in groups]}") + return None else: logger.warning(f"未找到名为 '{group_name}' 的分组") return None @@ -1258,6 +1326,85 @@ class AdsPowerClient: logger.error(f"查询 Profile 异常: {str(e)}") return None + def create_profile(self, group_id: str, name: str = None, proxy_id: str = None) -> Optional[str]: + """ + 创建新的 Profile + 使用 AdsPower API v2 + + Args: + group_id: 分组ID + name: Profile名称(可选,不填则自动生成) + proxy_id: 代理ID(必填) + + Returns: + 创建的 Profile ID,失败返回 None + """ + try: + url = f"{self.api_url}/api/v2/browser-profile/create" + + # 准备请求头 + headers = { + 'Content-Type': 'application/json' + } + if self.api_key: + headers['Authorization'] = f'Bearer {self.api_key}' + + # 准备请求体 + import time + profile_name = name or f"auto_{int(time.time())}" + + payload = { + "group_id": group_id, + "name": profile_name, + "platform": "health.baidu.com", + "proxyid": proxy_id, + "fingerprint_config": { + "automatic_timezone": "1", + "language": ["zh-CN", "zh"], + "ua": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" + } + } + + logger.info("\n" + "="*70) + logger.info("创建 Profile (API v2)") + logger.info("="*70) + logger.info(f"URL: {url}") + logger.info(f"Method: POST") + logger.info(f"Payload: {json.dumps(payload, indent=2, ensure_ascii=False)}") + + response = requests.post(url, json=payload, headers=headers, timeout=30) + + logger.info("\n" + "-"*70) + logger.info("响应信息") + logger.info("-"*70) + logger.info(f"Status Code: {response.status_code}") + + try: + response_json = response.json() + logger.info(f"Response Body: {json.dumps(response_json, indent=2, ensure_ascii=False)}") + except: + logger.info(f"Response Body (Raw): {response.text}") + + logger.info("="*70 + "\n") + + result = response_json if 'response_json' in locals() else response.json() + + if result.get('code') == 0: + profile_id = result.get('data', {}).get('profile_id') + if profile_id: + logger.success(f"成功创建 Profile,ID: {profile_id}") + return profile_id + else: + logger.error("创建 Profile 成功但未返回ID") + return None + else: + logger.error(f"创建 Profile 失败: {result.get('msg')}") + return None + + except Exception as e: + logger.error(f"创建 Profile 异常: {str(e)}") + return None + def delete_profile(self, profile_id: str) -> bool: """ 删除 Profile diff --git a/app.py b/app.py index e0e8e32..10e68af 100644 --- a/app.py +++ b/app.py @@ -1,11 +1,33 @@ -from flask import Flask, request, jsonify, send_from_directory, redirect +from flask import Flask, request, jsonify, send_from_directory, redirect, Response, session from flask_cors import CORS from loguru import logger +from functools import wraps import sys +import csv +import io +import os +import logging +import secrets +import threading +import uuid from pathlib import Path +from datetime import datetime +import pandas as pd from config import Config -from scheduler import ClickScheduler +from log_manager import LogManager +from config_manager import ConfigManager +from db_manager import ( + EnhancedSiteManager, EnhancedClickManager, + EnhancedInteractionManager, EnhancedStatisticsManager +) + +# 导入任务进度存储(内存) +import_tasks = {} + +# 固定账号密码 +AUTH_USERNAME = 'admin' +AUTH_PASSWORD = 'admin@123' # 配置日志 Config.ensure_dirs() @@ -16,27 +38,121 @@ logger.add( level="INFO" ) logger.add( - Path(Config.LOG_DIR) / "mip_ad_service_{time}.log", - rotation="500 MB", + Path(Config.LOG_DIR) / "scheduler_{time:YYYY-MM-DD}.log", + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", + rotation="00:00", retention="10 days", encoding="utf-8", level="DEBUG" ) +# 禁用Flask/werkzeug的HTTP请求日志 +logging.getLogger('werkzeug').setLevel(logging.ERROR) + # 创建Flask应用 app = Flask(__name__, static_folder='static', static_url_path='') -CORS(app) +app.secret_key = secrets.token_hex(32) # 生成随机密钥用于session +CORS(app, supports_credentials=True) -# 创建调度器实例 -scheduler = ClickScheduler() + +# 登录验证装饰器 +def login_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + # 点击服务模式下跳过登录验证(内部API) + if Config.SERVICE_MODE == 'click': + return f(*args, **kwargs) + if not session.get('logged_in'): + return jsonify({'success': False, 'message': '未登录', 'code': 401}), 401 + return f(*args, **kwargs) + return decorated_function + +# Web系统不初始化调度器,调度器通过main.py独立运行 + +# 创建数据管理器(远程模式下也需要访问数据库) +from data_manager import DataManager +data_manager = DataManager() + +# 创建管理器实例 +log_manager = LogManager() +config_manager = ConfigManager() +site_manager = EnhancedSiteManager() +click_manager = EnhancedClickManager() +interaction_manager = EnhancedInteractionManager() +stats_manager = EnhancedStatisticsManager() @app.route('/') def index(): - """首页 - 重定向到新的单页应用""" + """首页""" + if Config.SERVICE_MODE == 'click': + # 点击服务模式:只返回API状态 + return jsonify({ + 'service': 'MIP Click Service', + 'mode': 'click', + 'status': 'running', + 'endpoints': ['/api/scheduler/start', '/api/scheduler/stop', '/api/scheduler/status'] + }) + # Web服务模式:重定向到前端页面 return redirect('/app.html') +# ==================== 登录相关API ==================== + +@app.route('/api/auth/login', methods=['POST']) +def login(): + """用户登录""" + try: + data = request.get_json() + username = data.get('username', '') + password = data.get('password', '') + + if username == AUTH_USERNAME and password == AUTH_PASSWORD: + session['logged_in'] = True + session['username'] = username + logger.info(f"用户登录成功: {username}") + return jsonify({'success': True, 'message': '登录成功', 'data': {'username': username}}) + else: + logger.warning(f"登录失败,用户名或密码错误: {username}") + return jsonify({'success': False, 'message': '用户名或密码错误'}), 401 + except Exception as e: + logger.error(f"登录异常: {str(e)}") + return jsonify({'success': False, 'message': f'登录异常: {str(e)}'}), 500 + + +@app.route('/api/auth/logout', methods=['POST']) +def logout(): + """用户登出""" + try: + username = session.get('username', 'unknown') + session.clear() + logger.info(f"用户登出: {username}") + return jsonify({'success': True, 'message': '已退出登录'}) + except Exception as e: + logger.error(f"登出异常: {str(e)}") + return jsonify({'success': False, 'message': f'登出异常: {str(e)}'}), 500 + + +@app.route('/api/auth/check', methods=['GET']) +def check_auth(): + """检查登录状态""" + if session.get('logged_in'): + return jsonify({ + 'success': True, + 'data': { + 'logged_in': True, + 'username': session.get('username', '') + } + }) + else: + return jsonify({ + 'success': True, + 'data': { + 'logged_in': False + } + }) + + @app.route('/health', methods=['GET']) def health(): """健康检查""" @@ -44,6 +160,7 @@ def health(): @app.route('/api/urls', methods=['POST']) +@login_required def add_urls(): """添加URL(支持单个或批量)""" try: @@ -59,7 +176,7 @@ def add_urls(): if not url: return jsonify({'success': False, 'message': 'URL不能为空'}), 400 - success = scheduler.add_url(url) + success = data_manager.add_url(url) if success: return jsonify({'success': True, 'message': '添加成功'}) else: @@ -71,7 +188,7 @@ def add_urls(): if not isinstance(urls, list) or not urls: return jsonify({'success': False, 'message': 'URLs必须是非空列表'}), 400 - count = scheduler.add_urls(urls) + count = data_manager.add_urls(urls) return jsonify({ 'success': True, 'message': f'成功添加 {count}/{len(urls)} 个URL', @@ -91,7 +208,7 @@ def add_urls(): def get_urls(): """获取所有URL列表""" try: - urls = scheduler.data_manager.get_all_urls() + urls = data_manager.get_all_urls() return jsonify({'success': True, 'data': urls}) except Exception as e: @@ -103,7 +220,7 @@ def get_urls(): def get_url_detail(url: str): """获取URL详细信息""" try: - url_info = scheduler.get_url_detail(url) + url_info = data_manager.get_url_detail(url) if url_info: return jsonify({'success': True, 'data': url_info}) @@ -116,10 +233,11 @@ def get_url_detail(url: str): @app.route('/api/urls/', methods=['DELETE']) +@login_required def delete_url(url: str): """删除URL""" try: - success = scheduler.data_manager.delete_url(url) + success = data_manager.delete_url(url) if success: return jsonify({'success': True, 'message': '删除成功'}) @@ -132,10 +250,11 @@ def delete_url(url: str): @app.route('/api/urls//reset', methods=['POST']) +@login_required def reset_url(url: str): """重置URL(重新开始点击)""" try: - success = scheduler.data_manager.reset_url(url) + success = data_manager.reset_url(url) if success: return jsonify({'success': True, 'message': '重置成功'}) @@ -151,7 +270,7 @@ def reset_url(url: str): def get_statistics(): """获取统计数据""" try: - stats = scheduler.get_statistics() + stats = data_manager.get_statistics() return jsonify({'success': True, 'data': stats}) except Exception as e: @@ -161,38 +280,20 @@ def get_statistics(): @app.route('/api/scheduler/start', methods=['POST']) def start_scheduler(): - """启动调度器""" - try: - scheduler.start_scheduler() - return jsonify({'success': True, 'message': '调度器已启动'}) - - except Exception as e: - logger.error(f"启动调度器异常: {str(e)}") - return jsonify({'success': False, 'message': f'服务异常: {str(e)}'}), 500 + """启动调度器 - 需手动运行main.py""" + return jsonify({'success': False, 'message': '请在服务器手动运行 python main.py 启动调度器'}) @app.route('/api/scheduler/stop', methods=['POST']) def stop_scheduler(): - """停止调度器""" - try: - scheduler.stop_scheduler() - return jsonify({'success': True, 'message': '调度器已停止'}) - - except Exception as e: - logger.error(f"停止调度器异常: {str(e)}") - return jsonify({'success': False, 'message': f'服务异常: {str(e)}'}), 500 + """停止调度器 - 需手动停止""" + return jsonify({'success': False, 'message': '请在服务器手动停止调度器进程'}) @app.route('/api/scheduler/status', methods=['GET']) def get_scheduler_status(): - """获取调度器状态""" - try: - status = 'running' if scheduler.running else 'stopped' - return jsonify({'success': True, 'data': {'status': status}}) - - except Exception as e: - logger.error(f"获取调度器状态异常: {str(e)}") - return jsonify({'success': False, 'message': f'服务异常: {str(e)}'}), 500 + """获取调度器状态 - Web端不管理调度器""" + return jsonify({'success': True, 'data': {'status': 'manual', 'message': '调度器需手动管理'}}) # AdsPower 接口调试 @@ -492,13 +593,880 @@ def get_all_interactions(): return jsonify({'success': False, 'message': str(e)}), 500 +# ==================== 日志管理API ==================== + +@app.route('/api/logs/stream', methods=['GET']) +def get_logs_stream(): + """获取最新日志(用于实时流)""" + try: + limit = request.args.get('limit', 50, type=int) + level = request.args.get('level', 'ALL') + + logs = log_manager.get_latest_logs(limit=limit, level=level) + return jsonify({'success': True, 'data': logs}) + except Exception as e: + logger.error(f"获取日志流异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/logs/list', methods=['GET']) +def get_logs_list(): + """分页查询日志""" + try: + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 100, type=int) + level = request.args.get('level', 'ALL') + keyword = request.args.get('keyword', '') + start_time = request.args.get('start_time', '') + end_time = request.args.get('end_time', '') + + logs, total = log_manager.search_logs( + keyword=keyword, + level=level, + start_time=start_time, + end_time=end_time, + page=page, + page_size=page_size + ) + + return jsonify({ + 'success': True, + 'data': { + 'logs': logs, + 'total': total, + 'page': page, + 'page_size': page_size + } + }) + except Exception as e: + logger.error(f"查询日志列表异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/logs/files', methods=['GET']) +def get_log_files(): + """获取日志文件列表""" + try: + files = log_manager.get_log_files() + return jsonify({'success': True, 'data': files}) + except Exception as e: + logger.error(f"获取日志文件列表异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/logs/stats', methods=['GET']) +def get_log_stats(): + """获取日志统计信息""" + try: + stats = log_manager.get_log_stats() + return jsonify({'success': True, 'data': stats}) + except Exception as e: + logger.error(f"获取日志统计异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== 配置管理API ==================== + +@app.route('/api/config', methods=['GET']) +def get_config(): + """获取当前配置""" + try: + config = config_manager.get_current_config() + return jsonify({'success': True, 'data': config}) + except Exception as e: + logger.error(f"获取配置异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/config/update', methods=['POST']) +@login_required +def update_config(): + """更新配置""" + try: + data = request.get_json() + if not data: + return jsonify({'success': False, 'message': '请求数据为空'}), 400 + + result = config_manager.update_config(data) + return jsonify(result) + except Exception as e: + logger.error(f"更新配置异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/config/schema', methods=['GET']) +def get_config_schema(): + """获取配置项定义""" + try: + schema = config_manager.get_config_schema() + return jsonify({'success': True, 'data': schema}) + except Exception as e: + logger.error(f"获取配置定义异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== 图表统计API ==================== + +@app.route('/api/statistics/trend', methods=['GET']) +def get_statistics_trend(): + """获取点击趋势数据""" + try: + days = request.args.get('days', 7, type=int) + data = stats_manager.get_click_trend(days=days) + return jsonify({'success': True, 'data': data}) + except Exception as e: + logger.error(f"获取点击趋势异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/statistics/hourly', methods=['GET']) +def get_statistics_hourly(): + """获取时段分布数据""" + try: + data = stats_manager.get_hourly_distribution() + return jsonify({'success': True, 'data': data}) + except Exception as e: + logger.error(f"获取时段分布异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/statistics/top-sites', methods=['GET']) +def get_top_sites(): + """获取Top活跃站点""" + try: + limit = request.args.get('limit', 10, type=int) + data = stats_manager.get_top_sites(limit=limit) + return jsonify({'success': True, 'data': data}) + except Exception as e: + logger.error(f"获取Top站点异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/statistics/reply-rate', methods=['GET']) +def get_reply_rate(): + """获取回复率分布""" + try: + data = stats_manager.get_reply_rate_distribution() + return jsonify({'success': True, 'data': data}) + except Exception as e: + logger.error(f"获取回复率分布异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== 增强的分页查询API ==================== + +@app.route('/api/sites/paginated', methods=['GET']) +def get_sites_paginated(): + """分页获取站点列表""" + try: + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + status = request.args.get('status', '') + keyword = request.args.get('keyword', '') + sort_by = request.args.get('sort_by', 'created_at') + sort_order = request.args.get('sort_order', 'desc') + + sites, total = site_manager.get_sites_paginated( + page=page, + page_size=page_size, + status=status if status else None, + keyword=keyword if keyword else None, + sort_by=sort_by, + sort_order=sort_order + ) + + return jsonify({ + 'success': True, + 'data': { + 'items': sites, + 'total': total, + 'page': page, + 'page_size': page_size + } + }) + except Exception as e: + logger.error(f"分页查询站点异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/clicks/paginated', methods=['GET']) +def get_clicks_paginated(): + """分页获取点击记录""" + try: + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + site_id = request.args.get('site_id', type=int) + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + sort_by = request.args.get('sort_by', 'click_time') + sort_order = request.args.get('sort_order', 'desc') + + clicks, total = click_manager.get_clicks_paginated( + page=page, + page_size=page_size, + site_id=site_id, + start_date=start_date if start_date else None, + end_date=end_date if end_date else None, + sort_by=sort_by, + sort_order=sort_order + ) + + return jsonify({ + 'success': True, + 'data': { + 'items': clicks, + 'total': total, + 'page': page, + 'page_size': page_size + } + }) + except Exception as e: + logger.error(f"分页查询点击记录异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/interactions/paginated', methods=['GET']) +def get_interactions_paginated(): + """分页获取互动记录""" + try: + page = request.args.get('page', 1, type=int) + page_size = request.args.get('page_size', 20, type=int) + site_id = request.args.get('site_id', type=int) + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + status = request.args.get('status', '') + sort_by = request.args.get('sort_by', 'interaction_time') + sort_order = request.args.get('sort_order', 'desc') + + interactions, total = interaction_manager.get_interactions_paginated( + page=page, + page_size=page_size, + site_id=site_id, + start_date=start_date if start_date else None, + end_date=end_date if end_date else None, + status=status if status else None, + sort_by=sort_by, + sort_order=sort_order + ) + + return jsonify({ + 'success': True, + 'data': { + 'items': interactions, + 'total': total, + 'page': page, + 'page_size': page_size + } + }) + except Exception as e: + logger.error(f"分页查询互动记录异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== 批量操作API ==================== + +def _run_import_task(task_id: str, df, params: dict): + """后台执行导入任务""" + global import_tasks + + try: + task = import_tasks[task_id] + task['status'] = 'running' + + # 获取参数 + url_column = params['url_column'] + query_word = params.get('query_word') + site_dimension = params.get('site_dimension') + frequency = params.get('frequency', 1) + time_start = params.get('time_start', '09:00:00') + time_end = params.get('time_end', '21:00:00') + interval_minutes = params.get('interval_minutes', 30) + + site_manager = EnhancedSiteManager() + total = len(df) + + # 批量获取已存在的URL(优化查询) + all_urls = [str(row.get(url_column, '')).strip() for _, row in df.iterrows() + if not pd.isna(row.get(url_column)) and str(row.get(url_column, '')).strip()] + existing_urls = set(site_manager.get_existing_urls(all_urls) if hasattr(site_manager, 'get_existing_urls') else []) + + for idx, row in df.iterrows(): + if task.get('cancelled'): + task['status'] = 'cancelled' + return + + try: + # 更新进度 + task['current'] = idx + 1 + task['progress'] = int((idx + 1) / total * 100) + + # 获取链接 + site_url = row.get(url_column, None) + + # 跳过空链接 + if pd.isna(site_url) or not site_url or str(site_url).strip() == '': + task['stats']['skipped'] += 1 + continue + + site_url = str(site_url).strip() + + # 验证URL格式 + if not (site_url.startswith('http://') or site_url.startswith('https://')): + task['stats']['skipped'] += 1 + continue + + # 检查是否已存在(使用预查询结果) + if site_url in existing_urls: + task['stats']['duplicate'] += 1 + continue + + # 获取站点名称 + site_name = None + for col in ['医生', '名称', 'name', 'Name', 'site_name', '站点名称']: + if col in df.columns: + site_name = row.get(col) + break + if pd.isna(site_name) or not site_name: + site_name = site_url + else: + site_name = str(site_name).strip() + + # 获取查询词 + row_query_word = None + for col in ['查询词', 'query_word', 'keyword', '关键词']: + if col in df.columns: + row_query_word = row.get(col) + break + if pd.isna(row_query_word) or not row_query_word: + row_query_word = query_word + else: + row_query_word = str(row_query_word).strip() + + # 获取维度 + row_dimension = None + for col in ['维度', 'dimension', 'site_dimension', '分类']: + if col in df.columns: + row_dimension = row.get(col) + break + if pd.isna(row_dimension) or not row_dimension: + row_dimension = site_dimension + else: + row_dimension = str(row_dimension).strip() + + # 插入数据库 + site_id = site_manager.add_site( + site_url=site_url, + site_name=site_name, + site_dimension=row_dimension, + query_word=row_query_word, + frequency=frequency, + time_start=time_start, + time_end=time_end, + interval_minutes=interval_minutes + ) + + if site_id: + task['stats']['success'] += 1 + existing_urls.add(site_url) # 避免重复插入 + else: + task['stats']['failed'] += 1 + + except Exception as e: + logger.error(f"处理第 {idx+1} 行失败: {str(e)}") + task['stats']['failed'] += 1 + + task['status'] = 'completed' + task['progress'] = 100 + logger.info(f"导入任务 {task_id} 完成: {task['stats']}") + + except Exception as e: + logger.error(f"导入任务 {task_id} 异常: {str(e)}") + import_tasks[task_id]['status'] = 'error' + import_tasks[task_id]['error'] = str(e) + + +@app.route('/api/sites/import', methods=['POST']) +@login_required +def import_sites_file(): + """从Excel或CSV文件导入站点(异步处理)""" + try: + if 'file' not in request.files: + return jsonify({'success': False, 'message': '请上传文件'}), 400 + + file = request.files['file'] + if not file.filename: + return jsonify({'success': False, 'message': '文件名为空'}), 400 + + filename = file.filename.lower() + + # 获取额外参数 + params = { + 'query_word': request.form.get('query_word', None), + 'site_dimension': request.form.get('site_dimension', None), + 'frequency': int(request.form.get('frequency', 1)), + 'time_start': request.form.get('time_start', '09:00:00'), + 'time_end': request.form.get('time_end', '21:00:00'), + 'interval_minutes': int(request.form.get('interval_minutes', 30)) + } + + # 根据文件类型读取数据 + if filename.endswith('.xlsx') or filename.endswith('.xls'): + df = pd.read_excel(file) + elif filename.endswith('.csv'): + df = pd.read_csv(file, encoding='utf-8-sig') + else: + return jsonify({'success': False, 'message': '请上传Excel(.xlsx/.xls)或CSV(.csv)格式文件'}), 400 + + logger.info(f"导入文件: {file.filename}, 共 {len(df)} 行数据") + + # 检测URL列名 + url_column = None + for col_name in ['链接', 'url', 'URL', 'link', 'Link', '网址', 'site_url']: + if col_name in df.columns: + url_column = col_name + break + + if url_column is None: + url_column = df.columns[0] + + params['url_column'] = url_column + + # 创建导入任务 + task_id = str(uuid.uuid4())[:8] + import_tasks[task_id] = { + 'id': task_id, + 'filename': file.filename, + 'total': len(df), + 'current': 0, + 'progress': 0, + 'status': 'pending', + 'stats': {'total': len(df), 'success': 0, 'failed': 0, 'skipped': 0, 'duplicate': 0}, + 'created_at': datetime.now().isoformat() + } + + # 启动后台线程 + thread = threading.Thread(target=_run_import_task, args=(task_id, df, params)) + thread.daemon = True + thread.start() + + return jsonify({ + 'success': True, + 'message': '导入任务已启动', + 'task_id': task_id, + 'total': len(df) + }) + + except Exception as e: + logger.error(f"导入文件异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/sites/import/progress/', methods=['GET']) +def get_import_progress(task_id: str): + """获取导入任务进度""" + if task_id not in import_tasks: + return jsonify({'success': False, 'message': '任务不存在'}), 404 + + task = import_tasks[task_id] + return jsonify({ + 'success': True, + 'data': { + 'id': task['id'], + 'filename': task['filename'], + 'total': task['total'], + 'current': task['current'], + 'progress': task['progress'], + 'status': task['status'], + 'stats': task['stats'], + 'error': task.get('error') + } + }) + + +@app.route('/api/sites/import/cancel/', methods=['POST']) +@login_required +def cancel_import_task(task_id: str): + """取消导入任务""" + if task_id not in import_tasks: + return jsonify({'success': False, 'message': '任务不存在'}), 404 + + import_tasks[task_id]['cancelled'] = True + return jsonify({'success': True, 'message': '已发送取消请求'}) + + +@app.route('/api/sites/import/tasks', methods=['GET']) +def list_import_tasks(): + """获取所有导入任务(包括进行中的)""" + # 按创建时间倒序,只返回最近10个 + tasks = sorted(import_tasks.values(), key=lambda x: x.get('created_at', ''), reverse=True)[:10] + # 只返回进行中和最近完成的任务 + active_tasks = [t for t in tasks if t.get('status') in ['pending', 'running'] or + (t.get('status') == 'completed' and t.get('progress', 0) == 100)] + return jsonify({'success': True, 'data': active_tasks}) + + +@app.route('/api/sites//status', methods=['PUT']) +@login_required +def update_site_status(site_id: int): + """更新单个站点状态""" + try: + data = request.get_json() + status = data.get('status', '') + + if status not in ['active', 'inactive']: + return jsonify({'success': False, 'message': '无效的状态值'}), 400 + + from db_manager import SiteManager + mgr = SiteManager() + success = mgr.update_site_status(site_id, status) + + if success: + return jsonify({'success': True, 'message': '状态更新成功'}) + else: + return jsonify({'success': False, 'message': '更新失败'}), 400 + except Exception as e: + logger.error(f"更新站点状态异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/sites/batch/delete', methods=['POST']) +@login_required +def batch_delete_sites(): + """批量删除站点""" + try: + data = request.get_json() + site_ids = data.get('site_ids', []) + + if not site_ids: + return jsonify({'success': False, 'message': '请选择要删除的站点'}), 400 + + deleted = site_manager.delete_sites_batch(site_ids) + return jsonify({ + 'success': True, + 'message': f'成功删除 {deleted}/{len(site_ids)} 个站点', + 'deleted_count': deleted + }) + except Exception as e: + logger.error(f"批量删除站点异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/sites/batch/status', methods=['POST']) +@login_required +def batch_update_sites_status(): + """批量更新站点状态""" + try: + data = request.get_json() + site_ids = data.get('site_ids', []) + status = data.get('status', '') + + if not site_ids or not status: + return jsonify({'success': False, 'message': '请提供站点ID和状态'}), 400 + + if status not in ['active', 'inactive']: + return jsonify({'success': False, 'message': '无效的状态值'}), 400 + + updated = site_manager.update_sites_status_batch(site_ids, status) + return jsonify({ + 'success': True, + 'message': f'成功更新 {updated}/{len(site_ids)} 个站点', + 'updated_count': updated + }) + except Exception as e: + logger.error(f"批量更新站点状态异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== 数据导出API ==================== + +@app.route('/api/export/sites', methods=['GET']) +def export_sites(): + """导出站点数据为CSV""" + try: + status = request.args.get('status', '') + keyword = request.args.get('keyword', '') + + sites = site_manager.export_sites( + status=status if status else None, + keyword=keyword if keyword else None + ) + + # 生成CSV + output = io.StringIO() + writer = csv.writer(output) + + # 写入表头 + headers = ['ID', '站点URL', '站点名称', '状态', '点击次数', '回复次数', + '频次', '开始时间', '结束时间', '维度', '查询词', '创建时间'] + writer.writerow(headers) + + # 写入数据 + for site in sites: + writer.writerow([ + site.get('id', ''), + site.get('site_url', ''), + site.get('site_name', ''), + site.get('status', ''), + site.get('click_count', 0), + site.get('reply_count', 0), + site.get('frequency', ''), + site.get('time_start', ''), + site.get('time_end', ''), + site.get('site_dimension', ''), + site.get('query_word', ''), + site.get('created_at', '') + ]) + + output.seek(0) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=sites_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv', + 'Content-Type': 'text/csv; charset=utf-8-sig' + } + ) + except Exception as e: + logger.error(f"导出站点数据异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/export/clicks', methods=['GET']) +def export_clicks(): + """导出点击记录为CSV""" + try: + site_id = request.args.get('site_id', type=int) + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + clicks = click_manager.export_clicks( + site_id=site_id, + start_date=start_date if start_date else None, + end_date=end_date if end_date else None + ) + + # 生成CSV + output = io.StringIO() + writer = csv.writer(output) + + headers = ['ID', '站点ID', '站点名称', '站点URL', '点击时间', + '用户IP', '设备类型', '任务ID'] + writer.writerow(headers) + + for click in clicks: + writer.writerow([ + click.get('id', ''), + click.get('site_id', ''), + click.get('site_name', ''), + click.get('site_url', ''), + click.get('click_time', ''), + click.get('user_ip', ''), + click.get('device_type', ''), + click.get('task_id', '') + ]) + + output.seek(0) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=clicks_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv', + 'Content-Type': 'text/csv; charset=utf-8-sig' + } + ) + except Exception as e: + logger.error(f"导出点击记录异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/export/interactions', methods=['GET']) +def export_interactions(): + """导出互动记录为CSV""" + try: + site_id = request.args.get('site_id', type=int) + start_date = request.args.get('start_date', '') + end_date = request.args.get('end_date', '') + + interactions = interaction_manager.export_interactions( + site_id=site_id, + start_date=start_date if start_date else None, + end_date=end_date if end_date else None + ) + + # 生成CSV + output = io.StringIO() + writer = csv.writer(output) + + headers = ['ID', '站点ID', '站点名称', '站点URL', '互动时间', + '互动类型', '互动状态', '发送内容', '是否收到回复', '回复内容', '代理IP'] + writer.writerow(headers) + + for interaction in interactions: + writer.writerow([ + interaction.get('id', ''), + interaction.get('site_id', ''), + interaction.get('site_name', ''), + interaction.get('site_url', ''), + interaction.get('interaction_time', ''), + interaction.get('interaction_type', ''), + interaction.get('interaction_status', ''), + interaction.get('reply_content', ''), + '是' if interaction.get('response_received') else '否', + interaction.get('response_content', ''), + interaction.get('proxy_ip', '') + ]) + + output.seek(0) + + return Response( + output.getvalue(), + mimetype='text/csv', + headers={ + 'Content-Disposition': f'attachment; filename=interactions_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv', + 'Content-Type': 'text/csv; charset=utf-8-sig' + } + ) + except Exception as e: + logger.error(f"导出互动记录异常: {str(e)}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== Query挖掘API ==================== + +@app.route('/api/query/upload', methods=['POST']) +@login_required +def upload_query_file(): + """上传Query关键词Excel文件""" + try: + if 'file' not in request.files: + return jsonify({'success': False, 'message': '请上传文件'}), 400 + + file = request.files['file'] + if not file.filename: + return jsonify({'success': False, 'message': '文件名为空'}), 400 + + filename = file.filename + ext = filename.rsplit('.', 1)[-1].lower() if '.' in filename else '' + if ext not in ('xlsx', 'xls'): + return jsonify({'success': False, 'message': '请上传Excel文件(.xlsx/.xls)'}), 400 + + # 获取导入模式 + import_mode = request.form.get('import_mode', 'query_only') + + # 先读取Excel验证列数 + file_bytes = file.read() + from io import BytesIO + df = pd.read_excel(BytesIO(file_bytes)) + col_count = len(df.columns) + + if import_mode == 'query_only': + if col_count != 1: + return jsonify({'success': False, 'message': f'仅导入Query模式要求Excel只有1列,当前有{col_count}列'}) + elif import_mode == 'full_import': + if col_count != 3: + return jsonify({'success': False, 'message': f'完整导入模式要求Excel恰好3列(科室、关键字、query),当前有{col_count}列'}) + + # 确保上传目录存在 + upload_dir = Config.QUERY_UPLOAD_DIR + os.makedirs(upload_dir, exist_ok=True) + + # 生成带时间戳的文件名避免重名 + timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') + safe_filename = f"{timestamp}_{filename}" + filepath = os.path.abspath(os.path.join(upload_dir, safe_filename)) + + # 保存文件 + with open(filepath, 'wb') as f: + f.write(file_bytes) + logger.info(f"[Query上传] 文件已保存: {filepath}, 模式: {import_mode}") + + # 创建导入日志 + from db_manager import QueryImportLogManager + log_mgr = QueryImportLogManager() + log_id = log_mgr.create_log(filename, filepath) + + # 启动后台导入线程 + from query_keyword_importer import QueryKeywordImporter + importer = QueryKeywordImporter() + thread = threading.Thread(target=importer.import_file, args=(filepath, log_id, import_mode)) + thread.daemon = True + thread.start() + + return jsonify({ + 'success': True, + 'message': '文件上传成功,导入任务已启动', + 'log_id': log_id, + 'filename': filename + }) + + except Exception as e: + logger.error(f"[Query上传] 异常: {e}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/query/import/records', methods=['GET']) +@login_required +def get_query_import_records(): + """获取Query导入记录(分页)""" + try: + page = int(request.args.get('page', 1)) + page_size = int(request.args.get('page_size', 20)) + + from db_manager import QueryImportLogManager + log_mgr = QueryImportLogManager() + result = log_mgr.get_logs_paginated(page, page_size) + + return jsonify({'success': True, 'data': result}) + except Exception as e: + logger.error(f"[Query记录] 查询失败: {e}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +@app.route('/api/query/import/trigger', methods=['POST']) +@login_required +def trigger_query_import(): + """手动触发Query目录扫描和导入""" + try: + from query_keyword_importer import QueryKeywordImporter + importer = QueryKeywordImporter() + + thread = threading.Thread(target=importer.scan_and_import) + thread.daemon = True + thread.start() + + return jsonify({ + 'success': True, + 'message': '目录扫描任务已启动' + }) + except Exception as e: + logger.error(f"[Query触发] 异常: {e}") + return jsonify({'success': False, 'message': str(e)}), 500 + + +# ==================== 任务队列API ==================== + +@app.route('/api/tasks/queue', methods=['GET']) +def get_task_queue(): + """获取任务队列状态 - Web端不管理调度器""" + return jsonify({ + 'success': True, + 'data': { + 'pending': [], + 'running': None, + 'completed': [], + 'scheduler_status': 'manual', + 'message': '调度器需通过 main.py 独立运行' + } + }) + + if __name__ == '__main__': logger.info(f"启动MIP广告点击服务 - 环境: {Config.ENV}") logger.info(f"服务地址: http://{Config.SERVER_HOST}:{Config.SERVER_PORT}") logger.info(f"调试模式: {Config.DEBUG}") - - # 自动启动调度器 - scheduler.start_scheduler() + logger.info("调度器需通过 python main.py 独立启动") # 启动Flask应用 app.run( diff --git a/config.py b/config.py index 378bc71..9cdd123 100644 --- a/config.py +++ b/config.py @@ -48,6 +48,7 @@ class BaseConfig: CLICK_INTERVAL_MINUTES = int(os.getenv('CLICK_INTERVAL_MINUTES', 30)) # 点击间隔(分钟) MIN_TASK_INTERVAL_MINUTES = int(os.getenv('MIN_TASK_INTERVAL_MINUTES', 3)) # 任务间最小间隔(分钟) MAX_TASK_INTERVAL_MINUTES = int(os.getenv('MAX_TASK_INTERVAL_MINUTES', 5)) # 任务间最大间隔(分钟) + MAX_CONCURRENT_WORKERS = int(os.getenv('MAX_CONCURRENT_WORKERS', 2)) # 最大并发任务数(默认2) WORK_START_HOUR = int(os.getenv('WORK_START_HOUR', 9)) # 工作开始时间 WORK_END_HOUR = int(os.getenv('WORK_END_HOUR', 21)) # 工作结束时间 REPLY_WAIT_TIMEOUT = int(os.getenv('REPLY_WAIT_TIMEOUT', 30)) # 回复等待超时(秒) @@ -60,6 +61,7 @@ class BaseConfig: # 数据存储路径 DATA_DIR = os.getenv('DATA_DIR', './data') LOG_DIR = os.getenv('LOG_DIR', './logs') + QUERY_UPLOAD_DIR = os.getenv('QUERY_UPLOAD_DIR', './query_upload') # Query挖掘上传目录 # 调试模式 DEBUG = os.getenv('DEBUG', 'False').lower() == 'true' @@ -74,11 +76,24 @@ class BaseConfig: MYSQL_PASSWORD = os.getenv('MYSQL_PASSWORD', '') MYSQL_DATABASE = os.getenv('MYSQL_DATABASE', 'ai_article') + # 远程点击服务配置(分布式部署时使用) + # 为空表示本地模式,调度器在Web服务内运行 + # 设置URL表示远程模式,Web服务转发请求到远程点击服务 + CLICK_SERVICE_URL = os.getenv('CLICK_SERVICE_URL', '') # 例如: http://192.168.1.100:8888 + + # 服务模式:web=提供前端界面, click=仅提供调度API + SERVICE_MODE = os.getenv('SERVICE_MODE', 'web') + + # 千问大模型API配置 + QWEN_API_KEY = os.getenv('QWEN_API_KEY', 'sk-6d22dd845a624d9c92a821d24a50e2e8') + QWEN_API_URL = os.getenv('QWEN_API_URL', 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions') + @classmethod def ensure_dirs(cls): """确保必要的目录存在""" os.makedirs(cls.DATA_DIR, exist_ok=True) os.makedirs(cls.LOG_DIR, exist_ok=True) + os.makedirs(cls.QUERY_UPLOAD_DIR, exist_ok=True) class DevelopmentConfig(BaseConfig): @@ -88,7 +103,7 @@ class DevelopmentConfig(BaseConfig): class ProductionConfig(BaseConfig): """生产环境配置""" - DEBUG = False + DEBUG = True # 根据环境选择配置 diff --git a/config_manager.py b/config_manager.py new file mode 100644 index 0000000..08290b5 --- /dev/null +++ b/config_manager.py @@ -0,0 +1,261 @@ +""" +配置管理模块 +提供配置的读取、更新和持久化功能 +""" +import os +import re +from pathlib import Path +from typing import Dict, Any, Optional +from config import Config + + +class ConfigManager: + """配置管理器""" + + # 可修改的配置项定义 + CONFIGURABLE_ITEMS = { + 'click_strategy': { + 'MIN_CLICK_COUNT': {'type': 'int', 'min': 1, 'max': 100, 'label': '最小点击次数'}, + 'MAX_CLICK_COUNT': {'type': 'int', 'min': 1, 'max': 100, 'label': '最大点击次数'}, + 'CLICK_INTERVAL_MINUTES': {'type': 'int', 'min': 1, 'max': 1440, 'label': '点击间隔(分钟)'}, + }, + 'work_time': { + 'WORK_START_HOUR': {'type': 'int', 'min': 0, 'max': 23, 'label': '工作开始时间'}, + 'WORK_END_HOUR': {'type': 'int', 'min': 0, 'max': 23, 'label': '工作结束时间'}, + }, + 'task_config': { + 'MIN_TASK_INTERVAL_MINUTES': {'type': 'int', 'min': 1, 'max': 60, 'label': '任务最小间隔(分钟)'}, + 'MAX_TASK_INTERVAL_MINUTES': {'type': 'int', 'min': 1, 'max': 60, 'label': '任务最大间隔(分钟)'}, + 'REPLY_WAIT_TIMEOUT': {'type': 'int', 'min': 5, 'max': 300, 'label': '回复等待超时(秒)'}, + }, + 'crawler_config': { + 'CRAWLER_ENABLED': {'type': 'bool', 'label': '启用爬虫'}, + 'CRAWLER_SCHEDULE_TIME': {'type': 'str', 'pattern': r'^\d{2}:\d{2}$', 'label': '爬虫执行时间'}, + 'CRAWLER_BATCH_SIZE': {'type': 'int', 'min': 1, 'max': 100, 'label': '每次爬取数量'}, + }, + 'server_config': { + 'SERVER_PORT': {'type': 'int', 'min': 1024, 'max': 65535, 'label': '服务端口'}, + 'DEBUG': {'type': 'bool', 'label': '调试模式'}, + } + } + + def __init__(self): + self.env = os.getenv('ENV', 'development') + self.env_file = f'.env.{self.env}' if self.env in ['development', 'production'] else '.env' + self.env_path = Path(self.env_file) + + def get_current_config(self) -> Dict[str, Any]: + """获取当前配置""" + config = { + 'env': self.env, + 'click_strategy': { + 'min_click_count': getattr(Config, 'MIN_CLICK_COUNT', 1), + 'max_click_count': getattr(Config, 'MAX_CLICK_COUNT', 3), + 'click_interval_minutes': getattr(Config, 'CLICK_INTERVAL_MINUTES', 30), + }, + 'work_time': { + 'start_hour': getattr(Config, 'WORK_START_HOUR', 9), + 'end_hour': getattr(Config, 'WORK_END_HOUR', 21), + }, + 'task_config': { + 'min_interval_minutes': getattr(Config, 'MIN_TASK_INTERVAL_MINUTES', 3), + 'max_interval_minutes': getattr(Config, 'MAX_TASK_INTERVAL_MINUTES', 5), + 'reply_wait_timeout': getattr(Config, 'REPLY_WAIT_TIMEOUT', 30), + }, + 'crawler_config': { + 'enabled': getattr(Config, 'CRAWLER_ENABLED', True), + 'schedule_time': getattr(Config, 'CRAWLER_SCHEDULE_TIME', '02:00'), + 'batch_size': getattr(Config, 'CRAWLER_BATCH_SIZE', 10), + }, + 'server_config': { + 'host': getattr(Config, 'SERVER_HOST', '0.0.0.0'), + 'port': getattr(Config, 'SERVER_PORT', 5000), + 'debug': getattr(Config, 'DEBUG', False), + }, + 'adspower_config': { + 'api_url': getattr(Config, 'ADSPOWER_API_URL', 'http://local.adspower.net:50325'), + 'user_id': getattr(Config, 'ADSPOWER_USER_ID', ''), + }, + 'paths': { + 'data_dir': getattr(Config, 'DATA_DIR', './data'), + 'log_dir': getattr(Config, 'LOG_DIR', './logs'), + } + } + return config + + def validate_config(self, key: str, value: Any, config_def: Dict) -> tuple: + """ + 验证配置值 + 返回: (是否有效, 错误消息) + """ + config_type = config_def.get('type', 'str') + + try: + if config_type == 'int': + value = int(value) + min_val = config_def.get('min') + max_val = config_def.get('max') + if min_val is not None and value < min_val: + return False, f'{key} 不能小于 {min_val}' + if max_val is not None and value > max_val: + return False, f'{key} 不能大于 {max_val}' + + elif config_type == 'bool': + if isinstance(value, str): + value = value.lower() in ('true', '1', 'yes') + else: + value = bool(value) + + elif config_type == 'str': + value = str(value) + pattern = config_def.get('pattern') + if pattern and not re.match(pattern, value): + return False, f'{key} 格式不正确' + + return True, None + + except (ValueError, TypeError) as e: + return False, f'{key} 值无效: {str(e)}' + + def update_config(self, updates: Dict[str, Any]) -> Dict[str, Any]: + """ + 更新配置 + 返回: {'success': bool, 'message': str, 'updated': list, 'requires_restart': bool} + """ + result = { + 'success': True, + 'message': '', + 'updated': [], + 'requires_restart': False, + 'errors': [] + } + + # 配置项映射 + config_mapping = { + 'min_click_count': 'MIN_CLICK_COUNT', + 'max_click_count': 'MAX_CLICK_COUNT', + 'click_interval_minutes': 'CLICK_INTERVAL_MINUTES', + 'start_hour': 'WORK_START_HOUR', + 'end_hour': 'WORK_END_HOUR', + 'min_interval_minutes': 'MIN_TASK_INTERVAL_MINUTES', + 'max_interval_minutes': 'MAX_TASK_INTERVAL_MINUTES', + 'reply_wait_timeout': 'REPLY_WAIT_TIMEOUT', + 'crawler_enabled': 'CRAWLER_ENABLED', + 'schedule_time': 'CRAWLER_SCHEDULE_TIME', + 'batch_size': 'CRAWLER_BATCH_SIZE', + 'server_port': 'SERVER_PORT', + 'debug': 'DEBUG', + } + + # 需要重启的配置项 + restart_required_keys = ['server_port', 'debug', 'crawler_enabled', 'schedule_time'] + + env_updates = {} + + for key, value in updates.items(): + env_key = config_mapping.get(key) + if not env_key: + continue + + # 查找配置定义 + config_def = None + for category, items in self.CONFIGURABLE_ITEMS.items(): + if env_key in items: + config_def = items[env_key] + break + + if not config_def: + continue + + # 验证 + is_valid, error = self.validate_config(key, value, config_def) + if not is_valid: + result['errors'].append(error) + continue + + env_updates[env_key] = value + result['updated'].append(key) + + if key in restart_required_keys: + result['requires_restart'] = True + + if result['errors']: + result['success'] = False + result['message'] = '部分配置验证失败: ' + '; '.join(result['errors']) + return result + + if not env_updates: + result['message'] = '没有需要更新的配置' + return result + + # 更新环境变量和Config类 + for key, value in env_updates.items(): + os.environ[key] = str(value) + if hasattr(Config, key): + setattr(Config, key, value) + + # 尝试更新.env文件 + try: + self._update_env_file(env_updates) + except Exception as e: + result['message'] = f'配置已更新到内存,但写入文件失败: {str(e)}' + return result + + result['message'] = f'成功更新 {len(result["updated"])} 项配置' + if result['requires_restart']: + result['message'] += '(部分配置需要重启服务生效)' + + return result + + def _update_env_file(self, updates: Dict[str, Any]): + """更新.env文件""" + if not self.env_path.exists(): + # 如果文件不存在,创建新文件 + lines = [] + else: + with open(self.env_path, 'r', encoding='utf-8') as f: + lines = f.readlines() + + # 更新已存在的配置 + updated_keys = set() + new_lines = [] + for line in lines: + stripped = line.strip() + if stripped and not stripped.startswith('#') and '=' in stripped: + key = stripped.split('=')[0].strip() + if key in updates: + value = updates[key] + if isinstance(value, bool): + value = 'true' if value else 'false' + new_lines.append(f'{key}={value}\n') + updated_keys.add(key) + else: + new_lines.append(line) + else: + new_lines.append(line) + + # 添加新的配置项 + for key, value in updates.items(): + if key not in updated_keys: + if isinstance(value, bool): + value = 'true' if value else 'false' + new_lines.append(f'{key}={value}\n') + + # 写入文件 + with open(self.env_path, 'w', encoding='utf-8') as f: + f.writelines(new_lines) + + def get_config_schema(self) -> Dict: + """获取配置项定义(用于前端表单生成)""" + schema = {} + for category, items in self.CONFIGURABLE_ITEMS.items(): + schema[category] = {} + for key, config_def in items.items(): + schema[category][key] = { + 'type': config_def['type'], + 'label': config_def['label'], + 'min': config_def.get('min'), + 'max': config_def.get('max'), + 'pattern': config_def.get('pattern'), + } + return schema diff --git a/db/QUERY_TASK_README.md b/db/QUERY_TASK_README.md deleted file mode 100644 index 27c0953..0000000 --- a/db/QUERY_TASK_README.md +++ /dev/null @@ -1,107 +0,0 @@ -# AI MIP Query Task 表创建说明 - -## 1. 创建表 - -在MySQL数据库中执行以下文件: - -```bash -mysql -u your_user -p your_database < db/ai_mip_query_task.sql -``` - -或者在MySQL客户端中直接执行 `db/ai_mip_query_task.sql` 文件内容。 - -## 2. 表结构说明 - -### 字段列表 - -| 字段名 | 类型 | 说明 | -|--------|------|------| -| id | int | 主键ID | -| query_word | varchar(512) | 查询词/关键词 | -| query_type | enum | 查询类型:keyword/phrase/long_tail | -| task_date | char(8) | 任务日期 YYYYMMDD | -| threshold_max | int | 最大抓取数量阈值 | -| current_count | int | 当前已抓取数量 | -| status | enum | 任务状态:ready/doing/failed/finished/closed | -| priority | tinyint | 优先级 1-10 | -| category | varchar(64) | 分类标签 | -| source_platform | varchar(64) | 来源平台 | -| crawl_url_count | int | 已爬取URL数量 | -| valid_url_count | int | 有效URL数量(带广告) | -| error_message | text | 错误信息 | -| started_at | timestamp | 开始执行时间 | -| finished_at | timestamp | 完成时间 | -| closed_at | timestamp | 达到阈值关闭时间 | -| created_at | timestamp | 创建时间 | -| updated_at | timestamp | 更新时间 | -| created_by | varchar(64) | 创建人 | -| remark | varchar(512) | 备注信息 | - -### 索引 - -- `uniq_query_date`: 同一查询词每天只有一个任务 -- `idx_date_status`: 按日期和状态查询 -- `idx_status_priority`: 按状态和优先级查询 -- `idx_category`: 按分类查询 -- `idx_threshold`: 阈值监控 -- `idx_closed`: 关闭时间索引 - -## 3. 使用示例 - -### Python代码 - -```python -from db_manager import QueryTaskManager - -# 初始化管理器 -task_mgr = QueryTaskManager() - -# 创建任务 -task_id = task_mgr.create_task( - query_word="糖尿病治疗", - query_type="keyword", - threshold_max=50, - priority=3, - category="医疗" -) - -# 获取ready任务 -ready_tasks = task_mgr.get_ready_tasks(limit=10) - -# 更新任务状态 -task_mgr.update_task_status(task_id, 'doing') - -# 增加抓取计数 -task_mgr.increment_crawl_count(task_id, crawl_count=5, valid_count=3) - -# 检查阈值 -task_mgr.check_threshold(task_id) - -# 获取统计信息 -stats = task_mgr.get_task_statistics('20260119') -``` - -## 4. 测试 - -运行测试脚本: - -```bash -python test_query_task.py -``` - -## 5. 任务状态流转 - -``` -ready (准备中) - ↓ -doing (执行中) - ↓ -finished (完成) / failed (失败) / closed (达到阈值关闭) -``` - -## 6. 注意事项 - -1. **唯一约束**:同一查询词在同一天只能有一个任务 -2. **阈值检查**:达到threshold_max时自动关闭任务 -3. **优先级**:数字越小优先级越高(1-10) -4. **时间戳**:状态变更会自动更新对应的时间字段 diff --git a/db/ai_mip_click.sql b/db/ai_mip_click.sql deleted file mode 100644 index c4804ba..0000000 --- a/db/ai_mip_click.sql +++ /dev/null @@ -1,51 +0,0 @@ -/* - Navicat Premium Dump SQL - - Source Server : mixue - Source Server Type : MySQL - Source Server Version : 90001 (9.0.1) - Source Host : localhost:3306 - Source Schema : ai_article - - Target Server Type : MySQL - Target Server Version : 90001 (9.0.1) - File Encoding : 65001 - - Date: 12/01/2026 20:31:43 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for ai_mip_click --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_click`; -CREATE TABLE `ai_mip_click` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `site_id` bigint NOT NULL COMMENT '关联站点ID(外键指向 ai_mip_site.id)', - `site_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '网站URL(冗余字段,便于查询优化)', - `click_time` datetime NOT NULL COMMENT '点击发生时间', - `user_ip` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户IP地址', - `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '浏览器/设备信息', - `referer_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '来源页面URL', - `device_type` enum('mobile','pc','tablet') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '设备类型', - `click_count` int NULL DEFAULT 1 COMMENT '本次点击事件的计数(一般为1,可用于批量插入)', - `is_valid` tinyint(1) NULL DEFAULT 1 COMMENT '是否有效点击(防刷)', - `task_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'RPA任务ID(可选)', - `operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作者(如自动系统)', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_site_id`(`site_id` ASC) USING BTREE, - INDEX `idx_click_time`(`click_time` ASC) USING BTREE, - INDEX `idx_site_url`(`site_url` ASC) USING BTREE, - INDEX `idx_click_time_site`(`click_time` ASC, `site_id` ASC) USING BTREE, - INDEX `idx_task_id`(`task_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'MIP页广告点击日志表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Records of ai_mip_click --- ---------------------------- -INSERT INTO `ai_mip_click` VALUES (1, 1, 'https://example.com', '2026-01-12 20:25:09', NULL, NULL, NULL, NULL, 1, 1, 'TASK20260112001', 'RPA_SYSTEM', '2026-01-12 20:25:09'); - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/db/ai_mip_interaction.sql b/db/ai_mip_interaction.sql deleted file mode 100644 index 85a9c76..0000000 --- a/db/ai_mip_interaction.sql +++ /dev/null @@ -1,75 +0,0 @@ -/* - Navicat Premium Dump SQL - - Source Server : mixue - Source Server Type : MySQL - Source Server Version : 90001 (9.0.1) - Source Host : localhost:3306 - Source Schema : ai_article - - Target Server Type : MySQL - Target Server Version : 90001 (9.0.1) - File Encoding : 65001 - - Date: 12/01/2026 20:31:30 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for ai_mip_interaction --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_interaction`; -CREATE TABLE `ai_mip_interaction` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `site_id` bigint NOT NULL COMMENT '关联站点ID', - `click_id` bigint NULL DEFAULT NULL COMMENT '关联点击记录ID', - `task_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'RPA任务ID', - `interaction_type` enum('reply','comment','message','form_submit','follow','like','share') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '互动类型', - `interaction_time` datetime NOT NULL COMMENT '互动发生时间', - `interaction_status` enum('pending','success','failed','skipped') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'pending' COMMENT '互动状态', - `reply_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '回复/评论的内容', - `reply_template_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用的回复模板ID', - `ad_element_xpath` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告元素的XPath定位', - `ad_element_selector` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告元素的CSS选择器', - `ad_text_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '广告的文本内容', - `execution_mode` enum('auto','manual','semi_auto') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'auto' COMMENT '执行方式', - `rpa_script` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用的RPA脚本名称', - `browser_type` enum('headless','headed','playwright','selenium') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '浏览器类型', - `anti_detection_method` json NULL COMMENT '万金油技术方案', - `proxy_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用的代理IP', - `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '使用的User-Agent', - `custom_headers` json NULL COMMENT '自定义HTTP头', - `fingerprint_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '浏览器指纹ID', - `response_received` tinyint(1) NULL DEFAULT 0 COMMENT '是否收到回复', - `response_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '对方回复的内容', - `response_time` datetime NULL DEFAULT NULL COMMENT '收到回复的时间', - `response_delay_seconds` int NULL DEFAULT NULL COMMENT '回复延迟(秒)', - `is_successful` tinyint(1) NULL DEFAULT 0 COMMENT '是否成功互动', - `error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '失败原因/错误信息', - `retry_count` int NULL DEFAULT 0 COMMENT '重试次数', - `conversion_flag` tinyint(1) NULL DEFAULT 0 COMMENT '是否产生转化', - `site_dimension` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '网址维度标签', - `campaign_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告活动ID', - `operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作者', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间', - `remark` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注信息', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_site_id`(`site_id` ASC) USING BTREE, - INDEX `idx_click_id`(`click_id` ASC) USING BTREE, - INDEX `idx_task_id`(`task_id` ASC) USING BTREE, - INDEX `idx_interaction_time`(`interaction_time` ASC) USING BTREE, - INDEX `idx_interaction_status`(`interaction_status` ASC) USING BTREE, - INDEX `idx_composite`(`site_id` ASC, `interaction_time` ASC, `interaction_status` ASC) USING BTREE, - INDEX `idx_response_received`(`response_received` ASC) USING BTREE, - INDEX `idx_conversion`(`conversion_flag` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'MIP页广告互动回复日志表' ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Records of ai_mip_interaction --- ---------------------------- -INSERT INTO `ai_mip_interaction` VALUES (1, 1, 1, 'TASK20260112001', 'reply', '2026-01-12 20:25:09', 'success', '您好,请问有什么可以帮助您的吗?', NULL, NULL, NULL, NULL, 'auto', NULL, NULL, NULL, NULL, NULL, NULL, NULL, 0, NULL, NULL, NULL, 1, NULL, 0, 0, NULL, NULL, NULL, '2026-01-12 20:25:09', '2026-01-12 20:25:09', NULL); - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/db/ai_mip_query_task.sql b/db/ai_mip_query_task.sql deleted file mode 100644 index 3af693b..0000000 --- a/db/ai_mip_query_task.sql +++ /dev/null @@ -1,60 +0,0 @@ -/* - MIP Query Task Table - 用于存储查询词任务,抓取需要自动点击的网址 - - Date: 2026-01-19 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for ai_mip_query_task --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_query_task`; -CREATE TABLE `ai_mip_query_task` ( - `id` int NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `query_word` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '查询词/关键词', - `query_type` enum('keyword','phrase','long_tail') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'keyword' COMMENT '查询类型:关键词/短语/长尾词', - `task_date` char(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '任务日期,格式:YYYYMMDD', - `threshold_max` int NOT NULL DEFAULT 100 COMMENT '最大抓取数量阈值', - `current_count` int NOT NULL DEFAULT 0 COMMENT '当前已抓取数量', - `status` enum('ready','doing','failed','finished','closed') CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT 'ready' COMMENT '任务状态:准备中/执行中/失败/完成/已关闭', - `priority` tinyint NOT NULL DEFAULT 5 COMMENT '优先级(1-10,数字越小优先级越高)', - `category` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '分类标签(如:医疗、教育、法律等)', - `source_platform` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'baidu' COMMENT '来源平台:baidu/sogou/360等', - `crawl_url_count` int NOT NULL DEFAULT 0 COMMENT '已爬取URL数量', - `valid_url_count` int NOT NULL DEFAULT 0 COMMENT '有效URL数量(带广告)', - `error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '错误信息', - `started_at` timestamp NULL DEFAULT NULL COMMENT '开始执行时间', - `finished_at` timestamp NULL DEFAULT NULL COMMENT '完成时间', - `closed_at` timestamp NULL DEFAULT NULL COMMENT '达到阈值关闭时间', - `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `created_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'system' COMMENT '创建人', - `remark` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注信息', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `uniq_query_date`(`query_word`(191) ASC, `task_date` ASC) USING BTREE COMMENT '同一查询词每天只有一个任务', - INDEX `idx_date_status`(`task_date` ASC, `status` ASC) USING BTREE COMMENT '按日期和状态查询', - INDEX `idx_status_priority`(`status` ASC, `priority` ASC) USING BTREE COMMENT '按状态和优先级查询', - INDEX `idx_category`(`category` ASC) USING BTREE COMMENT '按分类查询', - INDEX `idx_threshold`(`threshold_max` ASC, `current_count` ASC) USING BTREE COMMENT '阈值监控', - INDEX `idx_closed`(`closed_at` ASC) USING BTREE COMMENT '关闭时间索引' -) ENGINE = InnoDB - AUTO_INCREMENT = 1 - CHARACTER SET = utf8mb4 - COLLATE = utf8mb4_general_ci - COMMENT = 'MIP查询任务表 - 用于存储查询词抓取网址任务' - ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- 示例数据 --- ---------------------------- -INSERT INTO `ai_mip_query_task` - (`query_word`, `query_type`, `task_date`, `threshold_max`, `priority`, `category`, `source_platform`, `remark`) -VALUES - ('糖尿病治疗', 'keyword', '20260119', 50, 3, '医疗', 'baidu', '医疗类关键词测试'), - ('在线教育平台', 'phrase', '20260119', 30, 5, '教育', 'baidu', '教育类短语测试'), - ('法律咨询免费在线', 'long_tail', '20260119', 20, 7, '法律', 'baidu', '法律类长尾词测试'); - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/db/ai_mip_site.sql b/db/ai_mip_site.sql deleted file mode 100644 index c632d3f..0000000 --- a/db/ai_mip_site.sql +++ /dev/null @@ -1,55 +0,0 @@ -/* - Navicat Premium Dump SQL - - Source Server : mixue - Source Server Type : MySQL - Source Server Version : 90001 (9.0.1) - Source Host : localhost:3306 - Source Schema : ai_article - - Target Server Type : MySQL - Target Server Version : 90001 (9.0.1) - File Encoding : 65001 - - Date: 12/01/2026 20:31:23 -*/ - -SET NAMES utf8mb4; -SET FOREIGN_KEY_CHECKS = 0; - --- ---------------------------- --- Table structure for ai_mip_site --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_site`; -CREATE TABLE `ai_mip_site` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `site_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '网站URL,唯一', - `site_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '网站名称(可选)', - `status` enum('active','inactive','pending') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'active' COMMENT '状态:激活/停用/待审核', - `frequency` int NULL DEFAULT 1 COMMENT '频次(如每小时发几次)', - `time_start` time NULL DEFAULT '00:00:00' COMMENT '开始时间(HH:MM:SS)', - `time_end` time NULL DEFAULT '23:59:59' COMMENT '结束时间(HH:MM:SS)', - `interval_minutes` int NULL DEFAULT 60 COMMENT '执行间隔(分钟)', - `ad_feature` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告特征描述(JSON格式,如:{\"color\":\"red\", \"position\":\"top\"})', - `click_count` bigint NULL DEFAULT 0 COMMENT '累计点击次数', - `reply_count` bigint NULL DEFAULT 0 COMMENT '累计回复次数', - `site_dimension` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '网址维度标签(如:教育、医疗等)', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `created_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人', - `updated_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '更新人', - `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注信息', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `site_url`(`site_url` ASC) USING BTREE, - UNIQUE INDEX `idx_site_url`(`site_url`(191) ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'MIP页广告网址管理表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Records of ai_mip_site --- ---------------------------- -INSERT INTO `ai_mip_site` VALUES (1, 'https://example.com', '示例网站1', 'active', 1, '00:00:00', '23:59:59', 60, NULL, 0, 0, '教育', '2026-01-12 20:24:18', '2026-01-12 20:24:18', 'admin', NULL, NULL); -INSERT INTO `ai_mip_site` VALUES (2, 'https://test.com', '测试网站2', 'active', 1, '00:00:00', '23:59:59', 60, NULL, 0, 0, '医疗', '2026-01-12 20:24:18', '2026-01-12 20:24:18', 'admin', NULL, NULL); - -SET FOREIGN_KEY_CHECKS = 1; diff --git a/db/alter_add_query_word.sql b/db/alter_add_query_word.sql deleted file mode 100644 index 59b757c..0000000 --- a/db/alter_add_query_word.sql +++ /dev/null @@ -1,14 +0,0 @@ -/* - 为ai_mip_site表添加query_word字段 - 用于记录该URL是从哪个查询词抓取的 - - Date: 2026-01-19 -*/ - -ALTER TABLE `ai_mip_site` -ADD COLUMN `query_word` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '来源查询词(从哪个关键词抓取)' -AFTER `site_dimension`; - --- 添加索引,方便按查询词查询 -ALTER TABLE `ai_mip_site` -ADD INDEX `idx_query_word`(`query_word`(191) ASC) USING BTREE COMMENT '按查询词查询'; diff --git a/db/init_databases.py b/db/init_databases.py deleted file mode 100644 index 2b25574..0000000 --- a/db/init_databases.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python3 -""" -SQLite数据库初始化脚本 -自动创建开发环境(ai_mip_dev.db)和生产环境(ai_mip_prod.db)数据库 -""" - -import sqlite3 -import os -from pathlib import Path - -# 数据库文件路径 -DB_DIR = Path(__file__).parent -DEV_DB = DB_DIR / "ai_mip_dev.db" -PROD_DB = DB_DIR / "ai_mip_prod.db" - -# SQL脚本路径 -INIT_SQL = DB_DIR / "init_sqlite.sql" -SEED_DEV_SQL = DB_DIR / "seed_dev.sql" - - -def execute_sql_file(conn, sql_file): - """执行SQL文件""" - with open(sql_file, 'r', encoding='utf-8') as f: - sql_script = f.read() - - # SQLite需要逐条执行语句 - conn.executescript(sql_script) - conn.commit() - print(f"✓ 已执行: {sql_file.name}") - - -def init_database(db_path, with_seed=False): - """初始化数据库""" - # 如果数据库已存在,询问是否覆盖 - if db_path.exists(): - response = input(f"\n数据库 {db_path.name} 已存在,是否覆盖? (y/n): ").strip().lower() - if response != 'y': - print(f"跳过 {db_path.name}") - return - os.remove(db_path) - print(f"已删除旧数据库: {db_path.name}") - - print(f"\n创建数据库: {db_path.name}") - - # 连接数据库(自动创建) - conn = sqlite3.connect(db_path) - - try: - # 执行初始化SQL - execute_sql_file(conn, INIT_SQL) - - # 如果需要,执行种子数据 - if with_seed: - execute_sql_file(conn, SEED_DEV_SQL) - - print(f"✓ 数据库 {db_path.name} 创建成功") - - # 验证表是否创建成功 - cursor = conn.cursor() - cursor.execute("SELECT name FROM sqlite_master WHERE type='table'") - tables = cursor.fetchall() - print(f" 创建的表: {', '.join([t[0] for t in tables])}") - - except Exception as e: - print(f"✗ 创建数据库失败: {str(e)}") - raise - finally: - conn.close() - - -def main(): - print("=" * 60) - print("SQLite数据库初始化工具") - print("=" * 60) - - # 检查SQL文件是否存在 - if not INIT_SQL.exists(): - print(f"错误: 找不到初始化脚本 {INIT_SQL}") - return - - # 初始化开发数据库(带测试数据) - print("\n[1] 初始化开发环境数据库") - init_database(DEV_DB, with_seed=True) - - # 初始化生产数据库(不带测试数据) - print("\n[2] 初始化生产环境数据库") - init_database(PROD_DB, with_seed=False) - - print("\n" + "=" * 60) - print("数据库初始化完成") - print("=" * 60) - print(f"开发数据库: {DEV_DB}") - print(f"生产数据库: {PROD_DB}") - print("\n使用方法:") - print(" 开发环境: 在 .env.development 中设置 DATABASE_PATH=db/ai_mip_dev.db") - print(" 生产环境: 在 .env.production 中设置 DATABASE_PATH=db/ai_mip_prod.db") - - -if __name__ == "__main__": - main() diff --git a/db/init_sqlite.sql b/db/init_sqlite.sql deleted file mode 100644 index fe44978..0000000 --- a/db/init_sqlite.sql +++ /dev/null @@ -1,125 +0,0 @@ --- SQLite数据库初始化脚本 --- 适用于开发环境(ai_mip_dev.db)和生产环境(ai_mip_prod.db) - --- ---------------------------- --- Table structure for ai_mip_site --- ---------------------------- -DROP TABLE IF EXISTS ai_mip_site; -CREATE TABLE ai_mip_site ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - site_url TEXT NOT NULL UNIQUE, - site_name TEXT, - status TEXT CHECK(status IN ('active', 'inactive', 'pending')) DEFAULT 'active', - frequency INTEGER DEFAULT 1, - time_start TEXT DEFAULT '00:00:00', - time_end TEXT DEFAULT '23:59:59', - interval_minutes INTEGER DEFAULT 60, - ad_feature TEXT, - click_count INTEGER DEFAULT 0, - reply_count INTEGER DEFAULT 0, - site_dimension TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - created_by TEXT, - updated_by TEXT, - remark TEXT -); - --- 创建索引 -CREATE UNIQUE INDEX idx_site_url ON ai_mip_site(site_url); -CREATE INDEX idx_status ON ai_mip_site(status); -CREATE INDEX idx_created_at ON ai_mip_site(created_at); - --- ---------------------------- --- Table structure for ai_mip_click --- ---------------------------- -DROP TABLE IF EXISTS ai_mip_click; -CREATE TABLE ai_mip_click ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - site_id INTEGER NOT NULL, - site_url TEXT NOT NULL, - click_time DATETIME NOT NULL, - user_ip TEXT, - user_agent TEXT, - referer_url TEXT, - device_type TEXT CHECK(device_type IN ('mobile', 'pc', 'tablet')), - click_count INTEGER DEFAULT 1, - is_valid INTEGER DEFAULT 1, - task_id TEXT, - operator TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - FOREIGN KEY (site_id) REFERENCES ai_mip_site(id) -); - --- 创建索引 -CREATE INDEX idx_site_id ON ai_mip_click(site_id); -CREATE INDEX idx_click_time ON ai_mip_click(click_time); -CREATE INDEX idx_site_url_click ON ai_mip_click(site_url); -CREATE INDEX idx_click_time_site ON ai_mip_click(click_time, site_id); -CREATE INDEX idx_task_id ON ai_mip_click(task_id); - --- ---------------------------- --- Table structure for ai_mip_interaction --- ---------------------------- -DROP TABLE IF EXISTS ai_mip_interaction; -CREATE TABLE ai_mip_interaction ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - site_id INTEGER NOT NULL, - click_id INTEGER, - task_id TEXT, - interaction_type TEXT CHECK(interaction_type IN ('reply', 'comment', 'message', 'form_submit', 'follow', 'like', 'share')) NOT NULL, - interaction_time DATETIME NOT NULL, - interaction_status TEXT CHECK(interaction_status IN ('pending', 'success', 'failed', 'skipped')) DEFAULT 'pending', - reply_content TEXT, - reply_template_id TEXT, - ad_element_xpath TEXT, - ad_element_selector TEXT, - ad_text_content TEXT, - execution_mode TEXT CHECK(execution_mode IN ('auto', 'manual', 'semi_auto')) DEFAULT 'auto', - rpa_script TEXT, - browser_type TEXT CHECK(browser_type IN ('headless', 'headed', 'playwright', 'selenium')), - anti_detection_method TEXT, - proxy_ip TEXT, - user_agent TEXT, - custom_headers TEXT, - fingerprint_id TEXT, - response_received INTEGER DEFAULT 0, - response_content TEXT, - response_time DATETIME, - response_delay_seconds INTEGER, - is_successful INTEGER DEFAULT 0, - error_message TEXT, - retry_count INTEGER DEFAULT 0, - conversion_flag INTEGER DEFAULT 0, - site_dimension TEXT, - campaign_id TEXT, - operator TEXT, - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, - remark TEXT, - FOREIGN KEY (site_id) REFERENCES ai_mip_site(id), - FOREIGN KEY (click_id) REFERENCES ai_mip_click(id) -); - --- 创建索引 -CREATE INDEX idx_site_id_interaction ON ai_mip_interaction(site_id); -CREATE INDEX idx_click_id_interaction ON ai_mip_interaction(click_id); -CREATE INDEX idx_task_id_interaction ON ai_mip_interaction(task_id); -CREATE INDEX idx_interaction_time ON ai_mip_interaction(interaction_time); -CREATE INDEX idx_interaction_status ON ai_mip_interaction(interaction_status); -CREATE INDEX idx_composite ON ai_mip_interaction(site_id, interaction_time, interaction_status); -CREATE INDEX idx_response_received ON ai_mip_interaction(response_received); -CREATE INDEX idx_conversion ON ai_mip_interaction(conversion_flag); - --- 创建触发器:自动更新 updated_at -CREATE TRIGGER update_ai_mip_site_timestamp -AFTER UPDATE ON ai_mip_site -BEGIN - UPDATE ai_mip_site SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; -END; - -CREATE TRIGGER update_ai_mip_interaction_timestamp -AFTER UPDATE ON ai_mip_interaction -BEGIN - UPDATE ai_mip_interaction SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id; -END; diff --git a/db/mip_table.txt b/db/mip_table.txt deleted file mode 100644 index c6c1c35..0000000 --- a/db/mip_table.txt +++ /dev/null @@ -1,145 +0,0 @@ - --- ---------------------------- --- Table structure for ai_mip_click --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_click`; -CREATE TABLE `ai_mip_click` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `site_id` bigint NOT NULL COMMENT '关联站点ID(外键指向 ai_mip_site.id)', - `site_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '网站URL(冗余字段,便于查询优化)', - `click_time` datetime NOT NULL COMMENT '点击发生时间', - `user_ip` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '用户IP地址', - `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '浏览器/设备信息', - `referer_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '来源页面URL', - `device_type` enum('mobile','pc','tablet') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '设备类型', - `click_count` int NULL DEFAULT 1 COMMENT '本次点击事件的计数(一般为1,可用于批量插入)', - `is_valid` tinyint(1) NULL DEFAULT 1 COMMENT '是否有效点击(防刷)', - `task_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'RPA任务ID(可选)', - `operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作者(如自动系统)', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_site_id`(`site_id` ASC) USING BTREE, - INDEX `idx_click_time`(`click_time` ASC) USING BTREE, - INDEX `idx_site_url`(`site_url` ASC) USING BTREE, - INDEX `idx_click_time_site`(`click_time` ASC, `site_id` ASC) USING BTREE, - INDEX `idx_task_id`(`task_id` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'MIP页广告点击日志表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Table structure for ai_mip_interaction --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_interaction`; -CREATE TABLE `ai_mip_interaction` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `site_id` bigint NOT NULL COMMENT '关联站点ID', - `click_id` bigint NULL DEFAULT NULL COMMENT '关联点击记录ID', - `task_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT 'RPA任务ID', - `interaction_type` enum('reply','comment','message','form_submit','follow','like','share') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '互动类型', - `interaction_time` datetime NOT NULL COMMENT '互动发生时间', - `interaction_status` enum('pending','success','failed','skipped') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'pending' COMMENT '互动状态', - `reply_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '回复/评论的内容', - `reply_template_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用的回复模板ID', - `ad_element_xpath` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告元素的XPath定位', - `ad_element_selector` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告元素的CSS选择器', - `ad_text_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '广告的文本内容', - `execution_mode` enum('auto','manual','semi_auto') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'auto' COMMENT '执行方式', - `rpa_script` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用的RPA脚本名称', - `browser_type` enum('headless','headed','playwright','selenium') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '浏览器类型', - `anti_detection_method` json NULL COMMENT '万金油技术方案', - `proxy_ip` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '使用的代理IP', - `user_agent` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '使用的User-Agent', - `custom_headers` json NULL COMMENT '自定义HTTP头', - `fingerprint_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '浏览器指纹ID', - `response_received` tinyint(1) NULL DEFAULT 0 COMMENT '是否收到回复', - `response_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '对方回复的内容', - `response_time` datetime NULL DEFAULT NULL COMMENT '收到回复的时间', - `response_delay_seconds` int NULL DEFAULT NULL COMMENT '回复延迟(秒)', - `is_successful` tinyint(1) NULL DEFAULT 0 COMMENT '是否成功互动', - `error_message` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '失败原因/错误信息', - `retry_count` int NULL DEFAULT 0 COMMENT '重试次数', - `conversion_flag` tinyint(1) NULL DEFAULT 0 COMMENT '是否产生转化', - `site_dimension` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '网址维度标签', - `campaign_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告活动ID', - `operator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '操作者', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间', - `remark` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注信息', - PRIMARY KEY (`id`) USING BTREE, - INDEX `idx_site_id`(`site_id` ASC) USING BTREE, - INDEX `idx_click_id`(`click_id` ASC) USING BTREE, - INDEX `idx_task_id`(`task_id` ASC) USING BTREE, - INDEX `idx_interaction_time`(`interaction_time` ASC) USING BTREE, - INDEX `idx_interaction_status`(`interaction_status` ASC) USING BTREE, - INDEX `idx_composite`(`site_id` ASC, `interaction_time` ASC, `interaction_status` ASC) USING BTREE, - INDEX `idx_response_received`(`response_received` ASC) USING BTREE, - INDEX `idx_conversion`(`conversion_flag` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'MIP页广告互动回复日志表' ROW_FORMAT = DYNAMIC; - --- ---------------------------- --- Table structure for ai_mip_site --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_site`; -CREATE TABLE `ai_mip_site` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `site_url` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '网站URL,唯一', - `site_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '网站名称(可选)', - `status` enum('active','inactive','pending') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'active' COMMENT '状态:激活/停用/待审核', - `frequency` int NULL DEFAULT 1 COMMENT '频次(如每小时发几次)', - `time_start` time NULL DEFAULT '00:00:00' COMMENT '开始时间(HH:MM:SS)', - `time_end` time NULL DEFAULT '23:59:59' COMMENT '结束时间(HH:MM:SS)', - `interval_minutes` int NULL DEFAULT 60 COMMENT '执行间隔(分钟)', - `ad_feature` varchar(1024) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '广告特征描述(JSON格式,如:{\"color\":\"red\", \"position\":\"top\"})', - `click_count` bigint NULL DEFAULT 0 COMMENT '累计点击次数', - `reply_count` bigint NULL DEFAULT 0 COMMENT '累计回复次数', - `site_dimension` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '网址维度标签(如:教育、医疗等)', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', - `created_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '创建人', - `updated_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '更新人', - `remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '备注信息', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `site_url`(`site_url` ASC) USING BTREE, - UNIQUE INDEX `idx_site_url`(`site_url`(191) ASC) USING BTREE, - INDEX `idx_status`(`status` ASC) USING BTREE, - INDEX `idx_created_at`(`created_at` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 3 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'MIP页广告网址管理表' ROW_FORMAT = Dynamic; - --- ---------------------------- --- Table structure for ai_mip_task_log --- ---------------------------- -DROP TABLE IF EXISTS `ai_mip_task_log`; -CREATE TABLE `ai_mip_task_log` ( - `id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键ID', - `task_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'RPA任务唯一ID', - `site_id` bigint NOT NULL COMMENT '关联站点ID', - `step_1_visit_time` datetime NULL DEFAULT NULL COMMENT '步骤1:访问网址时间', - `step_1_status` enum('success','failed','skipped') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '步骤1状态', - `step_2_antibot_time` datetime NULL DEFAULT NULL COMMENT '步骤2:万金油技术方案执行时间', - `step_2_status` enum('success','failed','skipped') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '步骤2状态', - `step_3_ad_detection_time` datetime NULL DEFAULT NULL COMMENT '步骤3:广告检测时间', - `step_3_has_ad` tinyint(1) NULL DEFAULT NULL COMMENT '是否检测到广告', - `step_3_ad_count` int NULL DEFAULT 0 COMMENT '检测到的广告数量', - `step_4_click_time` datetime NULL DEFAULT NULL COMMENT '步骤4:点击广告时间', - `step_4_status` enum('success','failed','skipped') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '步骤4状态', - `step_5_reply_time` datetime NULL DEFAULT NULL COMMENT '步骤5:获取回复时间', - `step_5_status` enum('success','failed','skipped') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '步骤5状态', - `task_start_time` datetime NOT NULL COMMENT '任务开始时间', - `task_end_time` datetime NULL DEFAULT NULL COMMENT '任务结束时间', - `task_duration_seconds` int NULL DEFAULT NULL COMMENT '任务执行时长(秒)', - `task_status` enum('running','completed','failed','timeout') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'running' COMMENT '任务整体状态', - `total_clicks` int NULL DEFAULT 0 COMMENT '本次任务总点击次数', - `total_interactions` int NULL DEFAULT 0 COMMENT '本次任务总互动次数', - `successful_interactions` int NULL DEFAULT 0 COMMENT '成功互动次数', - `failed_interactions` int NULL DEFAULT 0 COMMENT '失败互动次数', - `execution_mode` enum('auto','manual','scheduled') CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT 'auto' COMMENT '执行模式', - `triggered_by` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '触发者(定时任务/手动触发/队列)', - `error_log` text CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL COMMENT '错误日志', - `created_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录创建时间', - `updated_at` timestamp NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '记录更新时间', - PRIMARY KEY (`id`) USING BTREE, - UNIQUE INDEX `task_id`(`task_id` ASC) USING BTREE, - UNIQUE INDEX `uk_task_id`(`task_id` ASC) USING BTREE, - INDEX `idx_site_id`(`site_id` ASC) USING BTREE, - INDEX `idx_task_status`(`task_status` ASC) USING BTREE, - INDEX `idx_start_time`(`task_start_time` ASC) USING BTREE -) ENGINE = InnoDB AUTO_INCREMENT = 2 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'RPA任务执行日志表' ROW_FORMAT = DYNAMIC; diff --git a/db/seed_dev.sql b/db/seed_dev.sql deleted file mode 100644 index d4233c2..0000000 --- a/db/seed_dev.sql +++ /dev/null @@ -1,20 +0,0 @@ --- 开发环境测试数据 --- 用于 ai_mip_dev.db - --- 插入测试站点 -INSERT INTO ai_mip_site (site_url, site_name, status, frequency, time_start, time_end, interval_minutes, click_count, reply_count, site_dimension, created_by) VALUES -('https://health.baidu.com/m/detail/ar_2366617956693492811', '百度健康测试页面', 'active', 3, '09:00:00', '21:00:00', 45, 0, 0, '医疗健康', 'admin'), -('https://example.com/test', '测试网站1', 'active', 2, '10:00:00', '20:00:00', 60, 0, 0, '教育', 'admin'), -('https://demo.com/page', '演示网站', 'inactive', 1, '00:00:00', '23:59:59', 120, 0, 0, '商业', 'admin'); - --- 插入测试点击记录 -INSERT INTO ai_mip_click (site_id, site_url, click_time, user_ip, device_type, task_id, operator) VALUES -(1, 'https://health.baidu.com/m/detail/ar_2366617956693492811', datetime('now'), '192.168.1.100', 'pc', 'TASK_DEV_001', 'RPA_SYSTEM'), -(1, 'https://health.baidu.com/m/detail/ar_2366617956693492811', datetime('now', '-1 hour'), '192.168.1.101', 'mobile', 'TASK_DEV_002', 'RPA_SYSTEM'), -(2, 'https://example.com/test', datetime('now', '-2 hours'), '192.168.1.102', 'pc', 'TASK_DEV_003', 'RPA_SYSTEM'); - --- 插入测试互动记录 -INSERT INTO ai_mip_interaction (site_id, click_id, task_id, interaction_type, interaction_time, interaction_status, reply_content, execution_mode, browser_type, is_successful, operator) VALUES -(1, 1, 'TASK_DEV_001', 'reply', datetime('now'), 'success', '测试回复内容', 'auto', 'playwright', 1, 'RPA_SYSTEM'), -(1, 2, 'TASK_DEV_002', 'comment', datetime('now', '-1 hour'), 'success', '测试评论内容', 'auto', 'playwright', 1, 'RPA_SYSTEM'), -(2, 3, 'TASK_DEV_003', 'reply', datetime('now', '-2 hours'), 'pending', NULL, 'auto', 'playwright', 0, 'RPA_SYSTEM'); diff --git a/db_manager.py b/db_manager.py index 590ada9..1c8adf9 100644 --- a/db_manager.py +++ b/db_manager.py @@ -48,10 +48,32 @@ class DatabaseManager: return conn def _dict_from_row(self, row) -> Dict: - """将数据库行转换为字典""" + """将数据库行转换为字典,处理特殊类型""" if row is None: return None - return dict(row) if isinstance(row, dict) else row + + result = dict(row) if isinstance(row, dict) else row + + # 处理特殊类型,确保JSON可序列化 + if isinstance(result, dict): + from datetime import datetime, date, timedelta + from decimal import Decimal + + for key, value in result.items(): + if isinstance(value, datetime): + result[key] = value.strftime('%Y-%m-%d %H:%M:%S') + elif isinstance(value, date): + result[key] = value.strftime('%Y-%m-%d') + elif isinstance(value, timedelta): + # 将timedelta转换为字符串格式 HH:MM:SS + total_seconds = int(value.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + result[key] = f'{hours:02d}:{minutes:02d}:{seconds:02d}' + elif isinstance(value, Decimal): + result[key] = float(value) + + return result def _get_placeholder(self) -> str: """获取SQL占位符,MySQL使用 %s""" @@ -816,3 +838,801 @@ class QueryTaskManager(DatabaseManager): except Exception as e: logger.error(f"获取任务统计失败: {str(e)}") return {} + + +class EnhancedSiteManager(SiteManager): + """增强的站点管理器,支持分页、排序、筛选""" + + def get_sites_paginated( + self, + page: int = 1, + page_size: int = 20, + status: str = None, + keyword: str = None, + sort_by: str = 'created_at', + sort_order: str = 'desc' + ) -> tuple: + """ + 分页获取站点列表 + + Returns: + (站点列表, 总数) + """ + try: + conn = self.get_connection() + ph = self._get_placeholder() + + # 构建WHERE条件 + conditions = [] + params = [] + + if status: + conditions.append(f"status = {ph}") + params.append(status) + + if keyword: + conditions.append(f"(site_url LIKE {ph} OR site_name LIKE {ph})") + params.extend([f'%{keyword}%', f'%{keyword}%']) + + where_clause = ' AND '.join(conditions) if conditions else '1=1' + + # 允许的排序字段 + allowed_sort_fields = ['created_at', 'click_count', 'reply_count', 'site_url', 'status'] + if sort_by not in allowed_sort_fields: + sort_by = 'created_at' + + sort_order = 'DESC' if sort_order.upper() == 'DESC' else 'ASC' + + # 查询总数 + count_sql = f"SELECT COUNT(*) as total FROM ai_mip_site WHERE {where_clause}" + cursor = self._execute_query(conn, count_sql, tuple(params) if params else None) + total = cursor.fetchone()['total'] + + # 查询数据 + offset = (page - 1) * page_size + data_sql = f""" + SELECT * FROM ai_mip_site + WHERE {where_clause} + ORDER BY {sort_by} {sort_order} + LIMIT {ph} OFFSET {ph} + """ + params.extend([page_size, offset]) + + cursor = self._execute_query(conn, data_sql, tuple(params)) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows], total + + except Exception as e: + logger.error(f"分页查询站点失败: {str(e)}") + return [], 0 + + def delete_sites_batch(self, site_ids: List[int]) -> int: + """ + 批量删除站点 + + Returns: + 成功删除的数量 + """ + if not site_ids: + return 0 + + try: + conn = self.get_connection() + placeholders = ','.join(['%s'] * len(site_ids)) + sql = f"DELETE FROM ai_mip_site WHERE id IN ({placeholders})" + + cursor = conn.cursor() + cursor.execute(sql, tuple(site_ids)) + deleted = cursor.rowcount + conn.commit() + conn.close() + + logger.info(f"批量删除站点: {deleted}/{len(site_ids)}") + return deleted + + except Exception as e: + logger.error(f"批量删除站点失败: {str(e)}") + return 0 + + def update_sites_status_batch(self, site_ids: List[int], status: str) -> int: + """ + 批量更新站点状态 + + Returns: + 成功更新的数量 + """ + if not site_ids: + return 0 + + try: + conn = self.get_connection() + placeholders = ','.join(['%s'] * len(site_ids)) + sql = f"UPDATE ai_mip_site SET status = %s WHERE id IN ({placeholders})" + + cursor = conn.cursor() + cursor.execute(sql, (status, *site_ids)) + updated = cursor.rowcount + conn.commit() + conn.close() + + logger.info(f"批量更新站点状态为{status}: {updated}/{len(site_ids)}") + return updated + + except Exception as e: + logger.error(f"批量更新站点状态失败: {str(e)}") + return 0 + + def export_sites(self, status: str = None, keyword: str = None) -> List[Dict]: + """导出站点数据""" + try: + conn = self.get_connection() + ph = self._get_placeholder() + + conditions = [] + params = [] + + if status: + conditions.append(f"status = {ph}") + params.append(status) + + if keyword: + conditions.append(f"(site_url LIKE {ph} OR site_name LIKE {ph})") + params.extend([f'%{keyword}%', f'%{keyword}%']) + + where_clause = ' AND '.join(conditions) if conditions else '1=1' + + sql = f""" + SELECT id, site_url, site_name, status, click_count, reply_count, + frequency, time_start, time_end, site_dimension, query_word, + created_at + FROM ai_mip_site + WHERE {where_clause} + ORDER BY created_at DESC + """ + + cursor = self._execute_query(conn, sql, tuple(params) if params else None) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows] + + except Exception as e: + logger.error(f"导出站点数据失败: {str(e)}") + return [] + + +class EnhancedClickManager(ClickManager): + """增强的点击记录管理器""" + + def get_clicks_paginated( + self, + page: int = 1, + page_size: int = 20, + site_id: int = None, + start_date: str = None, + end_date: str = None, + sort_by: str = 'click_time', + sort_order: str = 'desc' + ) -> tuple: + """ + 分页获取点击记录 + + Returns: + (点击记录列表, 总数) + """ + try: + conn = self.get_connection() + ph = self._get_placeholder() + + conditions = [] + params = [] + + if site_id: + conditions.append(f"c.site_id = {ph}") + params.append(site_id) + + if start_date: + conditions.append(f"c.click_time >= {ph}") + params.append(f"{start_date} 00:00:00") + + if end_date: + conditions.append(f"c.click_time <= {ph}") + params.append(f"{end_date} 23:59:59") + + where_clause = ' AND '.join(conditions) if conditions else '1=1' + + allowed_sort_fields = ['click_time', 'site_id', 'device_type'] + if sort_by not in allowed_sort_fields: + sort_by = 'click_time' + + sort_order = 'DESC' if sort_order.upper() == 'DESC' else 'ASC' + + # 查询总数 + count_sql = f"SELECT COUNT(*) as total FROM ai_mip_click c WHERE {where_clause}" + cursor = self._execute_query(conn, count_sql, tuple(params) if params else None) + total = cursor.fetchone()['total'] + + # 查询数据 + offset = (page - 1) * page_size + data_sql = f""" + SELECT c.*, s.site_name + FROM ai_mip_click c + LEFT JOIN ai_mip_site s ON c.site_id = s.id + WHERE {where_clause} + ORDER BY c.{sort_by} {sort_order} + LIMIT {ph} OFFSET {ph} + """ + params.extend([page_size, offset]) + + cursor = self._execute_query(conn, data_sql, tuple(params)) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows], total + + except Exception as e: + logger.error(f"分页查询点击记录失败: {str(e)}") + return [], 0 + + def export_clicks( + self, + site_id: int = None, + start_date: str = None, + end_date: str = None + ) -> List[Dict]: + """导出点击记录""" + try: + conn = self.get_connection() + ph = self._get_placeholder() + + conditions = [] + params = [] + + if site_id: + conditions.append(f"c.site_id = {ph}") + params.append(site_id) + + if start_date: + conditions.append(f"c.click_time >= {ph}") + params.append(f"{start_date} 00:00:00") + + if end_date: + conditions.append(f"c.click_time <= {ph}") + params.append(f"{end_date} 23:59:59") + + where_clause = ' AND '.join(conditions) if conditions else '1=1' + + sql = f""" + SELECT c.id, c.site_id, s.site_name, c.site_url, c.click_time, + c.user_ip, c.device_type, c.task_id + FROM ai_mip_click c + LEFT JOIN ai_mip_site s ON c.site_id = s.id + WHERE {where_clause} + ORDER BY c.click_time DESC + """ + + cursor = self._execute_query(conn, sql, tuple(params) if params else None) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows] + + except Exception as e: + logger.error(f"导出点击记录失败: {str(e)}") + return [] + + +class EnhancedInteractionManager(InteractionManager): + """增强的互动记录管理器""" + + def get_interactions_paginated( + self, + page: int = 1, + page_size: int = 20, + site_id: int = None, + start_date: str = None, + end_date: str = None, + status: str = None, + sort_by: str = 'interaction_time', + sort_order: str = 'desc' + ) -> tuple: + """ + 分页获取互动记录 + + Returns: + (互动记录列表, 总数) + """ + try: + conn = self.get_connection() + ph = self._get_placeholder() + + conditions = [] + params = [] + + if site_id: + conditions.append(f"i.site_id = {ph}") + params.append(site_id) + + if start_date: + conditions.append(f"i.interaction_time >= {ph}") + params.append(f"{start_date} 00:00:00") + + if end_date: + conditions.append(f"i.interaction_time <= {ph}") + params.append(f"{end_date} 23:59:59") + + if status: + conditions.append(f"i.interaction_status = {ph}") + params.append(status) + + where_clause = ' AND '.join(conditions) if conditions else '1=1' + + allowed_sort_fields = ['interaction_time', 'site_id', 'interaction_status'] + if sort_by not in allowed_sort_fields: + sort_by = 'interaction_time' + + sort_order = 'DESC' if sort_order.upper() == 'DESC' else 'ASC' + + # 查询总数 + count_sql = f"SELECT COUNT(*) as total FROM ai_mip_interaction i WHERE {where_clause}" + cursor = self._execute_query(conn, count_sql, tuple(params) if params else None) + total = cursor.fetchone()['total'] + + # 查询数据 + offset = (page - 1) * page_size + data_sql = f""" + SELECT i.*, s.site_name, s.site_url as site_url_ref + FROM ai_mip_interaction i + LEFT JOIN ai_mip_site s ON i.site_id = s.id + WHERE {where_clause} + ORDER BY i.{sort_by} {sort_order} + LIMIT {ph} OFFSET {ph} + """ + params.extend([page_size, offset]) + + cursor = self._execute_query(conn, data_sql, tuple(params)) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows], total + + except Exception as e: + logger.error(f"分页查询互动记录失败: {str(e)}") + return [], 0 + + def export_interactions( + self, + site_id: int = None, + start_date: str = None, + end_date: str = None + ) -> List[Dict]: + """导出互动记录""" + try: + conn = self.get_connection() + ph = self._get_placeholder() + + conditions = [] + params = [] + + if site_id: + conditions.append(f"i.site_id = {ph}") + params.append(site_id) + + if start_date: + conditions.append(f"i.interaction_time >= {ph}") + params.append(f"{start_date} 00:00:00") + + if end_date: + conditions.append(f"i.interaction_time <= {ph}") + params.append(f"{end_date} 23:59:59") + + where_clause = ' AND '.join(conditions) if conditions else '1=1' + + sql = f""" + SELECT i.id, i.site_id, s.site_name, s.site_url, i.interaction_time, + i.interaction_type, i.interaction_status, i.reply_content, + i.response_received, i.response_content, i.proxy_ip + FROM ai_mip_interaction i + LEFT JOIN ai_mip_site s ON i.site_id = s.id + WHERE {where_clause} + ORDER BY i.interaction_time DESC + """ + + cursor = self._execute_query(conn, sql, tuple(params) if params else None) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows] + + except Exception as e: + logger.error(f"导出互动记录失败: {str(e)}") + return [] + + +class EnhancedStatisticsManager(StatisticsManager): + """增强的统计管理器,支持图表数据""" + + def get_click_trend(self, days: int = 7) -> Dict: + """ + 获取点击趋势数据 + + Args: + days: 天数 + + Returns: + {'dates': [...], 'clicks': [...], 'successes': [...]} + """ + try: + conn = self.get_connection() + ph = self._get_placeholder() + + # 点击趋势 + click_sql = f""" + SELECT DATE(click_time) as date, COUNT(*) as count + FROM ai_mip_click + WHERE click_time >= DATE_SUB(CURDATE(), INTERVAL {ph} DAY) + GROUP BY DATE(click_time) + ORDER BY date + """ + cursor = self._execute_query(conn, click_sql, (days,)) + click_rows = cursor.fetchall() + + # 成功次数趋势(is_successful=1) + success_sql = f""" + SELECT DATE(interaction_time) as date, COUNT(*) as count + FROM ai_mip_interaction + WHERE interaction_time >= DATE_SUB(CURDATE(), INTERVAL {ph} DAY) + AND is_successful = 1 + GROUP BY DATE(interaction_time) + ORDER BY date + """ + cursor = self._execute_query(conn, success_sql, (days,)) + success_rows = cursor.fetchall() + conn.close() + + # 构建结果 + from datetime import timedelta + + dates = [] + clicks = [] + successes = [] + + click_map = {str(row['date']): row['count'] for row in click_rows} + success_map = {str(row['date']): row['count'] for row in success_rows} + + for i in range(days - 1, -1, -1): + date = (datetime.now() - timedelta(days=i)).strftime('%Y-%m-%d') + dates.append(date) + clicks.append(click_map.get(date, 0)) + successes.append(success_map.get(date, 0)) + + return { + 'dates': dates, + 'clicks': clicks, + 'successes': successes + } + + except Exception as e: + logger.error(f"获取点击趋势失败: {str(e)}") + return {'dates': [], 'clicks': [], 'successes': []} + + def get_hourly_distribution(self) -> Dict: + """ + 获取按小时分布的点击数据 + + Returns: + {'hours': [0-23], 'clicks': [...]} + """ + try: + conn = self.get_connection() + + sql = """ + SELECT HOUR(click_time) as hour, COUNT(*) as count + FROM ai_mip_click + WHERE click_time >= DATE_SUB(NOW(), INTERVAL 7 DAY) + GROUP BY HOUR(click_time) + ORDER BY hour + """ + cursor = self._execute_query(conn, sql) + rows = cursor.fetchall() + conn.close() + + hour_map = {row['hour']: row['count'] for row in rows} + + hours = list(range(24)) + clicks = [hour_map.get(h, 0) for h in hours] + + return { + 'hours': hours, + 'clicks': clicks + } + + except Exception as e: + logger.error(f"获取时段分布失败: {str(e)}") + return {'hours': list(range(24)), 'clicks': [0] * 24} + + def get_top_sites(self, limit: int = 10) -> List[Dict]: + """ + 获取Top活跃站点 + + Args: + limit: 数量 + + Returns: + 站点列表 [{'site_name', 'click_count', 'reply_count'}, ...] + """ + try: + conn = self.get_connection() + ph = self._get_placeholder() + + sql = f""" + SELECT id, site_name, site_url, click_count, reply_count + FROM ai_mip_site + WHERE status = 'active' + ORDER BY click_count DESC + LIMIT {ph} + """ + cursor = self._execute_query(conn, sql, (limit,)) + rows = cursor.fetchall() + conn.close() + + return [self._dict_from_row(row) for row in rows] + + except Exception as e: + logger.error(f"获取Top站点失败: {str(e)}") + return [] + + def get_reply_rate_distribution(self) -> Dict: + """ + 获取回复率分布数据(用于饼图) + + Returns: + {'labels': [...], 'values': [...]} + """ + try: + conn = self.get_connection() + + # 获取总点击和回复 + cursor = self._execute_query(conn, "SELECT COUNT(*) as total FROM ai_mip_click") + total_clicks = cursor.fetchone()['total'] + + cursor = self._execute_query(conn, "SELECT COUNT(*) as total FROM ai_mip_interaction WHERE response_received = 1") + total_replies = cursor.fetchone()['total'] + + conn.close() + + no_reply = total_clicks - total_replies if total_clicks > total_replies else 0 + + return { + 'labels': ['有回复', '无回复'], + 'values': [total_replies, no_reply] + } + + except Exception as e: + logger.error(f"获取回复率分布失败: {str(e)}") + return {'labels': ['有回复', '无回复'], 'values': [0, 0]} + + +class QueryImportLogManager(DatabaseManager): + """Query导入日志管理器""" + + def ensure_table(self): + """确保 query_import_log 表存在""" + try: + conn = self.get_connection() + cursor = conn.cursor() + cursor.execute(""" + CREATE TABLE IF NOT EXISTS `query_import_log` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `filename` VARCHAR(255) NOT NULL COMMENT '上传的文件名', + `filepath` VARCHAR(500) NOT NULL COMMENT '文件完整路径', + `upload_time` DATETIME NOT NULL COMMENT '上传时间', + `import_time` DATETIME NULL COMMENT '实际导入时间', + `status` VARCHAR(20) DEFAULT 'pending' COMMENT '导入状态', + `total_count` INT DEFAULT 0 COMMENT '总行数', + `success_count` INT DEFAULT 0 COMMENT '成功插入数', + `skip_count` INT DEFAULT 0 COMMENT '跳过数(已存在)', + `fail_count` INT DEFAULT 0 COMMENT '失败数', + `error_message` TEXT NULL COMMENT '错误信息', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX `idx_status` (`status`), + INDEX `idx_upload_time` (`upload_time`) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='关键词导入日志表' + """) + conn.commit() + conn.close() + except Exception as e: + logger.error(f"创建 query_import_log 表失败: {e}") + + def create_log(self, filename: str, filepath: str) -> Optional[int]: + """创建导入日志记录""" + try: + self.ensure_table() + conn = self.get_connection() + ph = self._get_placeholder() + cursor = conn.cursor() + cursor.execute( + f"INSERT INTO query_import_log (filename, filepath, upload_time, status) VALUES ({ph}, {ph}, NOW(), 'pending')", + (filename, filepath) + ) + log_id = cursor.lastrowid + conn.commit() + conn.close() + logger.info(f"创建导入日志: {filename} (ID: {log_id})") + return log_id + except Exception as e: + logger.error(f"创建导入日志失败: {e}") + return None + + def update_status(self, log_id: int, status: str, + total_count: int = 0, success_count: int = 0, + skip_count: int = 0, fail_count: int = 0, + error_message: str = None): + """更新导入状态和统计数据""" + try: + conn = self.get_connection() + ph = self._get_placeholder() + cursor = conn.cursor() + + import_time_sql = ", import_time = NOW()" if status in ('running', 'completed', 'failed') else "" + + cursor.execute( + f"""UPDATE query_import_log + SET status = {ph}, total_count = {ph}, success_count = {ph}, + skip_count = {ph}, fail_count = {ph}, error_message = {ph} + {import_time_sql} + WHERE id = {ph}""", + (status, total_count, success_count, skip_count, fail_count, error_message, log_id) + ) + conn.commit() + conn.close() + except Exception as e: + logger.error(f"更新导入日志失败: {e}") + + def get_pending_logs(self) -> List[Dict]: + """获取待处理的导入日志""" + try: + self.ensure_table() + conn = self.get_connection() + cursor = self._execute_query( + conn, "SELECT * FROM query_import_log WHERE status = 'pending' ORDER BY created_at ASC" + ) + rows = cursor.fetchall() + conn.close() + return [self._dict_from_row(row) for row in rows] + except Exception as e: + logger.error(f"查询待处理日志失败: {e}") + return [] + + def get_logs_paginated(self, page: int = 1, page_size: int = 20) -> Dict: + """分页获取导入日志""" + try: + self.ensure_table() + conn = self.get_connection() + ph = self._get_placeholder() + + # 总数 + cursor = self._execute_query(conn, "SELECT COUNT(*) as total FROM query_import_log") + total = cursor.fetchone()['total'] + + # 分页数据 + offset = (page - 1) * page_size + cursor = self._execute_query( + conn, + f"SELECT * FROM query_import_log ORDER BY created_at DESC LIMIT {ph} OFFSET {ph}", + (page_size, offset) + ) + rows = cursor.fetchall() + conn.close() + + return { + 'items': [self._dict_from_row(row) for row in rows], + 'total': total, + 'page': page, + 'page_size': page_size + } + except Exception as e: + logger.error(f"分页查询导入日志失败: {e}") + return {'items': [], 'total': 0, 'page': page, 'page_size': page_size} + + def is_file_logged(self, filepath: str) -> bool: + """检查文件是否已有导入记录""" + try: + conn = self.get_connection() + ph = self._get_placeholder() + cursor = self._execute_query( + conn, + f"SELECT COUNT(*) as cnt FROM query_import_log WHERE filepath = {ph}", + (filepath,) + ) + cnt = cursor.fetchone()['cnt'] + conn.close() + return cnt > 0 + except Exception as e: + logger.error(f"检查文件日志失败: {e}") + return False + + +class QueryKeywordManager(DatabaseManager): + """Query关键词管理器 - 操作 baidu_keyword 表""" + + def insert_keyword(self, keyword: str, seed_id: int = 9999, seed_name: str = '手动提交', + crawled: int = 1, department: str = '', department_id: int = 0, + author_id: int = 0, author_name: str = '') -> int: + """ + 插入单条关键词到 baidu_keyword 表(INSERT IGNORE) + + Returns: + affected rows: 1=新插入, 0=已存在被跳过, -1=失败 + """ + try: + conn = self.get_connection() + ph = self._get_placeholder() + cursor = conn.cursor() + cursor.execute( + f"""INSERT IGNORE INTO baidu_keyword + (keyword, seed_id, seed_name, crawled, parents_id, created_at, + department, department_id, query_status, author_id, author_name) + VALUES ({ph}, {ph}, {ph}, {ph}, 0, NOW(), {ph}, {ph}, 'manual_review', {ph}, {ph})""", + (keyword, seed_id, seed_name, crawled, department, department_id, author_id, author_name) + ) + affected = cursor.rowcount + conn.commit() + conn.close() + return affected + except Exception as e: + logger.error(f"插入关键词失败: {keyword} - {e}") + return -1 + + def batch_insert_keywords(self, keyword_list: list, seed_id: int = 9999, + seed_name: str = '手动提交', crawled: int = 1, + query_status: str = 'manual_review') -> dict: + """ + 批量插入关键词到 baidu_keyword 表(INSERT IGNORE) + + Args: + keyword_list: [{'keyword': str, 'department': str, 'seed_name': str(可选)}, ...] + query_status: 写入的query_status值,如 'draft' 或 'manual_review' + + Returns: + {'success': int, 'skip': int, 'fail': int} + """ + stats = {'success': 0, 'skip': 0, 'fail': 0} + if not keyword_list: + return stats + + try: + conn = self.get_connection() + cursor = conn.cursor() + + values = [] + for item in keyword_list: + values.append(( + item['keyword'], seed_id, seed_name, crawled, + item.get('department', ''), query_status + )) + + cursor.executemany( + """INSERT IGNORE INTO baidu_keyword + (keyword, seed_id, seed_name, crawled, parents_id, created_at, + department, department_id, query_status, author_id, author_name) + VALUES (%s, %s, %s, %s, 0, NOW(), %s, 0, %s, 0, '')""", + values + ) + + # executemany 的 rowcount 返回实际插入的行数 + inserted = cursor.rowcount + conn.commit() + conn.close() + + stats['success'] = inserted + stats['skip'] = len(keyword_list) - inserted + return stats + + except Exception as e: + logger.error(f"批量插入关键词失败: {e}") + stats['fail'] = len(keyword_list) + return stats diff --git a/debug_no_input.png b/debug_no_input.png deleted file mode 100644 index 645743f..0000000 Binary files a/debug_no_input.png and /dev/null differ diff --git a/deploy.sh b/deploy.sh deleted file mode 100644 index 521c48d..0000000 --- a/deploy.sh +++ /dev/null @@ -1,85 +0,0 @@ -#!/bin/bash -# AI MIP 服务部署脚本 -# 用法: sudo bash deploy.sh - -set -e - -echo "==========================================" -echo " AI MIP 服务部署脚本" -echo "==========================================" - -# 配置变量 -PROJECT_DIR="/opt/ai_mip" -SERVICE_NAME="ai_mip" -SERVICE_FILE="${SERVICE_NAME}.service" -LOG_DIR="/var/log/ai_mip" -VENV_DIR="${PROJECT_DIR}/venv" -USER="www-data" -GROUP="www-data" - -# 检查是否root权限 -if [[ $EUID -ne 0 ]]; then - echo "❌ 错误: 请使用 sudo 运行此脚本" - exit 1 -fi - -echo "" -echo "📦 步骤1: 创建项目目录" -mkdir -p ${PROJECT_DIR} -mkdir -p ${LOG_DIR} -echo "✅ 目录创建完成" - -echo "" -echo "📂 步骤2: 复制项目文件" -echo "请确保当前目录是项目根目录" -cp -r ./* ${PROJECT_DIR}/ -echo "✅ 文件复制完成" - -echo "" -echo "🐍 步骤3: 创建Python虚拟环境" -if [ ! -d "${VENV_DIR}" ]; then - python3 -m venv ${VENV_DIR} - echo "✅ 虚拟环境创建完成" -else - echo "⚠️ 虚拟环境已存在,跳过创建" -fi - -echo "" -echo "📦 步骤4: 安装依赖" -${VENV_DIR}/bin/pip install --upgrade pip -${VENV_DIR}/bin/pip install -r ${PROJECT_DIR}/requirements.txt -echo "✅ 依赖安装完成" - -echo "" -echo "🔐 步骤5: 设置权限" -chown -R ${USER}:${GROUP} ${PROJECT_DIR} -chown -R ${USER}:${GROUP} ${LOG_DIR} -chmod +x ${PROJECT_DIR}/main.py -echo "✅ 权限设置完成" - -echo "" -echo "⚙️ 步骤6: 安装systemd服务" -cp ${PROJECT_DIR}/${SERVICE_FILE} /etc/systemd/system/ -systemctl daemon-reload -echo "✅ 服务文件已安装" - -echo "" -echo "🚀 步骤7: 启动服务" -systemctl enable ${SERVICE_NAME} -systemctl restart ${SERVICE_NAME} -echo "✅ 服务已启动" - -echo "" -echo "==========================================" -echo " 部署完成!" -echo "==========================================" -echo "" -echo "📋 常用命令:" -echo " 查看状态: sudo systemctl status ${SERVICE_NAME}" -echo " 查看日志: sudo journalctl -u ${SERVICE_NAME} -f" -echo " 查看服务日志: tail -f ${LOG_DIR}/service.log" -echo " 查看错误日志: tail -f ${LOG_DIR}/error.log" -echo " 重启服务: sudo systemctl restart ${SERVICE_NAME}" -echo " 停止服务: sudo systemctl stop ${SERVICE_NAME}" -echo " 健康检查: curl http://localhost:8899/health" -echo "" diff --git a/export_clicks_to_csv.py b/export_clicks_to_csv.py new file mode 100644 index 0000000..b59196d --- /dev/null +++ b/export_clicks_to_csv.py @@ -0,0 +1,348 @@ +""" +导出点击记录到CSV文件 +支持开发环境(dev)和生产环境(prod) +""" + +import os +import sys +import csv +import argparse +from pathlib import Path +from datetime import datetime +from loguru import logger +from config import Config +from db_manager import ClickManager + +# 配置日志 +logger.remove() +logger.add( + sys.stdout, + format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {message}", + level="INFO" +) + + +class ClickExporter: + """点击记录导出器""" + + def __init__(self): + """初始化导出器""" + self.config = Config + self.click_manager = ClickManager() + + logger.info("=" * 70) + logger.info(f"点击记录导出器已初始化") + logger.info(f"当前环境: {self.config.ENV}") + logger.info(f"数据库配置:") + logger.info(f" - Host: {self.config.MYSQL_HOST}:{self.config.MYSQL_PORT}") + logger.info(f" - Database: {self.config.MYSQL_DATABASE}") + logger.info(f" - User: {self.config.MYSQL_USER}") + logger.info("=" * 70) + logger.info("提示: 通过设置环境变量 ENV=production 切换到生产环境") + logger.info("=" * 70) + + def get_all_clicks(self, start_date: str = None, end_date: str = None, + site_id: int = None, limit: int = None, join_mode: str = 'simple') -> list: + """ + 查询点击记录 + + Args: + start_date: 开始日期 YYYY-MM-DD + end_date: 结束日期 YYYY-MM-DD + site_id: 站点ID筛选 + limit: 限制数量 + join_mode: 查询模式 simple=仅点击表, full=联合三表 + + Returns: + 点击记录列表 + """ + try: + conn = self.click_manager.get_connection() + cursor = conn.cursor() + + # 根据模式构建不同的SQL查询 + if join_mode == 'full': + # 联合三表查询 + sql = """ + SELECT + c.id as click_id, + c.site_id, + c.site_url, + c.click_time, + c.user_ip, + c.device_type, + c.task_id as click_task_id, + c.operator as click_operator, + s.site_name, + s.status as site_status, + s.site_dimension, + s.click_count as total_click_count, + s.reply_count as total_reply_count, + i.id as interaction_id, + i.interaction_type, + i.interaction_time, + i.interaction_status, + i.reply_content, + i.response_received, + i.response_content, + i.is_successful as interaction_successful, + i.proxy_ip as interaction_proxy_ip, + i.fingerprint_id, + i.error_message + FROM ai_mip_click c + LEFT JOIN ai_mip_site s ON c.site_id = s.id + LEFT JOIN ai_mip_interaction i ON c.id = i.click_id + WHERE 1=1 + """ + else: + # 简单查询,仅点击表 + sql = "SELECT * FROM ai_mip_click WHERE 1=1" + + params = [] + + if start_date: + sql += " AND c.click_time >= %s" if join_mode == 'full' else " AND click_time >= %s" + params.append(f"{start_date} 00:00:00") + + if end_date: + sql += " AND c.click_time <= %s" if join_mode == 'full' else " AND click_time <= %s" + params.append(f"{end_date} 23:59:59") + + if site_id: + sql += " AND c.site_id = %s" if join_mode == 'full' else " AND site_id = %s" + params.append(site_id) + + sql += " ORDER BY c.click_time DESC" if join_mode == 'full' else " ORDER BY click_time DESC" + + if limit: + sql += f" LIMIT {limit}" + + logger.info(f"查询模式: {join_mode}") + logger.info(f"执行查询: {sql[:200]}..." if len(sql) > 200 else f"执行查询: {sql}") + logger.info(f"参数: {params}") + + cursor.execute(sql, params if params else None) + + # 获取列名 + columns = [desc[0] for desc in cursor.description] + + # 获取所有记录 + rows = cursor.fetchall() + + # 转换为字典列表 + clicks = [] + for row in rows: + click_dict = {} + for idx, col in enumerate(columns): + value = row[idx] + # 处理datetime类型 + if isinstance(value, datetime): + value = value.strftime('%Y-%m-%d %H:%M:%S') + click_dict[col] = value + clicks.append(click_dict) + + conn.close() + + logger.success(f"查询成功,获取到 {len(clicks)} 条记录") + return clicks + + except Exception as e: + logger.error(f"查询点击记录失败: {str(e)}") + return [] + + def export_to_csv(self, clicks: list, output_file: str, encoding: str = 'utf-8-sig'): + """ + 导出到CSV文件 + + Args: + clicks: 点击记录列表 + output_file: 输出文件路径 + encoding: 文件编码,默认utf-8-sig(Excel兼容) + """ + if not clicks: + logger.warning("没有数据可导出") + return False + + try: + # 确保输出目录存在 + output_path = Path(output_file) + output_path.parent.mkdir(parents=True, exist_ok=True) + + # 定义字段中文映射 + field_mapping = { + # 点击表字段 + 'id': '点击ID', + 'click_id': '点击ID', + 'site_id': '站点ID', + 'site_url': '站点URL', + 'click_time': '点击时间', + 'user_ip': '用户IP', + 'user_agent': '浏览器标识', + 'referer_url': '来源页面', + 'device_type': '设备类型', + 'click_count': '点击次数', + 'is_valid': '是否有效', + 'task_id': '任务ID', + 'click_task_id': '任务ID', + 'operator': '操作者', + 'click_operator': '操作者', + 'created_at': '创建时间', + # 站点表字段 + 'site_name': '站点名称', + 'site_status': '站点状态', + 'status': '状态', + 'site_dimension': '站点维度', + 'total_click_count': '总点击数', + 'total_reply_count': '总回复数', + # 互动表字段 + 'interaction_id': '互动ID', + 'interaction_type': '互动类型', + 'interaction_time': '互动时间', + 'interaction_status': '互动状态', + 'reply_content': '回复内容', + 'response_received': '是否收到回复', + 'response_content': '对方回复内容', + 'interaction_successful': '互动是否成功', + 'is_successful': '是否成功', + 'interaction_proxy_ip': '代理IP', + 'proxy_ip': '代理IP', + 'fingerprint_id': '浏览器指纹ID', + 'error_message': '错误信息', + } + + # 获取所有字段名(英文) + fieldnames = list(clicks[0].keys()) + + # 转换为中文字段名 + chinese_fieldnames = [field_mapping.get(field, field) for field in fieldnames] + + # 写入CSV + with open(output_file, 'w', newline='', encoding=encoding) as csvfile: + writer = csv.writer(csvfile) + # 写入中文表头 + writer.writerow(chinese_fieldnames) + # 写入数据行 + for click in clicks: + row = [click.get(field, '') for field in fieldnames] + writer.writerow(row) + + logger.success(f"成功导出 {len(clicks)} 条记录到: {output_file}") + logger.info(f"文件大小: {output_path.stat().st_size / 1024:.2f} KB") + + return True + + except Exception as e: + logger.error(f"导出CSV失败: {str(e)}") + return False + + def print_summary(self, clicks: list): + """打印数据摘要""" + if not clicks: + return + + logger.info("\n" + "=" * 70) + logger.info("数据摘要") + logger.info("=" * 70) + logger.info(f"总记录数: {len(clicks)}") + + # 统计设备类型 + device_stats = {} + for click in clicks: + device = click.get('device_type', 'unknown') + device_stats[device] = device_stats.get(device, 0) + 1 + + logger.info(f"设备类型分布:") + for device, count in device_stats.items(): + logger.info(f" - {device}: {count} 条") + + # 时间范围 + if clicks: + times = [click.get('click_time') for click in clicks if click.get('click_time')] + if times: + logger.info(f"时间范围: {min(times)} ~ {max(times)}") + + logger.info("=" * 70) + + +def main(): + """主函数""" + parser = argparse.ArgumentParser( + description='导出点击记录到CSV文件(环境通过ENV环境变量控制)', + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +使用示例: + # 导出开发环境所有点击记录(默认,仅点击表) + python export_clicks_to_csv.py -o clicks_dev.csv + + # 导出联合三表的完整数据(包含站点信息和互动记录) + python export_clicks_to_csv.py -o clicks_full.csv --join-mode full + + # 导出生产环境点击记录(设置ENV环境变量) + $env:ENV="production"; python export_clicks_to_csv.py -o clicks_prod.csv --join-mode full + + # 按日期范围导出 + python export_clicks_to_csv.py -o clicks.csv --start-date 2026-01-01 --end-date 2026-01-31 --join-mode full + + # 导出指定站点的记录 + python export_clicks_to_csv.py -o clicks.csv --site-id 123 --join-mode full + + # 限制导出数量 + python export_clicks_to_csv.py -o clicks.csv --limit 1000 --join-mode full + """ + ) + + parser.add_argument('-o', '--output', required=True, + help='输出CSV文件路径') + parser.add_argument('--start-date', help='开始日期 YYYY-MM-DD') + parser.add_argument('--end-date', help='结束日期 YYYY-MM-DD') + parser.add_argument('--site-id', type=int, help='站点ID筛选') + parser.add_argument('--limit', type=int, help='限制导出数量') + parser.add_argument('--join-mode', choices=['simple', 'full'], default='simple', + help='查询模式: simple=仅点击表, full=联合三表(点击+站点+互动)') + parser.add_argument('--encoding', default='utf-8-sig', + help='文件编码,默认utf-8-sig(Excel兼容)') + + args = parser.parse_args() + + try: + # 创建导出器 + exporter = ClickExporter() + + # 查询点击记录 + logger.info("\n开始查询点击记录...") + clicks = exporter.get_all_clicks( + start_date=args.start_date, + end_date=args.end_date, + site_id=args.site_id, + limit=args.limit, + join_mode=args.join_mode + ) + + if not clicks: + logger.warning("没有找到符合条件的记录") + sys.exit(1) + + # 打印摘要 + exporter.print_summary(clicks) + + # 导出到CSV + logger.info(f"\n开始导出到: {args.output}") + success = exporter.export_to_csv(clicks, args.output, args.encoding) + + if success: + logger.success("\n导出完成!") + sys.exit(0) + else: + logger.error("\n导出失败") + sys.exit(1) + + except KeyboardInterrupt: + logger.warning("\n用户中断导出") + sys.exit(130) + except Exception as e: + logger.error(f"导出失败: {str(e)}") + sys.exit(1) + + +if __name__ == '__main__': + main() diff --git a/import_excel_to_db.py b/import_excel_to_db.py index 1c187bc..5941cf2 100644 --- a/import_excel_to_db.py +++ b/import_excel_to_db.py @@ -25,38 +25,9 @@ logger.add( class ExcelImporter: """Excel数据导入器""" - def __init__(self, env: str = None): - """ - 初始化导入器 - - Args: - env: 环境标识,dev或prod,默认使用当前配置 - """ - # 如果指定了环境,临时设置环境变量 - if env: - original_env = os.getenv('ENV') - if env == 'dev': - os.environ['ENV'] = 'development' - elif env == 'prod': - os.environ['ENV'] = 'production' - else: - raise ValueError(f"无效的环境标识: {env},必须是 dev 或 prod") - - # 重新加载配置 - import importlib - import config as config_module - importlib.reload(config_module) - from config import Config as ReloadedConfig - self.config = ReloadedConfig - - # 恢复原始环境变量 - if original_env: - os.environ['ENV'] = original_env - else: - os.environ.pop('ENV', None) - else: - self.config = Config - + def __init__(self): + """初始化导入器""" + self.config = Config self.site_manager = SiteManager() logger.info("=" * 70) @@ -67,6 +38,8 @@ class ExcelImporter: logger.info(f" - Database: {self.config.MYSQL_DATABASE}") logger.info(f" - User: {self.config.MYSQL_USER}") logger.info("=" * 70) + logger.info("提示: 通过设置环境变量 ENV=production 切换到生产环境") + logger.info("=" * 70) def read_excel(self, file_path: str) -> pd.DataFrame: """ @@ -258,27 +231,25 @@ class ExcelImporter: def main(): """主函数""" parser = argparse.ArgumentParser( - description='从Excel文件导入URL数据到数据库', + description='从Excel文件导入URL数据到数据库(环境通过ENV环境变量控制)', formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" 使用示例: - # 导入到开发环境 - python import_excel_to_db.py -f "广告链接 1.26(962条).xlsx" -e dev + # 导入到开发环境(默认) + python import_excel_to_db.py -f "广告链接 1.26(962条).xlsx" - # 导入到生产环境 - python import_excel_to_db.py -f "广告链接 1.26(962条).xlsx" -e prod + # 导入到生产环境(设置ENV环境变量) + $env:ENV="production"; python import_excel_to_db.py -f "广告链接 1.26(962条).xlsx" # 指定查询词和维度 - python import_excel_to_db.py -f "广告链接.xlsx" -e dev -q "关键词" -d "医疗" + python import_excel_to_db.py -f "广告链接.xlsx" -q "关键词" -d "医疗" # 试运行模式(不实际插入) - python import_excel_to_db.py -f "广告链接.xlsx" -e dev --dry-run + python import_excel_to_db.py -f "广告链接.xlsx" --dry-run """ ) parser.add_argument('-f', '--file', required=True, help='Excel文件路径') - parser.add_argument('-e', '--env', choices=['dev', 'prod'], required=True, - help='目标环境: dev=开发环境, prod=生产环境') parser.add_argument('-q', '--query-word', help='查询词(默认:None)') parser.add_argument('-d', '--dimension', help='站点维度(默认:None)') parser.add_argument('--frequency', type=int, default=1, help='频次(默认:1)') @@ -291,7 +262,7 @@ def main(): try: # 创建导入器 - importer = ExcelImporter(env=args.env) + importer = ExcelImporter() # 读取Excel df = importer.read_excel(args.file) @@ -307,7 +278,7 @@ def main(): # 确认导入 if not args.dry_run: - logger.warning(f"\n即将导入 {len(df)} 条数据到【{args.env.upper()}】环境") + logger.warning(f"\n即将导入 {len(df)} 条数据到【{importer.config.ENV.upper()}】环境") logger.warning(f"数据库: {importer.config.MYSQL_HOST}:{importer.config.MYSQL_PORT}/{importer.config.MYSQL_DATABASE}") response = input("\n确认继续?[y/N]: ") diff --git a/localAPI-main/README.md b/localAPI-main/README.md deleted file mode 100644 index cec4c8b..0000000 --- a/localAPI-main/README.md +++ /dev/null @@ -1,44 +0,0 @@ -# localAPI -AdsPower supports Local API, which has functions like reading and writing account configuration information, opening and closing browsers, searching for accounts. Besides, it can cooperate with Selenium and Puppeteer to execute browser operations automatically. - -
- -> ## How to Use AdsPower Local API - - - Users of AdsPower team collaboration version have access to API - - Start AdsPower, log in the account with API permission - - Go to Account Management-> Setting-> Local API to check the following items - - API status: Success - - API address: http://local.adspower.net:50325/ or http://localhost:50325/ (port: 50325, which might change and subjects to the address in the setting). - - Script can go to Profile Management-> click Settings-> click Cache folder-> local_api file to obtain API address - - Use script or http request tool to invoke Local API, allow to configure account data, browser fingerprint, open or close browser and other operations - - API parameter type: string, Post format: JSON, unnecessary parameters are optional and can not be passed - - Access frequency control for all APIs, max. access frequency: 1 request/second - - At the same time, it supports the mode of no-interface api-key to start the Local API service. For details, see: [Local Api Doc](https://localapi-doc-en.adspower.com/) - -
- -> ## **What the Local API supports** - -- [x] API Status -- [x] Browser Operation - - [x] Open Browser - - [x] Close Browser - - [x] Check Open Status -- [x] Group Management - - [x] Create Group - - [ ] Update Group(coming soon) - - [x] Query Group -- [x] Profile Management - - [x] Create Profile - - [x] Update Profile - - [x] Query Profile - - [x] Delete Profile - - [x] Update Profile Group - - [x] Delete Profile Cache - -
- -## More Details - -👉[Local Api Doc](https://localapi-doc-en.adspower.com/) diff --git a/localAPI-main/countryCode.json b/localAPI-main/countryCode.json deleted file mode 100644 index 0098006..0000000 --- a/localAPI-main/countryCode.json +++ /dev/null @@ -1,249 +0,0 @@ -{ - "ad": "Andorra", - "ae": "United Arab Emirates", - "af": "Afghanistan", - "ag": "Antigua & Barbuda", - "ai": "Anguilla", - "al": "Albania", - "am": "Armenia", - "ao": "Angola", - "aq": "Antarctica", - "ar": "Argentina", - "as": "American Samoa", - "at": "Austria", - "au": "Australia", - "aw": "Aruba", - "ax": "_land Islands", - "az": "Azerbaijan", - "ba": "Bosnia & Herzegovina", - "bb": "Barbados", - "bd": "Bangladesh", - "be": "Belgium", - "bf": "Burkina", - "bg": "Bulgaria", - "bh": "Bahrain", - "bi": "Burundi", - "bj": "Benin", - "bl": "Saint Barthélemy", - "bm": "Bermuda", - "bn": "Brunei", - "bo": "Bolivia", - "bq": "Caribbean Netherlands", - "br": "Brazil", - "bs": "The Bahamas", - "bt": "Bhutan", - "bv": "Bouvet Island", - "bw": "Botswana", - "by": "Belarus", - "bz": "Belize", - "ca": "Canada", - "cc": "Cocos (Keeling) Islands", - "cf": "Central African Republic", - "ch": "Switzerland", - "cl": "Chile", - "cm": "Cameroon", - "co": "Colombia", - "cr": "Costa Rica", - "cu": "Cuba", - "cv": "Cape Verde", - "cx": "Christmas Island", - "cy": "Cyprus", - "cz": "Czech Republic", - "de": "Germany", - "dj": "Djibouti", - "dk": "Denmark", - "dm": "Dominica", - "do": "Dominican Republic", - "dz": "Algeria", - "ec": "Ecuador", - "ee": "Estonia", - "eg": "Egypt", - "eh": "Western Sahara", - "er": "Eritrea", - "es": "Spain", - "fi": "Finland", - "fj": "Fiji", - "fk": "Falkland Islands", - "fm": "Federated States of Micronesia", - "fo": "Faroe Islands", - "fr": "France", - "ga": "Gabon", - "gd": "Grenada", - "ge": "Georgia", - "gf": "French Guiana", - "gh": "Ghana", - "gi": "Gibraltar", - "gl": "Greenland", - "gn": "Guinea", - "gp": "Guadeloupe", - "gq": "Equatorial Guinea", - "gr": "Greece", - "gs": "South Georgia and the South Sandwich Islands", - "gt": "Guatemala", - "gu": "Guam", - "gw": "Guinea-Bissau", - "gy": "Guyana", - "hk": "China Hong Kong", - "hm": "Heard Island and McDonald Islands", - "hn": "Honduras", - "hr": "Croatia", - "ht": "Haiti", - "hu": "Hungary", - "id": "Indonesia", - "ie": "Ireland", - "il": "Israel", - "im": "Isle of Man", - "in": "India", - "io": "British Indian Ocean Territory", - "iq": "Iraq", - "ir": "Iran", - "is": "Iceland", - "it": "Italy", - "je": "Jersey", - "jm": "Jamaica", - "jo": "Jordan", - "jp": "Japan", - "kh": "Cambodia", - "ki": "Kiribati", - "km": "The Comoros", - "kw": "Kuwait", - "ky": "Cayman Islands", - "lb": "Lebanon", - "li": "Liechtenstein", - "lk": "Sri Lanka", - "lr": "Liberia", - "ls": "Lesotho", - "lt": "Lithuania", - "lu": "Luxembourg", - "lv": "Latvia", - "ly": "Libya", - "ma": "Morocco", - "mc": "Monaco", - "md": "Moldova", - "me": "Montenegro", - "mf": "Saint Martin (France)", - "mg": "Madagascar", - "mh": "Marshall islands", - "mk": "Republic of Macedonia (FYROM)", - "ml": "Mali", - "mm": "Myanmar (Burma)", - "mo": "China Macao", - "mq": "Martinique", - "mr": "Mauritania", - "ms": "Montserrat", - "mt": "Malta", - "mv": "Maldives", - "mw": "Malawi", - "mx": "Mexico", - "my": "Malaysia", - "na": "Namibia", - "ne": "Niger", - "nf": "Norfolk Island", - "ng": "Nigeria", - "ni": "Nicaragua", - "nl": "Netherlands", - "no": "Norway", - "np": "Nepal", - "nr": "Nauru", - "om": "Oman", - "pa": "Panama", - "pe": "Peru", - "pf": "French polynesia", - "pg": "Papua New Guinea", - "ph": "The Philippines", - "pk": "Pakistan", - "pl": "Poland", - "pn": "Pitcairn Islands", - "pr": "Puerto Rico", - "ps": "Palestinian territories", - "pw": "Palau", - "py": "Paraguay", - "qa": "Qatar", - "re": "Réunion", - "ro": "Romania", - "rs": "Serbia", - "ru": "Russian Federation", - "rw": "Rwanda", - "sb": "Solomon Islands", - "sc": "Seychelles", - "sd": "Sudan", - "se": "Sweden", - "sg": "Singapore", - "si": "Slovenia", - "sj": "Template:Country data SJM Svalbard", - "sk": "Slovakia", - "sl": "Sierra Leone", - "sm": "San Marino", - "sn": "Senegal", - "so": "Somalia", - "sr": "Suriname", - "ss": "South Sudan", - "st": "Sao Tome & Principe", - "sv": "El Salvador", - "sy": "Syria", - "sz": "Swaziland", - "tc": "Turks & Caicos Islands", - "td": "Chad", - "tg": "Togo", - "th": "Thailand", - "tk": "Tokelau", - "tl": "Timor-Leste (East Timor)", - "tn": "Tunisia", - "to": "Tonga", - "tr": "Turkey", - "tv": "Tuvalu", - "tz": "Tanzania", - "ua": "Ukraine", - "ug": "Uganda", - "us": "United States of America (USA)", - "uy": "Uruguay", - "va": "Vatican City (The Holy See)", - "ve": "Venezuela", - "vg": "British Virgin Islands", - "vi": "United States Virgin Islands", - "vn": "Vietnam", - "wf": "Wallis and Futuna", - "ws": "Samoa", - "ye": "Yemen", - "yt": "Mayotte", - "za": "South Africa", - "zm": "Zambia", - "zw": "Zimbabwe", - "cn": "China", - "cg": "Republic of the Congo", - "cd": "Democratic Republic of the Congo", - "mz": "Mozambique", - "gg": "Guernsey", - "gm": "Gambia", - "mp": "Northern Mariana Islands", - "et": "Ethiopia", - "nc": "New Caledonia", - "vu": "Vanuatu", - "tf": "French Southern Territories", - "nu": "Niue", - "um": "United States Minor Outlying Islands", - "ck": "Cook Islands", - "gb": "Great Britain", - "tt": "Trinidad & Tobago", - "vc": "St. Vincent & the Grenadines", - "tw": "China Taiwan", - "nz": "New Zealand", - "sa": "Saudi Arabia", - "la": "Laos", - "kp": "North Korea", - "kr": "South Korea", - "pt": "Portugal", - "kg": "Kyrgyzstan", - "kz": "Kazakhstan", - "tj": "Tajikistan", - "tm": "Turkmenistan", - "uz": "Uzbekistan", - "kn": "St. Kitts & Nevis", - "pm": "Saint-Pierre and Miquelon", - "sh": "St. Helena & Dependencies", - "lc": "St. Lucia", - "mu": "Mauritius", - "ci": "C_te d'Ivoire", - "ke": "Kenya", - "mn": "Mongolia" -} \ No newline at end of file diff --git a/localAPI-main/font.js b/localAPI-main/font.js deleted file mode 100644 index 6ab189f..0000000 --- a/localAPI-main/font.js +++ /dev/null @@ -1,185 +0,0 @@ -const fonts = [ - "Arial", - "Calibri", - "Cambria", - "Cambria Math", - "Candara", - "Comic Sans MS", - "Comic Sans MS Bold", - "Comic Sans", - "Consolas", - "Constantia", - "Corbel", - "Courier New", - "Caurier Regular", - "Ebrima", - "Fixedsys Regular", - "Franklin Gothic", - "Gabriola Regular", - "Gadugi", - "Georgia", - "HoloLens MDL2 Assets Regular", - "Impact Regular", - "Javanese Text Regular", - "Leelawadee UI", - "Lucida Console Regular", - "Lucida Sans Unicode Regular", - "Malgun Gothic", - "Microsoft Himalaya Regular", - "Microsoft JhengHei", - "Microsoft JhengHei UI", - "Microsoft PhangsPa", - "Microsoft Sans Serif Regular", - "Microsoft Tai Le", - "Microsoft YaHei", - "Microsoft YaHei UI", - "Microsoft Yi Baiti Regular", - "MingLiU_HKSCS-ExtB Regular", - "MingLiu-ExtB Regular", - "Modern Regular", - "Mongolia Baiti Regular", - "MS Gothic Regular", - "MS PGothic Regular", - "MS Sans Serif Regular", - "MS Serif Regular", - "MS UI Gothic Regular", - "MV Boli Regular", - "Myanmar Text", - "Nimarla UI", - "MV Boli Regular", - "Myanmar Tet", - "Nirmala UI", - "NSimSun Regular", - "Palatino Linotype", - "PMingLiU-ExtB Regular", - "Roman Regular", - "Script Regular", - "Segoe MDL2 Assets Regular", - "Segoe Print", - "Segoe Script", - "Segoe UI", - "Segoe UI Emoji Regular", - "Segoe UI Historic Regular", - "Segoe UI Symbol Regular", - "SimSun Regular", - "SimSun-ExtB Regular", - "Sitka Banner", - "Sitka Display", - "Sitka Heading", - "Sitka Small", - "Sitka Subheading", - "Sitka Text", - "Small Fonts Regular", - "Sylfaen Regular", - "Symbol Regular", - "System Bold", - "Tahoma", - "Terminal", - "Times New Roman", - "Trebuchet MS", - "Verdana", - "Webdings Regular", - "Wingdings Regular", - "Yu Gothic", - "Yu Gothic UI", - "Arial", - "Arial Black", - "Calibri", - "Calibri Light", - "Cambria", - "Cambria Math", - "Candara", - "Comic Sans MS", - "Consolas", - "Constantia", - "Corbel", - "Courier", - "Courier New", - "Ebrima", - "Fixedsys", - "Franklin Gothic Medium", - "Gabriola", - "Gadugi", - "Georgia", - "HoloLens MDL2 Assets", - "Impact", - "Javanese Text", - "Leelawadee UI", - "Leelawadee UI Semilight", - "Lucida Console", - "Lucida Sans Unicode", - "MS Gothic", - "MS PGothic", - "MS Sans Serif", - "MS Serif", - "MS UI Gothic", - "MV Boli", - "Malgun Gothic", - "Malgun Gothic Semilight", - "Marlett", - "Microsoft Himalaya", - "Microsoft JhengHei", - "Microsoft JhengHei Light", - "Microsoft JhengHei UI", - "Microsoft JhengHei UI Light", - "Microsoft New Tai Lue", - "Microsoft PhagsPa", - "Microsoft Sans Serif", - "Microsoft Tai Le", - "Microsoft YaHei", - "Microsoft YaHei Light", - "Microsoft YaHei UI", - "Microsoft YaHei UI Light", - "Microsoft Yi Baiti", - "MingLiU-ExtB", - "MingLiU_HKSCS-ExtB", - "Modern", - "Mongolian Baiti", - "Myanmar Text", - "NSimSun", - "Nirmala UI", - "Nirmala UI Semilight", - "PMingLiU-ExtB", - "Palatino Linotype", - "Roman", - "Script", - "Segoe MDL2 Assets", - "Segoe Print", - "Segoe Script", - "Segoe UI", - "Segoe UI Black", - "Segoe UI Emoji", - "Segoe UI Historic", - "Segoe UI Light", - "Segoe UI Semibold", - "Segoe UI Semilight", - "Segoe UI Symbol", - "SimSun", - "SimSun-ExtB", - "Sitka Banner", - "Sitka Display", - "Sitka Heading", - "Sitka Small", - "Sitka Subheading", - "Sitka Text", - "Small Fonts", - "Sylfaen", - "Symbol", - "System", - "Tahoma", - "Terminal", - "Times New Roman", - "Trebuchet MS", - "Verdana", - "Webdings", - "Wingdings", - "Yu Gothic", - "Yu Gothic Light", - "Yu Gothic Medium", - "Yu Gothic UI", - "Yu Gothic UI Light", - "Yu Gothic UI Semibold", - "Yu Gothic UI Semilight" -]; - -module.exports = fonts; diff --git a/localAPI-main/js-examples/example-check-profile-status.js b/localAPI-main/js-examples/example-check-profile-status.js deleted file mode 100644 index 3e3b55e..0000000 --- a/localAPI-main/js-examples/example-check-profile-status.js +++ /dev/null @@ -1,17 +0,0 @@ -const axios = require('axios'); - -const profileId = 'XX'; - -const config = { - method: 'get', - url: `http://localhost:50325/api/v1/browser/active?user_id=${profileId}`, - headers: { } -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-create-group.js b/localAPI-main/js-examples/example-create-group.js deleted file mode 100644 index a095856..0000000 --- a/localAPI-main/js-examples/example-create-group.js +++ /dev/null @@ -1,21 +0,0 @@ -const axios = require('axios'); -const data = { - group_name: "your_group_name" -}; - -const config = { - method: 'post', - url: 'http://local.adspower.net:50325/api/v1/group/create', - headers: { - 'Content-Type': 'application/json' - }, - data : data -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-create-profile.js b/localAPI-main/js-examples/example-create-profile.js deleted file mode 100644 index 61b1d30..0000000 --- a/localAPI-main/js-examples/example-create-profile.js +++ /dev/null @@ -1,48 +0,0 @@ -var axios = require('axios'); -var data = { - "name": "test", - "group_id": "0", - "domain_name": "facebook.com", - "repeat_config": [ - "0" - ], - "country": "us", - "fingerprint_config": { - "language": [ - "en-US" - ], - "ua": "Mozilla/5.0 (Linux; Android 8.0.0; BND-AL10 Build/HONORBND-AL10; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/63.0.3239.83 Mobile Safari/537.36 T7/11.5 baiduboxapp/11.5.0.10 (Baidu; P1 8.0.0)", - "flash": "block", - "scan_port_type": "1", - "screen_resolution": "1024_600", - "fonts": [ - "all" - ], - "longitude": "180", - "latitude": "90", - "webrtc": "proxy", - "do_not_track": "true", - "hardware_concurrency": "default", - "device_memory": "default" - }, - "user_proxy_config": { - "proxy_soft": "no_proxy" - } -}; - -var config = { - method: 'post', - url: 'http://local.adspower.net:50325/api/v1/user/create', - headers: { - 'Content-Type': 'application/json' - }, - data : data -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-delete-profile-cache.js b/localAPI-main/js-examples/example-delete-profile-cache.js deleted file mode 100644 index 06df94c..0000000 --- a/localAPI-main/js-examples/example-delete-profile-cache.js +++ /dev/null @@ -1,15 +0,0 @@ -const axios = require('axios'); - -const config = { - method: 'post', - url: 'http://localhost:50325/api/v1/user/delete-cache', - headers: { } -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-delete-profile.js b/localAPI-main/js-examples/example-delete-profile.js deleted file mode 100644 index 599382b..0000000 --- a/localAPI-main/js-examples/example-delete-profile.js +++ /dev/null @@ -1,23 +0,0 @@ -const axios = require('axios'); -const data = { - "user_ids": [ - "XX" - ] -}; - -const config = { - method: 'post', - url: 'http://localhost:50325/api/v1/user/delete', - headers: { - 'Content-Type': 'application/json' - }, - data : data -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-query-group.js b/localAPI-main/js-examples/example-query-group.js deleted file mode 100644 index 48f6f70..0000000 --- a/localAPI-main/js-examples/example-query-group.js +++ /dev/null @@ -1,15 +0,0 @@ -const axios = require('axios'); - -const config = { - method: 'get', - url: 'http://local.adspower.net:50325/api/v1/group/list?page=1&page_size=15', - headers: { } -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-query-profile.js b/localAPI-main/js-examples/example-query-profile.js deleted file mode 100644 index 295346e..0000000 --- a/localAPI-main/js-examples/example-query-profile.js +++ /dev/null @@ -1,15 +0,0 @@ -const axios = require('axios'); - -const config = { - method: 'get', - url: 'http://local.adspower.net:50325/api/v1/user/list?page=1&page_size=100', - headers: { } -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-regroup-profile.js b/localAPI-main/js-examples/example-regroup-profile.js deleted file mode 100644 index b499083..0000000 --- a/localAPI-main/js-examples/example-regroup-profile.js +++ /dev/null @@ -1,24 +0,0 @@ -const axios = require('axios'); -const data = { - "user_ids": [ - "XX" - ], - "group_id": "0" -}; - -const config = { - method: 'post', - url: 'http://local.adspower.net:50325/api/v1/user/regroup', - headers: { - 'Content-Type': 'application/json' - }, - data : data -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-start-profile.js b/localAPI-main/js-examples/example-start-profile.js deleted file mode 100644 index 19fc84d..0000000 --- a/localAPI-main/js-examples/example-start-profile.js +++ /dev/null @@ -1,27 +0,0 @@ -const axios = require('axios'); -const puppeteer = require('puppeteer-core'); - -const profileId = 'XXX'; - -// http://local.adspower.net:50325: Script can go to Profile Management-> click Settings-> click Cache folder-> local_api file to obtain API address -axios.get(`http://local.adspower.net:50325/api/v1/browser/start?user_id=${profileId}`).then(async (res) => { - console.log(res.data); - - if(res.data.code === 0 && res.data.data.ws && res.data.data.ws.puppeteer) { - try{ - const browser = await puppeteer.connect({ - browserWSEndpoint: res.data.data.ws.puppeteer, - defaultViewport:null - }); - - const page = await browser.newPage(); - await page.goto('https://www.adspower.com'); - await page.screenshot({ path: './adspower.png' }); - await browser.close(); - } catch(err){ - console.log(err.message); - } - } -}).catch((err) => { - console.log(err) -}) \ No newline at end of file diff --git a/localAPI-main/js-examples/example-stop-profile.js b/localAPI-main/js-examples/example-stop-profile.js deleted file mode 100644 index 4afef8a..0000000 --- a/localAPI-main/js-examples/example-stop-profile.js +++ /dev/null @@ -1,16 +0,0 @@ -const axios = require('axios'); - -const profileId = 'XX'; -const config = { - method: 'get', - url: `http://local.adspower.net:50325/api/v1/browser/stop?user_id=${profileId}`, - headers: { } -}; - -axios(config) -.then((response) => { - console.log(JSON.stringify(response.data)); -}) -.catch((error) => { - console.log(error); -}); diff --git a/localAPI-main/js-examples/example-update-profile.js b/localAPI-main/js-examples/example-update-profile.js deleted file mode 100644 index 5e6f245..0000000 --- a/localAPI-main/js-examples/example-update-profile.js +++ /dev/null @@ -1,41 +0,0 @@ -const axios = require('axios'); -const data = { - "user_id": "XX", - "name": "test", - "domain_name": "facebook.com", - "repeat_config": [ - "0" - ], - "open_urls": [ - "http://www.baidu.com", - "https://www.google.com" - ], - "country": "us", - "remark": "remark", - "fingerprint_config": { - "webrtc": "proxy", - "do_not_track": "true", - "hardware_concurrency": "default", - "device_memory": "default" - }, - "user_proxy_config": { - "proxy_soft": "no_proxy" - } -}; - -const config = { - method: 'post', - url: 'http://local.adspower.net:50325/api/v1/user/update', - headers: { - 'Content-Type': 'application/json' - }, - data : data -}; - -axios(config) -.then(function (response) { - console.log(JSON.stringify(response.data)); -}) -.catch(function (error) { - console.log(error); -}); diff --git a/localAPI-main/language.js b/localAPI-main/language.js deleted file mode 100644 index 042fd01..0000000 --- a/localAPI-main/language.js +++ /dev/null @@ -1,2160 +0,0 @@ -const language = [ - { - cc: 'ad', - code: 'ca-ES', - prefix: 'ca', - en: 'Catalan', - nation: '安道尔', - lang: '加泰罗尼亚语' - }, - { - cc: 'af', - code: 'prs-AF', - prefix: 'prs', - en: 'Dari', - nation: '阿富汗', - lang: '达里语' - }, - { - cc: 'af', - code: 'ps-AF', - prefix: 'ps', - en: 'Pashto', - nation: '阿富汗', - lang: '普什图语' - }, - { - cc: 'al', - code: 'sq-AL', - prefix: 'sq', - en: 'Albanian', - nation: '阿尔巴尼亚', - lang: '阿尔巴尼亚语' - }, - { - cc: 'am', - code: 'hy-AM', - prefix: 'hy', - en: 'Armenian', - nation: '亚美尼亚', - lang: '亚美尼亚语' - }, - { - cc: 'ao', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '安哥拉', - lang: '葡萄牙语' - }, - { - cc: 'aq', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '南极洲', - lang: '英语' - }, - { - cc: 'ar', - code: 'es-AR', - prefix: 'es', - en: 'Spanish', - nation: '阿根廷', - lang: '西班牙语' - }, - { - cc: 'as', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '美属萨摩亚', - lang: '英语' - }, - { - cc: 'at', - code: 'de-AT', - prefix: 'de', - en: 'German', - nation: '奥地利', - lang: '德语' - }, - { - cc: 'ag', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '安提瓜和巴布达', - lang: '英语' - }, - { - cc: 'ai', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '安圭拉', - lang: '英语' - }, - { - cc: 'ae', - code: 'ar-AE', - prefix: 'ar', - en: 'Arabic', - nation: '阿联酋', - lang: '阿拉伯语' - }, - { - cc: 'ax', - code: 'sv-SE', - prefix: 'sv', - en: 'Swedish', - nation: '奥兰群岛', - lang: '瑞典语' - }, - { - cc: 'ax', - code: 'fi-FI', - prefix: 'fi', - en: 'Finnish', - nation: '奥兰群岛', - lang: '芬兰语' - }, - { - cc: 'az', - code: 'az-Latn-AZ', - prefix: 'az', - en: 'Azerbaijani', - nation: '阿塞拜疆', - lang: '阿塞拜疆语' - }, - { - cc: 'az', - code: 'az-Cyrl-AZ', - prefix: 'az', - en: 'Azerbaijani', - nation: '阿塞拜疆', - lang: '阿塞拜疆语' - }, - { - cc: 'ba', - code: 'bs-BA', - prefix: 'bs', - en: 'Bosnian', - nation: '波黑', - lang: '波斯尼亚语' - }, - { - cc: 'ba', - code: 'hr-BA', - prefix: 'hr', - en: 'Croatian', - nation: '波黑', - lang: '克罗地亚语' - }, - { - cc: 'ba', - code: 'sr-BA', - prefix: 'sr', - en: 'Serbian', - nation: '波黑', - lang: '塞尔维亚语' - }, - { - cc: 'aw', - code: 'nl-NL', - prefix: 'nl', - en: 'Dutch', - nation: '阿鲁巴', - lang: '荷兰语' - }, - { - cc: 'bd', - code: 'bn-BD', - prefix: 'bn', - en: 'Bengali', - nation: '孟加拉', - lang: '孟加拉语' - }, - { - cc: 'au', - code: 'en-AU', - prefix: 'en', - en: 'English', - nation: '澳大利亚', - lang: '英语' - }, - { - cc: 'bf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '布基纳法索', - lang: '法语' - }, - { - cc: 'bg', - code: 'bg-BG', - prefix: 'bg', - en: 'Bulgarian', - nation: '保加利亚', - lang: '保加利亚语' - }, - { - cc: 'bh', - code: 'ar-BH', - prefix: 'ar', - en: 'Arabic', - nation: '巴林', - lang: '阿拉伯语' - }, - { - cc: 'bj', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '贝宁', - lang: '法语' - }, - { - cc: 'bi', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '布隆迪', - lang: '法语' - }, - { - cc: 'bi', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '布隆迪', - lang: '英语' - }, - { - cc: 'bm', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '百慕大', - lang: '英语' - }, - { - cc: 'bl', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '圣巴泰勒米岛', - lang: '法语' - }, - { - cc: 'bb', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '巴巴多斯', - lang: '英语' - }, - { - cc: 'bo', - code: 'quz-BO', - prefix: 'quz', - en: 'Quechua', - nation: '玻利维亚', - lang: '克丘亚语' - }, - { - cc: 'be', - code: 'fr-BE', - prefix: 'fr', - en: 'French', - nation: '比利时', - lang: '法语' - }, - { - cc: 'bq', - code: 'nl-NL', - prefix: 'nl', - en: 'Dutch', - nation: '荷兰加勒比区', - lang: '荷兰语' - }, - { - cc: 'br', - code: 'pt-BR', - prefix: 'pt', - en: 'Portuguese', - nation: '巴西', - lang: '葡萄牙语' - }, - { - cc: 'bs', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '巴哈马', - lang: '英语' - }, - { - cc: 'bt', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '不丹', - lang: '英语' - }, - { - cc: 'bv', - code: '', - prefix: '', - nation: '布韦岛', - lang: '' - }, - { - cc: 'bw', - code: 'tn-ZA', - prefix: 'tn', - en: 'Tswana', - nation: '博茨瓦纳', - lang: '茨瓦纳语' - }, - { - cc: 'by', - code: 'be-BY', - prefix: 'be', - en: 'Belarus', - nation: '白俄罗斯', - lang: '白俄罗斯语' - }, - { - cc: 'bz', - code: 'en-BZ', - prefix: 'en', - en: 'English', - nation: '伯利兹', - lang: '英语' - }, - { - cc: 'ca', - code: 'fr-CA', - prefix: 'fr', - en: 'French', - nation: '加拿大', - lang: '法语' - }, - { - cc: 'cc', - code: '', - prefix: '', - nation: '科科斯群岛', - lang: '' - }, - { - cc: 'bn', - code: 'ms-BN', - prefix: 'ms', - en: 'Malay', - nation: '文莱', - lang: '马来语' - }, - { - cc: 'cf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '中非', - lang: '法语' - }, - { - cc: 'cl', - code: 'arn-CL', - prefix: 'arn', - en: 'Mapdangan', - nation: '智利', - lang: '马普丹冈语' - }, - { - cc: 'cm', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '喀麦隆', - lang: '法语' - }, - { - cc: 'cm', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '喀麦隆', - lang: '英语' - }, - { - cc: 'co', - code: 'es-CO', - prefix: 'es', - en: 'Spanish', - nation: '哥伦比亚', - lang: '西班牙语' - }, - { - cc: 'cr', - code: 'es-CR', - prefix: 'es', - en: 'Spanish', - nation: '哥斯达黎加', - lang: '西班牙语' - }, - { - cc: 'cv', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '佛得角', - lang: '葡萄牙语' - }, - { - cc: 'cu', - code: 'es-ES', - prefix: 'es', - en: 'Spanish', - nation: '古巴', - lang: '西班牙语' - }, - { - cc: 'cx', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圣诞岛', - lang: '英语' - }, - { - cc: 'cy', - code: 'el-GR', - prefix: 'el', - en: 'Greek', - nation: '塞浦路斯', - lang: '希腊语' - }, - { - cc: 'cy', - code: 'tr-TR', - prefix: 'tr', - en: 'Turkish', - nation: '塞浦路斯', - lang: '土耳其语' - }, - { - cc: 'cz', - code: 'cs-CZ', - prefix: 'cs', - en: 'Czech', - nation: '捷克', - lang: '捷克语' - }, - { - cc: 'de', - code: 'de-DE', - prefix: 'de', - en: 'German', - nation: '德国', - lang: '德语' - }, - { - cc: 'dj', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '吉布提', - lang: '法语' - }, - { - cc: 'dj', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '吉布提', - lang: '阿拉伯语' - }, - { - cc: 'dk', - code: 'da-DK', - prefix: 'da', - en: 'Danish', - nation: '丹麦', - lang: '丹麦语' - }, - { - cc: 'dm', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '多米尼克', - lang: '英语' - }, - { - cc: 'do', - code: 'es-DO', - prefix: 'es', - en: 'Spanish', - nation: '多米尼加', - lang: '西班牙语' - }, - { - cc: 'dz', - code: 'ar-DZ', - prefix: 'ar', - en: 'Arabic', - nation: '阿尔及利亚', - lang: '阿拉伯语' - }, - { - cc: 'ec', - code: 'quz-EC', - prefix: 'quz', - en: 'Quechua', - nation: '厄瓜多尔', - lang: '克丘亚语' - }, - { - cc: 'ee', - code: 'et-EE', - prefix: 'et', - en: 'Estonian', - nation: '爱沙尼亚', - lang: '爱沙尼亚语' - }, - { - cc: 'eh', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '西撒哈拉', - lang: '阿拉伯语' - }, - { - cc: 'er', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '厄立特里亚', - lang: '阿拉伯语' - }, - { - cc: 'er', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '厄立特里亚', - lang: '英语' - }, - { - cc: 'es', - code: 'es-ES', - prefix: 'es', - en: 'Spanish', - nation: '西班牙', - lang: '西班牙语' - }, - { - cc: 'eg', - code: 'ar-EG', - prefix: 'ar', - en: 'Arabic', - nation: '埃及', - lang: '阿拉伯语' - }, - { - cc: 'fi', - code: 'se-FI', - prefix: 'se', - en: 'Northern Sami', - nation: '芬兰', - lang: '北萨米语' - }, - { - cc: 'fj', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '斐济群岛', - lang: '英语' - }, - { - cc: 'fk', - code: 'es-ES', - prefix: 'es', - en: 'Spanish', - nation: '马尔维纳斯群岛(福克兰)', - lang: '西班牙语' - }, - { - cc: 'fk', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '马尔维纳斯群岛(福克兰)', - lang: '英语' - }, - { - cc: 'ch', - code: 'de-CH', - prefix: 'de', - en: 'German', - nation: '瑞士', - lang: '德语' - }, - { - cc: 'fo', - code: 'fo-FO', - prefix: 'fo', - en: 'Faroese', - nation: '法罗群岛', - lang: '法罗语' - }, - { - cc: 'fr', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '法国', - lang: '法语' - }, - { - cc: 'fm', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '密克罗尼西亚联邦', - lang: '英语' - }, - { - cc: 'ga', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '加蓬', - lang: '法语' - }, - { - cc: 'gd', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '格林纳达', - lang: '英语' - }, - { - cc: 'ge', - code: 'ka-GE', - prefix: 'ka', - en: 'Georgian', - nation: '格鲁吉亚', - lang: '格鲁吉亚语' - }, - { - cc: 'gf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '法属圭亚那', - lang: '法语' - }, - { - cc: 'gh', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '加纳', - lang: '英语' - }, - { - cc: 'gi', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '直布罗陀', - lang: '英语' - }, - { - cc: 'gl', - code: 'kl-GL', - prefix: 'kl', - en: 'Greenland', - nation: '格陵兰', - lang: '格陵兰语' - }, - { - cc: 'gn', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '几内亚', - lang: '法语' - }, - { - cc: 'gp', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '瓜德罗普', - lang: '法语' - }, - { - cc: 'gq', - code: 'es-ES', - prefix: 'es', - en: 'Spanish', - nation: '赤道几内亚', - lang: '西班牙语' - }, - { - cc: 'gq', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '赤道几内亚', - lang: '法语' - }, - { - cc: 'gq', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '赤道几内亚', - lang: '葡萄牙语' - }, - { - cc: 'gr', - code: 'el-GR', - prefix: 'el', - en: 'Greek', - nation: '希腊', - lang: '希腊语' - }, - { - cc: 'gs', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '南乔治亚岛和南桑威奇群岛', - lang: '英语' - }, - { - cc: 'gt', - code: 'qut-GT', - prefix: 'qut', - en: 'Keeche', - nation: '危地马拉', - lang: '基切语' - }, - { - cc: 'gu', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '关岛', - lang: '英语' - }, - { - cc: 'gw', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '几内亚比绍', - lang: '葡萄牙语' - }, - { - cc: 'gy', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圭亚那', - lang: '英语' - }, - { - cc: 'hk', - code: 'zh-HK', - prefix: 'zh', - en: 'Chinese', - nation: '中国香港', - lang: '中文' - }, - { - cc: 'hm', - code: '', - prefix: '', - nation: '赫德岛和麦克唐纳群岛', - lang: '' - }, - { - cc: 'hn', - code: 'es-HN', - prefix: 'es', - en: 'Spanish', - nation: '洪都拉斯', - lang: '西班牙语' - }, - { - cc: 'hr', - code: 'hr-HR', - prefix: 'hr', - en: 'Croatian', - nation: '克罗地亚', - lang: '克罗地亚语' - }, - { - cc: 'hu', - code: 'hu-HU', - prefix: 'hu', - en: 'Hungarian', - nation: '匈牙利', - lang: '匈牙利语' - }, - { - cc: 'il', - code: 'he-IL', - prefix: 'he', - en: 'Hebrew', - nation: '以色列', - lang: '希伯来语' - }, - { - cc: 'ie', - code: 'ga-IE', - prefix: 'ga', - en: 'Irish', - nation: '爱尔兰', - lang: '爱尔兰语' - }, - { - cc: 'id', - code: 'id-ID', - prefix: 'id', - en: 'Indonesian', - nation: '印尼', - lang: '印度尼西亚语' - }, - { - cc: 'ht', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '海地', - lang: '法语' - }, - { - cc: 'im', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '马恩岛', - lang: '英语' - }, - { - cc: 'io', - code: 'en-GB', - prefix: 'en', - en: 'English', - nation: '英属印度洋领地', - lang: '英语' - }, - { - cc: 'in', - code: 'hi-IN', - prefix: 'hi', - en: 'Hindi', - nation: '印度', - lang: '印地语' - }, - { - cc: 'is', - code: 'is-IS', - prefix: 'is', - en: 'Island', - nation: '冰岛', - lang: '冰岛语' - }, - { - cc: 'ir', - code: 'fa-IR', - prefix: 'fa', - en: 'Persian', - nation: '伊朗', - lang: '波斯语' - }, - { - cc: 'iq', - code: 'ar-IQ', - prefix: 'ar', - en: 'Arabic', - nation: '伊拉克', - lang: '阿拉伯语' - }, - { - cc: 'it', - code: 'it-IT', - prefix: 'it', - en: 'Italian', - nation: '意大利', - lang: '意大利语' - }, - { - cc: 'je', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '泽西岛', - lang: '英语' - }, - { - cc: 'je', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '泽西岛', - lang: '法语' - }, - { - cc: 'jm', - code: 'en-JM', - prefix: 'en', - en: 'English', - nation: '牙买加', - lang: '英语' - }, - { - cc: 'jo', - code: 'ar-JO', - prefix: 'ar', - en: 'Arabic', - nation: '约旦', - lang: '阿拉伯语' - }, - { - cc: 'jp', - code: 'ja-JP', - prefix: 'ja', - en: 'Japanese', - nation: '日本', - lang: '日语' - }, - { - cc: 'kh', - code: 'km-KH', - prefix: 'km', - en: 'Khmer', - nation: '柬埔寨', - lang: '高棉语' - }, - { - cc: 'ki', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '基里巴斯', - lang: '英语' - }, - { - cc: 'km', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '科摩罗', - lang: '法语' - }, - { - cc: 'km', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '科摩罗', - lang: '阿拉伯语' - }, - { - cc: 'kw', - code: 'ar-KW', - prefix: 'ar', - en: 'Arabic Language', - nation: '科威特', - lang: '阿拉伯语' - }, - { - cc: 'ky', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '开曼群岛', - lang: '英语' - }, - { - cc: 'li', - code: 'de-LI', - prefix: 'de', - en: 'German', - nation: '列支敦士登', - lang: '德语' - }, - { - cc: 'lk', - code: 'si-LK', - prefix: 'si', - en: 'Sinhala', - nation: '斯里兰卡', - lang: '僧伽罗语' - }, - { - cc: 'lb', - code: 'ar-LB', - prefix: 'ar', - en: 'Arabic', - nation: '黎巴嫩', - lang: '阿拉伯语' - }, - { - cc: 'lt', - code: 'lt-LT', - prefix: 'lt', - en: 'Lithuanian', - nation: '立陶宛', - lang: '立陶宛语' - }, - { - cc: 'lu', - code: 'de-LU', - prefix: 'de', - en: 'German', - nation: '卢森堡', - lang: '德语' - }, - { - cc: 'lr', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '利比里亚', - lang: '英语' - }, - { - cc: 'lv', - code: 'lv-LV', - prefix: 'lv', - en: 'Latvian', - nation: '拉脱维亚', - lang: '拉脱维亚语' - }, - { - cc: 'ly', - code: 'ar-LY', - prefix: 'ar', - en: 'Arabic', - nation: '利比亚', - lang: '阿拉伯语' - }, - { - cc: 'ma', - code: 'ar-MA', - prefix: 'ar', - en: 'Arabic', - nation: '摩洛哥', - lang: '阿拉伯语' - }, - { - cc: 'mc', - code: 'fr-MC', - prefix: 'fr', - en: 'French', - nation: '摩纳哥', - lang: '法语' - }, - { - cc: 'md', - code: 'ro-RO', - prefix: 'ro', - en: 'Romanian', - nation: '摩尔多瓦', - lang: '罗马尼亚语' - }, - { - cc: 'me', - code: 'sr-Latn-ME', - prefix: 'sr', - en: 'Serbian', - nation: '黑山', - lang: '塞尔维亚语' - }, - { - cc: 'mf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '法属圣马丁', - lang: '法语' - }, - { - cc: 'mg', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '马达加斯加', - lang: '法语' - }, - { - cc: 'mh', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '马绍尔群岛', - lang: '英语' - }, - { - cc: 'ls', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '莱索托', - lang: '英语' - }, - { - cc: 'mk', - code: 'mk-MK', - prefix: 'mk', - en: 'Macedonian', - nation: '马其顿', - lang: '马其顿语' - }, - { - cc: 'mq', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '马提尼克', - lang: '法语' - }, - { - cc: 'mr', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '毛里塔尼亚', - lang: '阿拉伯语' - }, - { - cc: 'ms', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '蒙塞拉特岛', - lang: '英语' - }, - { - cc: 'mt', - code: 'mt-MT', - prefix: 'mt', - en: 'Maltese', - nation: '马耳他', - lang: '马耳他语' - }, - { - cc: 'mv', - code: 'dv-MV', - prefix: 'dv', - en: 'Dhivehi', - nation: '马尔代夫', - lang: '迪维希语' - }, - { - cc: 'mw', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '马拉维', - lang: '英语' - }, - { - cc: 'mx', - code: 'es-MX', - prefix: 'es', - en: 'Spanish', - nation: '墨西哥', - lang: '西班牙语' - }, - { - cc: 'ml', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '马里', - lang: '法语' - }, - { - cc: 'my', - code: 'ms-MY', - prefix: 'ms', - en: 'Malay', - nation: '马来西亚', - lang: '马来语' - }, - { - cc: 'na', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '纳米比亚', - lang: '英语' - }, - { - cc: 'ne', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '尼日尔', - lang: '法语' - }, - { - cc: 'nf', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '诺福克岛', - lang: '英语' - }, - { - cc: 'ng', - code: 'ha-Latn-NG', - prefix: 'ha', - en: 'Hausa', - nation: '尼日利亚', - lang: '豪撒语' - }, - { - cc: 'ni', - code: 'es-NI', - prefix: 'es', - en: 'Spain', - nation: '尼加拉瓜', - lang: '西班牙语' - }, - { - cc: 'nl', - code: 'fy-NL', - prefix: 'fy', - en: 'Frisian', - nation: '荷兰', - lang: '弗里西亚语' - }, - { - cc: 'no', - code: 'se-NO', - prefix: 'se', - en: 'Northern Sami', - nation: '挪威', - lang: '北萨米语' - }, - { - cc: 'np', - code: 'ne-NP', - prefix: 'ne', - en: 'Nepal', - nation: '尼泊尔', - lang: '尼泊尔语' - }, - { - cc: 'nr', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '瑙鲁', - lang: '英语' - }, - { - cc: 'om', - code: 'ar-OM', - prefix: 'ar', - en: 'Arabic', - nation: '阿曼', - lang: '阿拉伯语' - }, - { - cc: 'pa', - code: 'es-PA', - prefix: 'es', - en: 'Spanish', - nation: '巴拿马', - lang: '西班牙语' - }, - { - cc: 'pe', - code: 'quz-PE', - prefix: 'quz', - en: 'Quechua', - nation: '秘鲁', - lang: '克丘亚语' - }, - { - cc: 'mm', - code: '', - prefix: '', - nation: '缅甸', - lang: '' - }, - { - cc: 'pf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '法属波利尼西亚', - lang: '法语' - }, - { - cc: 'mo', - code: 'zh-MO', - prefix: 'zh', - en: 'Chinese', - nation: '中国澳门', - lang: '中文' - }, - { - cc: 'ph', - code: 'fil-PH', - prefix: 'fil', - en: 'Philippine', - nation: '菲律宾', - lang: '菲律宾语' - }, - { - cc: 'pg', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '巴布亚新几内亚', - lang: '英语' - }, - { - cc: 'pl', - code: 'pl-PL', - prefix: 'pl', - en: 'Polish', - nation: '波兰', - lang: '波兰语' - }, - { - cc: 'pk', - code: 'ur-PK', - prefix: 'ur', - en: 'Urdu', - nation: '巴基斯坦', - lang: '乌尔都语' - }, - { - cc: 'ps', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '巴勒斯坦', - lang: '阿拉伯语' - }, - { - cc: 'pr', - code: 'es-PR', - prefix: 'es', - en: 'Spain Language', - nation: '波多黎各', - lang: '西班牙语' - }, - { - cc: 'pn', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '皮特凯恩群岛', - lang: '英语' - }, - { - cc: 'pw', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '帕劳', - lang: '英语' - }, - { - cc: 'qa', - code: 'ar-QA', - prefix: 'ar', - en: 'Arabic', - nation: '卡塔尔', - lang: '阿拉伯语' - }, - { - cc: 'ro', - code: 'ro-RO', - prefix: 'ro', - en: 'Romanian', - nation: '罗马尼亚', - lang: '罗马尼亚语' - }, - { - cc: 're', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '留尼汪', - lang: '法语' - }, - { - cc: 'rs', - code: 'sr-Latn-RS', - prefix: 'sr', - en: 'Serbian', - nation: '塞尔维亚', - lang: '塞尔维亚语' - }, - { - cc: 'py', - code: 'es-PY', - prefix: 'es', - en: 'Spanish', - nation: '巴拉圭', - lang: '西班牙语' - }, - { - cc: 'ru', - code: 'ru-RU', - prefix: 'ru', - en: 'Russian', - nation: '俄罗斯', - lang: '俄语' - }, - { - cc: 'sb', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '所罗门群岛', - lang: '英语' - }, - { - cc: 'se', - code: 'se-SE', - prefix: 'se', - en: 'Northern Sami', - nation: '瑞典', - lang: '北萨米语' - }, - { - cc: 'sc', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '塞舌尔', - lang: '法语' - }, - { - cc: 'sc', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '塞舌尔', - lang: '英语' - }, - { - cc: 'sg', - code: 'en-SG', - prefix: 'en', - en: 'English', - nation: '新加坡', - lang: '英语' - }, - { - cc: 'sd', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '苏丹', - lang: '阿拉伯语' - }, - { - cc: 'sd', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '苏丹', - lang: '英语' - }, - { - cc: 'si', - code: 'sl-SI', - prefix: 'sl', - en: 'Slovenian', - nation: '斯洛文尼亚', - lang: '斯洛文尼亚语' - }, - { - cc: 'sj', - code: 'nn-no', - prefix: 'no', - en: 'Norwegian', - nation: '斯瓦尔巴群岛和扬马延岛', - lang: '挪威语' - }, - { - cc: 'sk', - code: 'sk-SK', - prefix: 'sk', - en: 'Slovak', - nation: '斯洛伐克', - lang: '斯洛伐克语' - }, - { - cc: 'sl', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '塞拉利昂', - lang: '英语' - }, - { - cc: 'sm', - code: 'it-IT', - prefix: 'it', - en: 'Italian', - nation: '圣马力诺', - lang: '意大利语' - }, - { - cc: 'sn', - code: 'wo-SN', - prefix: 'wo', - en: 'Wolof', - nation: '塞内加尔', - lang: '沃洛夫语' - }, - { - cc: 'so', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '索马里', - lang: '阿拉伯语' - }, - { - cc: 'sr', - code: 'nl-NL', - prefix: 'nl', - en: 'Dutch', - nation: '苏里南', - lang: '荷兰语' - }, - { - cc: 'ss', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '南苏丹', - lang: '英语' - }, - { - cc: 'st', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圣多美和普林西比', - lang: '英语' - }, - { - cc: 'sv', - code: 'es-SV', - prefix: 'es', - en: 'Spanish', - nation: '萨尔瓦多', - lang: '西班牙语' - }, - { - cc: 'sy', - code: 'ar-SY', - prefix: 'ar', - en: 'Arabic', - nation: '叙利亚', - lang: '阿拉伯语' - }, - { - cc: 'sz', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '斯威士兰', - lang: '英语' - }, - { - cc: 'tc', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '特克斯和凯科斯群岛', - lang: '英语' - }, - { - cc: 'tg', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '多哥', - lang: '法语' - }, - { - cc: 'td', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '乍得', - lang: '法语' - }, - { - cc: 'td', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '乍得', - lang: '阿拉伯语' - }, - { - cc: 'th', - code: 'th-TH', - prefix: 'ta', - en: 'Thai', - nation: '泰国', - lang: '泰语' - }, - { - cc: 'tk', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '托克劳', - lang: '英语' - }, - { - cc: 'tl', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '东帝汶', - lang: '葡萄牙语' - }, - { - cc: 'tn', - code: 'ar-TN', - prefix: 'ar', - en: 'Arabic', - nation: '突尼斯', - lang: '阿拉伯语' - }, - { - cc: 'tr', - code: 'tr-TR', - prefix: 'tr', - en: 'Turkish', - nation: '土耳其', - lang: '土耳其语' - }, - { - cc: 'to', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '汤加', - lang: '英语' - }, - { - cc: 'tz', - code: 'sw-KE', - prefix: 'sw', - en: 'Swah Greek', - nation: '坦桑尼亚', - lang: '斯瓦希里语' - }, - { - cc: 'tz', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '坦桑尼亚', - lang: '英语' - }, - { - cc: 'ua', - code: 'uk-UA', - prefix: 'uk', - en: 'Ukrainian', - nation: '乌克兰', - lang: '乌克兰语' - }, - { - cc: 'uy', - code: 'es-UY', - prefix: 'es', - en: 'Spanish', - nation: '乌拉圭', - lang: '西班牙语' - }, - { - cc: 'ug', - code: 'sw-KE', - prefix: 'sw', - en: 'Swah Greek', - nation: '乌干达', - lang: '斯瓦希里语' - }, - { - cc: 'ug', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '乌干达', - lang: '英语' - }, - { - cc: 'us', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '美国', - lang: '英语' - }, - { - cc: 'va', - code: 'it-IT', - prefix: 'it', - en: 'Italian', - nation: '梵蒂冈', - lang: '意大利语' - }, - { - cc: 'vi', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '美属维尔京群岛', - lang: '英语' - }, - { - cc: 've', - code: 'es-VE', - prefix: 'es', - en: 'Spanish', - nation: '委内瑞拉', - lang: '西班牙语' - }, - { - cc: 'ws', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '萨摩亚', - lang: '英语' - }, - { - cc: 'vn', - code: 'vi-VN', - prefix: 'vi', - en: 'Vietnamese', - nation: '越南', - lang: '越南语' - }, - { - cc: 'vg', - code: 'en-GB', - prefix: 'en', - en: 'English', - nation: '英属维尔京群岛', - lang: '英语' - }, - { - cc: 'yt', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '马约特', - lang: '法语' - }, - { - cc: 'ye', - code: 'ar-YE', - prefix: 'ar', - en: 'Arabic', - nation: '也门', - lang: '阿拉伯语' - }, - { - cc: 'wf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '瓦利斯和富图纳', - lang: '法语' - }, - { - cc: 'zw', - code: 'en-ZW', - prefix: 'en', - en: 'English', - nation: '津巴布韦', - lang: '英语' - }, - { - cc: 'za', - code: 'nso-ZA', - prefix: 'nso', - en: 'Basotho', - nation: '南非', - lang: '巴索托语' - }, - { - cc: 'zm', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '赞比亚', - lang: '英语' - }, - { - cc: 'cn', - code: 'zh-CN', - prefix: 'zh', - en: 'Chinese', - nation: '中国内地', - lang: '中文' - }, - { - cc: 'cd', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '刚果(金)', - lang: '法语' - }, - { - cc: 'mz', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '莫桑比克', - lang: '葡萄牙语' - }, - { - cc: 'cg', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '刚果(布)', - lang: '法语' - }, - { - cc: 'gg', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '根西岛', - lang: '英语' - }, - { - cc: 'gm', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '冈比亚', - lang: '英语' - }, - { - cc: 'mp', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '北马里亚纳群岛', - lang: '英语' - }, - { - cc: 'et', - code: 'am-ET', - prefix: 'am', - en: 'Amharic', - nation: '埃塞俄比亚', - lang: '阿姆哈拉语' - }, - { - cc: 'tf', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '法属南部领地', - lang: '法语' - }, - { - cc: 'vu', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '瓦努阿图', - lang: '法语' - }, - { - cc: 'vu', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '瓦努阿图', - lang: '英语' - }, - { - cc: 'nc', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '新喀里多尼亚', - lang: '法语' - }, - { - cc: 'um', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '美国本土外小岛屿', - lang: '英语' - }, - { - cc: 'nu', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '纽埃', - lang: '英语' - }, - { - cc: 'tt', - code: 'en-TT', - prefix: 'en', - en: 'English', - nation: '特立尼达和多巴哥', - lang: '英语' - }, - { - cc: 'vc', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圣文森特和格林纳丁斯', - lang: '英语' - }, - { - cc: 'ck', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '库克群岛', - lang: '英语' - }, - { - cc: 'uk', - code: 'en-GB', - prefix: 'en', - en: 'English', - nation: '英国', - lang: '英语' - }, - { - cc: 'tv', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '图瓦卢', - lang: '英语' - }, - { - cc: 'tw', - code: 'zh-TW', - prefix: 'zh', - en: 'Chinese (Traditional)', - nation: '中国台湾', - lang: '中文(繁体)' - }, - { - cc: 'nz', - code: 'en-NZ', - prefix: 'en', - en: 'English', - nation: '新西兰', - lang: '英语' - }, - { - cc: 'nz', - code: 'mi-NZ', - prefix: 'mi', - en: 'Maori', - nation: '新西兰', - lang: '毛利语' - }, - { - cc: 'sa', - code: 'ar-SA', - prefix: 'ar', - en: 'Arabic', - nation: '沙特阿拉伯', - lang: '阿拉伯语' - }, - { - cc: 'kr', - code: 'ko-KR', - prefix: 'ko', - en: 'North Korea', - nation: '韩国', - lang: '朝鲜语' - }, - { - cc: 'kp', - code: 'ko-KR', - prefix: 'ko', - en: 'North Korea', - nation: '朝鲜', - lang: '朝鲜语' - }, - { - cc: 'la', - code: 'lo-LA', - prefix: 'lo', - en: 'Lao', - nation: '老挝', - lang: '老挝语' - }, - { - cc: 'kg', - code: 'ky-KG', - prefix: 'ky', - en: 'Kyrgyz', - nation: '吉尔吉斯斯坦', - lang: '吉尔吉斯语' - }, - { - cc: 'pt', - code: 'pt-PT', - prefix: 'pt', - en: 'Portuguese', - nation: '葡萄牙', - lang: '葡萄牙语' - }, - { - cc: 'kz', - code: 'kk-KZ', - prefix: 'kk', - en: 'Kazakh', - nation: '哈萨克斯坦', - lang: '哈萨克语' - }, - { - cc: 'tj', - code: 'tg-Cyrl-TJ', - prefix: 'tg', - en: 'Tajik Language', - nation: '塔吉克斯坦', - lang: '塔吉克语' - }, - { - cc: 'tm', - code: 'tk-TM', - prefix: 'tk', - en: 'Turkmen', - nation: '土库曼斯坦', - lang: '土库曼语' - }, - { - cc: 'uz', - code: 'uz-Latn-UZ', - prefix: 'uz', - en: 'Uzbek', - nation: '乌兹别克斯坦', - lang: '乌兹别克语' - }, - { - cc: 'sh', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圣赫勒拿', - lang: '英语' - }, - { - cc: 'pm', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '圣皮埃尔和密克隆', - lang: '法语' - }, - { - cc: 'kn', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圣基茨和尼维斯', - lang: '英语' - }, - { - cc: 'lc', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '圣卢西亚', - lang: '英语' - }, - { - cc: 'lc', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '圣卢西亚', - lang: '法语' - }, - { - cc: 'ci', - code: 'fr-FR', - prefix: 'fr', - en: 'French', - nation: '科特迪瓦', - lang: '法语' - }, - { - cc: 'mu', - code: 'en-US', - prefix: 'en', - en: 'English', - nation: '毛里求斯', - lang: '英语' - }, - { - cc: 'ke', - code: 'sw-KE', - prefix: 'sw', - en: 'Swah Greek', - nation: '肯尼亚', - lang: '斯瓦希里语' - }, - { - cc: 'mn', - code: 'mn-Mong', - prefix: 'mn', - en: 'Mongolian', - nation: '蒙古国蒙古', - lang: '蒙古语' - }, - { - cc: 'rw', - code: 'rw-RW', - prefix: 'rw', - en: 'Rwanda', - nation: '卢旺达', - lang: '卢旺达语' - } -]; - -module.exports = language; \ No newline at end of file diff --git a/localAPI-main/py-examples/example-check-profile-status.py b/localAPI-main/py-examples/example-check-profile-status.py deleted file mode 100644 index 3df51af..0000000 --- a/localAPI-main/py-examples/example-check-profile-status.py +++ /dev/null @@ -1,8 +0,0 @@ -import requests - -profileId = 'XX' -url = "http://localhost:50325/api/v1/browser/active?user_id=" + profileId - -response = requests.request("GET", url, headers={}, data={}) - -print(response.text) diff --git a/localAPI-main/py-examples/example-create-group.py b/localAPI-main/py-examples/example-create-group.py deleted file mode 100644 index 9a19ffb..0000000 --- a/localAPI-main/py-examples/example-create-group.py +++ /dev/null @@ -1,15 +0,0 @@ -import json -import requests - -url = "http://local.adspower.net:50325/api/v1/group/create" - -payload = { - "group_name": "your_group_name" -} -headers = { - 'Content-Type': 'application/json' -} - -response = requests.request("POST", url, headers=headers, json=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-create-profile.py b/localAPI-main/py-examples/example-create-profile.py deleted file mode 100644 index 6fdd744..0000000 --- a/localAPI-main/py-examples/example-create-profile.py +++ /dev/null @@ -1,41 +0,0 @@ -import requests - -url = "http://local.adspower.net:50325/api/v1/user/create" - -payload = { - "name": "test", - "group_id": "0", - "domain_name": "facebook.com", - "repeat_config": [ - "0" - ], - "country": "us", - "fingerprint_config": { - "language": [ - "en-US" - ], - "ua": "Mozilla/5.0 (Linux; Android 8.0.0; BND-AL10 Build/HONORBND-AL10; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/63.0.3239.83 Mobile Safari/537.36 T7/11.5 baiduboxapp/11.5.0.10 (Baidu; P1 8.0.0)", - "flash": "block", - "scan_port_type": "1", - "screen_resolution": "1024_600", - "fonts": [ - "all" - ], - "longitude": "180", - "latitude": "90", - "webrtc": "proxy", - "do_not_track": "true", - "hardware_concurrency": "default", - "device_memory": "default" - }, - "user_proxy_config": { - "proxy_soft": "no_proxy" - } -} -headers = { - 'Content-Type': 'application/json' -} - -response = requests.request("POST", url, headers=headers, json=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-delete-profile-cache.py b/localAPI-main/py-examples/example-delete-profile-cache.py deleted file mode 100644 index 045ca67..0000000 --- a/localAPI-main/py-examples/example-delete-profile-cache.py +++ /dev/null @@ -1,10 +0,0 @@ -import requests - -url = "http://localhost:50325/api/v1/user/delete-cache" - -payload={} -headers = {} - -response = requests.request("POST", url, headers=headers, json=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-delete-profile.py b/localAPI-main/py-examples/example-delete-profile.py deleted file mode 100644 index 294d916..0000000 --- a/localAPI-main/py-examples/example-delete-profile.py +++ /dev/null @@ -1,16 +0,0 @@ -import requests - -url = "http://localhost:50325/api/v1/user/delete" - -payload = { - "user_ids": [ - "XX" - ] -} -headers = { - 'Content-Type': 'application/json' -} - -response = requests.request("POST", url, headers=headers, json=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-query-group.py b/localAPI-main/py-examples/example-query-group.py deleted file mode 100644 index 0f7acc7..0000000 --- a/localAPI-main/py-examples/example-query-group.py +++ /dev/null @@ -1,10 +0,0 @@ -import requests - -url = "http://local.adspower.net:50325/api/v1/group/list?page=1&page_size=15" - -payload={} -headers = {} - -response = requests.request("GET", url, headers=headers, data=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-query-profile.py b/localAPI-main/py-examples/example-query-profile.py deleted file mode 100644 index 814551e..0000000 --- a/localAPI-main/py-examples/example-query-profile.py +++ /dev/null @@ -1,10 +0,0 @@ -import requests - -url = "http://local.adspower.net:50325/api/v1/user/list?page=1&page_size=100" - -payload={} -headers = {} - -response = requests.request("GET", url, headers=headers, data=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-regroup-profile.py b/localAPI-main/py-examples/example-regroup-profile.py deleted file mode 100644 index 0d5e1d9..0000000 --- a/localAPI-main/py-examples/example-regroup-profile.py +++ /dev/null @@ -1,17 +0,0 @@ -import requests - -url = "http://local.adspower.net:50325/api/v1/user/regroup" - -payload = { - "user_ids": [ - "XX" - ], - "group_id": "0" -} -headers = { - 'Content-Type': 'application/json' -} - -response = requests.request("POST", url, headers=headers, json=payload) - -print(response.text) diff --git a/localAPI-main/py-examples/example-start-profile.py b/localAPI-main/py-examples/example-start-profile.py deleted file mode 100644 index 57c57e0..0000000 --- a/localAPI-main/py-examples/example-start-profile.py +++ /dev/null @@ -1,27 +0,0 @@ -# The sample passed the test in selenium version 3.141.0 - -import requests,time -from selenium import webdriver -from selenium.webdriver.chrome.options import Options -import sys - -ads_id = "XXX" -# http://local.adspower.net:50325 Script can go to Profile Management-> click Settings-> click Cache folder-> local_api file to obtain API address -open_url = "http://local.adspower.net:50325/api/v1/browser/start?user_id=" + ads_id -close_url = "http://local.adspower.net:50325/api/v1/browser/stop?user_id=" + ads_id - -resp = requests.get(open_url).json() -if resp["code"] != 0: - print(resp["msg"]) - print("please check ads_id") - sys.exit() - -chrome_driver = resp["data"]["webdriver"] -chrome_options = Options() -chrome_options.add_experimental_option("debuggerAddress", resp["data"]["ws"]["selenium"]) -driver = webdriver.Chrome(chrome_driver, options=chrome_options) -print(driver.title) -driver.get("https://www.adspower.com") -time.sleep(5) -driver.quit() -requests.get(close_url) \ No newline at end of file diff --git a/localAPI-main/py-examples/example-stop-profile.py b/localAPI-main/py-examples/example-stop-profile.py deleted file mode 100644 index 3034b02..0000000 --- a/localAPI-main/py-examples/example-stop-profile.py +++ /dev/null @@ -1,8 +0,0 @@ -import requests - -profildId = 'XX' -url = "http://local.adspower.net:50325/api/v1/browser/stop?user_id=" + profildId - -response = requests.request("GET", url, headers={}, data={}) - -print(response.text) diff --git a/localAPI-main/py-examples/example-update-profile.py b/localAPI-main/py-examples/example-update-profile.py deleted file mode 100644 index 90fa759..0000000 --- a/localAPI-main/py-examples/example-update-profile.py +++ /dev/null @@ -1,34 +0,0 @@ -import requests - -url = "http://local.adspower.net:50325/api/v1/user/update" - -payload = { - "user_id": "XX", - "name": "test", - "domain_name": "facebook.com", - "repeat_config": [ - "0" - ], - "open_urls": [ - "http://www.baidu.com", - "https://www.google.com" - ], - "country": "us", - "remark": "remark", - "fingerprint_config": { - "webrtc": "proxy", - "do_not_track": "true", - "hardware_concurrency": "default", - "device_memory": "default" - }, - "user_proxy_config": { - "proxy_soft": "no_proxy" - } -} -headers = { - 'Content-Type': 'application/json' -} - -response = requests.request("POST", url, headers=headers, json=payload) - -print(response.text) diff --git a/localAPI-main/timezone.js b/localAPI-main/timezone.js deleted file mode 100644 index d290222..0000000 --- a/localAPI-main/timezone.js +++ /dev/null @@ -1,1916 +0,0 @@ -const timezone = [ - { - tz: 'GMT-09:00', - gmt: 'America/Metlakatla' - }, - { - tz: 'GMT-12:00', - gmt: 'Etc/GMT+12' - }, - { - tz: 'GMT-11:00', - gmt: 'Etc/GMT+11' - }, - { - tz: 'GMT-11:00', - gmt: 'Pacific/Midway' - }, - { - tz: 'GMT-11:00', - gmt: 'Pacific/Niue' - }, - { - tz: 'GMT-11:00', - gmt: 'Pacific/Pago Pago' - }, - { - tz: 'GMT-10:00', - gmt: 'America/Adak' - }, - { - tz: 'GMT-10:00', - gmt: 'Etc/GMT+10' - }, - { - tz: 'GMT-10:00', - gmt: 'HST' - }, - { - tz: 'GMT-10:00', - gmt: 'Pacific/Honolulu' - }, - { - tz: 'GMT-10:00', - gmt: 'Pacific/Rarotonga' - }, - { - tz: 'GMT-10:00', - gmt: 'Pacific/Tahiti' - }, - { - tz: 'GMT-09:30', - gmt: 'Pacific/Marquesas' - }, - { - tz: 'GMT-09:00', - gmt: 'America/Anchorage' - }, - { - tz: 'GMT-09:00', - gmt: 'America/Juneau' - }, - { - tz: 'GMT-09:00', - gmt: 'America/Nome' - }, - { - tz: 'GMT-09:00', - gmt: 'America/Sitka' - }, - { - tz: 'GMT-09:00', - gmt: 'America/Yakutat' - }, - { - tz: 'GMT-09:00', - gmt: 'Etc/GMT+9' - }, - { - tz: 'GMT-09:00', - gmt: 'Pacific/Gambier' - }, - { - tz: 'GMT-08:00', - gmt: 'America/Los Angeles' - }, - { - tz: 'GMT-08:00', - gmt: 'America/Tijuana' - }, - { - tz: 'GMT-08:00', - gmt: 'America/Vancouver' - }, - { - tz: 'GMT-08:00', - gmt: 'Etc/GMT+8' - }, - { - tz: 'GMT-08:00', - gmt: 'PST8PDT' - }, - { - tz: 'GMT-08:00', - gmt: 'Pacific/Pitcairn' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Boise' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Cambridge Bay' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Chihuahua' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Creston' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Dawson' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Dawson Creek' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Denver' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Edmonton' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Fort Nelson' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Hermosillo' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Inuvik' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Mazatlan' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Ojinaga' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Phoenix' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Whitehorse' - }, - { - tz: 'GMT-07:00', - gmt: 'America/Yellowknife' - }, - { - tz: 'GMT-07:00', - gmt: 'Etc/GMT+7' - }, - { - tz: 'GMT-07:00', - gmt: 'MST' - }, - { - tz: 'GMT-07:00', - gmt: 'MST7MDT' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Bahia Banderas' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Belize' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Chicago' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Costa Rica' - }, - { - tz: 'GMT-06:00', - gmt: 'America/El Salvador' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Guatemala' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Indiana/Knox' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Indiana/Tell City' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Managua' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Matamoros' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Menominee' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Merida' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Mexico City' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Monterrey' - }, - { - tz: 'GMT-06:00', - gmt: 'America/North Dakota/Beulah' - }, - { - tz: 'GMT-06:00', - gmt: 'America/North Dakota/Center' - }, - { - tz: 'GMT-06:00', - gmt: 'America/North Dakota/New_Salem' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Rainy River' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Rankin Inlet' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Regina' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Resolute' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Swift Current' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Tegucigalpa' - }, - { - tz: 'GMT-06:00', - gmt: 'America/Winnipeg' - }, - { - tz: 'GMT-06:00', - gmt: 'CST6CDT' - }, - { - tz: 'GMT-06:00', - gmt: 'Etc/GMT+6' - }, - { - tz: 'GMT-06:00', - gmt: 'Pacific/Galapagos' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Atikokan' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Bogota' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Cancun' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Cayman' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Detroit' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Eirunepe' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Grand Turk' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Guayaquil' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Havana' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indiana/Indianapolis' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indiana/Marengo' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indiana/Petersburg' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indiana/Vevay' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indiana/Vincennes' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indiana/Winamac' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Indianapolis' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Iqaluit' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Jamaica' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Kentucky/Louisville' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Kentucky/Monticello' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Lima' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Montreal' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Nassau' - }, - { - tz: 'GMT-05:00', - gmt: 'America/New York' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Nipigon' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Panama' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Pangnirtung' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Port-au-Prince' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Rio Branco' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Thunder Bay' - }, - { - tz: 'GMT-05:00', - gmt: 'America/Toronto' - }, - { - tz: 'GMT-05:00', - gmt: 'EST' - }, - { - tz: 'GMT-05:00', - gmt: 'EST5EDT' - }, - { - tz: 'GMT-05:00', - gmt: 'Etc/GMT+5' - }, - { - tz: 'GMT-05:00', - gmt: 'Pacific/Easter' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Anguilla' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Antigua' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Aruba' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Barbados' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Blanc-Sablon' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Boa Vista' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Campo Grande' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Caracas' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Cuiaba' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Curacao' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Dominica' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Glace Bay' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Goose Bay' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Grenada' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Guadeloupe' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Guyana' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Halifax' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Kralendijk' - }, - { - tz: 'GMT-04:00', - gmt: 'America/La Paz' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Lower Princes' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Manaus' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Marigot' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Martinique' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Moncton' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Montserrat' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Port of_Spain' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Porto Velho' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Puerto Rico' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Santo Domingo' - }, - { - tz: 'GMT-04:00', - gmt: 'America/St Barthelemy' - }, - { - tz: 'GMT-04:00', - gmt: 'America/St Kitts' - }, - { - tz: 'GMT-04:00', - gmt: 'America/St Lucia' - }, - { - tz: 'GMT-04:00', - gmt: 'America/St Thomas' - }, - { - tz: 'GMT-04:00', - gmt: 'America/St Vincent' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Thule' - }, - { - tz: 'GMT-04:00', - gmt: 'America/Tortola' - }, - { - tz: 'GMT-04:00', - gmt: 'Atlantic/Bermuda' - }, - { - tz: 'GMT-04:00', - gmt: 'Etc/GMT+4' - }, - { - tz: 'GMT-03:30', - gmt: 'America/St Johns' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Araguaina' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Buenos Aires' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Catamarca' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Cordoba' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Jujuy' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/La Rioja' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Mendoza' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Rio Gallegos' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Salta' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/San Juan' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/San Luis' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Tucuman' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Argentina/Ushuaia' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Asuncion' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Bahia' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Belem' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Cayenne' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Fortaleza' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Godthab' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Maceio' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Miquelon' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Montevideo' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Nuuk' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Paramaribo' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Punta Arenas' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Recife' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Santarem' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Santiago' - }, - { - tz: 'GMT-03:00', - gmt: 'America/Sao Paulo' - }, - { - tz: 'GMT-03:00', - gmt: 'Antarctica/Palmer' - }, - { - tz: 'GMT-03:00', - gmt: 'Antarctica/Rothera' - }, - { - tz: 'GMT-03:00', - gmt: 'Atlantic/Stanley' - }, - { - tz: 'GMT-03:00', - gmt: 'Etc/GMT+3' - }, - { - tz: 'GMT-02:00', - gmt: 'America/Noronha' - }, - { - tz: 'GMT-02:00', - gmt: 'Atlantic/South Georgia' - }, - { - tz: 'GMT-02:00', - gmt: 'Etc/GMT+2' - }, - { - tz: 'GMT-01:00', - gmt: 'America/Scoresbysund' - }, - { - tz: 'GMT-01:00', - gmt: 'Atlantic/Azores' - }, - { - tz: 'GMT-01:00', - gmt: 'Atlantic/Cape Verde' - }, - { - tz: 'GMT-01:00', - gmt: 'Etc/GMT+1' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Abidjan' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Accra' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Bamako' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Banjul' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Bissau' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Conakry' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Dakar' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Freetown' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Lome' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Monrovia' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Nouakchott' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Ouagadougou' - }, - { - tz: 'GMT+00:00', - gmt: 'Africa/Sao Tome' - }, - { - tz: 'GMT+00:00', - gmt: 'America/Danmarkshavn' - }, - { - tz: 'GMT+00:00', - gmt: 'Antarctica/Troll' - }, - { - tz: 'GMT+00:00', - gmt: 'Atlantic/Canary' - }, - { - tz: 'GMT+00:00', - gmt: 'Atlantic/Faroe' - }, - { - tz: 'GMT+00:00', - gmt: 'Atlantic/Madeira' - }, - { - tz: 'GMT+00:00', - gmt: 'Atlantic/Reykjavik' - }, - { - tz: 'GMT+00:00', - gmt: 'Atlantic/St Helena' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/GMT' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/GMT+0' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/GMT-0' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/GMT0' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/Greenwich' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/Universal' - }, - { - tz: 'GMT+00:00', - gmt: 'Etc/Zulu' - }, - { - tz: 'GMT+00:00', - gmt: 'Europe/Dublin' - }, - { - tz: 'GMT+00:00', - gmt: 'Europe/Guernsey' - }, - { - tz: 'GMT+00:00', - gmt: 'Europe/Isle of_Man' - }, - { - tz: 'GMT+00:00', - gmt: 'Europe/Jersey' - }, - { - tz: 'GMT+00:00', - gmt: 'Europe/Lisbon' - }, - { - tz: 'GMT+00:00', - gmt: 'Europe/London' - }, - { - tz: 'GMT+00:00', - gmt: 'GMT' - }, - { - tz: 'GMT+00:00', - gmt: 'UTC' - }, - { - tz: 'GMT+00:00', - gmt: 'WET' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Algiers' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Bangui' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Brazzaville' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Casablanca' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Ceuta' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Douala' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/El Aaiun' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Kinshasa' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Lagos' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Libreville' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Luanda' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Malabo' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Ndjamena' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Niamey' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Porto-Novo' - }, - { - tz: 'GMT+01:00', - gmt: 'Africa/Tunis' - }, - { - tz: 'GMT+01:00', - gmt: 'Arctic/Longyearbyen' - }, - { - tz: 'GMT+01:00', - gmt: 'CET' - }, - { - tz: 'GMT+01:00', - gmt: 'Etc/GMT-1' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Amsterdam' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Andorra' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Belgrade' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Berlin' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Bratislava' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Brussels' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Budapest' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Busingen' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Copenhagen' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Gibraltar' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Ljubljana' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Luxembourg' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Madrid' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Malta' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Monaco' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Oslo' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Paris' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Podgorica' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Prague' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Rome' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/San Marino' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Sarajevo' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Skopje' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Stockholm' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Tirane' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Vaduz' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Vatican' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Vienna' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Warsaw' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Zagreb' - }, - { - tz: 'GMT+01:00', - gmt: 'Europe/Zurich' - }, - { - tz: 'GMT+01:00', - gmt: 'MET' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Blantyre' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Bujumbura' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Cairo' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Gaborone' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Harare' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Johannesburg' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Khartoum' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Kigali' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Lubumbashi' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Lusaka' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Maputo' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Maseru' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Mbabane' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Tripoli' - }, - { - tz: 'GMT+02:00', - gmt: 'Africa/Windhoek' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Amman' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Beirut' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Damascus' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Famagusta' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Gaza' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Hebron' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Jerusalem' - }, - { - tz: 'GMT+02:00', - gmt: 'Asia/Nicosia' - }, - { - tz: 'GMT+02:00', - gmt: 'EET' - }, - { - tz: 'GMT+02:00', - gmt: 'Etc/GMT-2' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Athens' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Bucharest' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Chisinau' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Helsinki' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Kaliningrad' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Kiev' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Mariehamn' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Nicosia' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Riga' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Sofia' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Tallinn' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Uzhgorod' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Vilnius' - }, - { - tz: 'GMT+02:00', - gmt: 'Europe/Zaporozhye' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Addis Ababa' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Asmara' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Dar es_Salaam' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Djibouti' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Juba' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Kampala' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Mogadishu' - }, - { - tz: 'GMT+03:00', - gmt: 'Africa/Nairobi' - }, - { - tz: 'GMT+03:00', - gmt: 'Antarctica/Syowa' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Aden' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Baghdad' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Bahrain' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Istanbul' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Kuwait' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Qatar' - }, - { - tz: 'GMT+03:00', - gmt: 'Asia/Riyadh' - }, - { - tz: 'GMT+03:00', - gmt: 'Etc/GMT-3' - }, - { - tz: 'GMT+03:00', - gmt: 'Europe/Istanbul' - }, - { - tz: 'GMT+03:00', - gmt: 'Europe/Kirov' - }, - { - tz: 'GMT+03:00', - gmt: 'Europe/Minsk' - }, - { - tz: 'GMT+03:00', - gmt: 'Europe/Moscow' - }, - { - tz: 'GMT+03:00', - gmt: 'Europe/Simferopol' - }, - { - tz: 'GMT+03:00', - gmt: 'Indian/Antananarivo' - }, - { - tz: 'GMT+03:00', - gmt: 'Indian/Comoro' - }, - { - tz: 'GMT+03:00', - gmt: 'Indian/Mayotte' - }, - { - tz: 'GMT+03:30', - gmt: 'Asia/Tehran' - }, - { - tz: 'GMT+04:00', - gmt: 'Asia/Baku' - }, - { - tz: 'GMT+04:00', - gmt: 'Asia/Dubai' - }, - { - tz: 'GMT+04:00', - gmt: 'Asia/Muscat' - }, - { - tz: 'GMT+04:00', - gmt: 'Asia/Tbilisi' - }, - { - tz: 'GMT+04:00', - gmt: 'Asia/Yerevan' - }, - { - tz: 'GMT+04:00', - gmt: 'Etc/GMT-4' - }, - { - tz: 'GMT+04:00', - gmt: 'Europe/Astrakhan' - }, - { - tz: 'GMT+04:00', - gmt: 'Europe/Samara' - }, - { - tz: 'GMT+04:00', - gmt: 'Europe/Saratov' - }, - { - tz: 'GMT+04:00', - gmt: 'Europe/Ulyanovsk' - }, - { - tz: 'GMT+04:00', - gmt: 'Europe/Volgograd' - }, - { - tz: 'GMT+04:00', - gmt: 'Indian/Mahe' - }, - { - tz: 'GMT+04:00', - gmt: 'Indian/Mauritius' - }, - { - tz: 'GMT+04:00', - gmt: 'Indian/Reunion' - }, - { - tz: 'GMT+04:30', - gmt: 'Asia/Kabul' - }, - { - tz: 'GMT+05:00', - gmt: 'Antarctica/Mawson' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Aqtau' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Aqtobe' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Ashgabat' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Atyrau' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Dushanbe' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Karachi' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Oral' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Qyzylorda' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Samarkand' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Tashkent' - }, - { - tz: 'GMT+05:00', - gmt: 'Asia/Yekaterinburg' - }, - { - tz: 'GMT+05:00', - gmt: 'Etc/GMT-5' - }, - { - tz: 'GMT+05:00', - gmt: 'Indian/Kerguelen' - }, - { - tz: 'GMT+05:00', - gmt: 'Indian/Maldives' - }, - { - tz: 'GMT+05:30', - gmt: 'Asia/Calcutta' - }, - { - tz: 'GMT+05:30', - gmt: 'Asia/Colombo' - }, - { - tz: 'GMT+05:30', - gmt: 'Asia/Kolkata' - }, - { - tz: 'GMT+05:45', - gmt: 'Asia/Kathmandu' - }, - { - tz: 'GMT+05:45', - gmt: 'Asia/Katmandu' - }, - { - tz: 'GMT+06:00', - gmt: 'Antarctica/Vostok' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Almaty' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Bishkek' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Dhaka' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Omsk' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Qostanay' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Thimphu' - }, - { - tz: 'GMT+06:00', - gmt: 'Asia/Urumqi' - }, - { - tz: 'GMT+06:00', - gmt: 'Etc/GMT-6' - }, - { - tz: 'GMT+06:00', - gmt: 'Indian/Chagos' - }, - { - tz: 'GMT+06:30', - gmt: 'Asia/Yangon' - }, - { - tz: 'GMT+06:30', - gmt: 'Indian/Cocos' - }, - { - tz: 'GMT+07:00', - gmt: 'Antarctica/Davis' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Bangkok' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Barnaul' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Ho Chi_Minh' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Hovd' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Jakarta' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Krasnoyarsk' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Novokuznetsk' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Novosibirsk' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Phnom Penh' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Pontianak' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Tomsk' - }, - { - tz: 'GMT+07:00', - gmt: 'Asia/Vientiane' - }, - { - tz: 'GMT+07:00', - gmt: 'Etc/GMT-7' - }, - { - tz: 'GMT+07:00', - gmt: 'Indian/Christmas' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Brunei' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Choibalsan' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Hong Kong' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Irkutsk' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Kuala Lumpur' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Kuching' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Macau' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Makassar' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Manila' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Shanghai' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Singapore' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Taipei' - }, - { - tz: 'GMT+08:00', - gmt: 'Asia/Ulaanbaatar' - }, - { - tz: 'GMT+08:00', - gmt: 'Australia/Perth' - }, - { - tz: 'GMT+08:00', - gmt: 'Etc/GMT-8' - }, - { - tz: 'GMT+08:45', - gmt: 'Australia/Eucla' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Chita' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Dili' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Jayapura' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Khandyga' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Pyongyang' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Seoul' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Tokyo' - }, - { - tz: 'GMT+09:00', - gmt: 'Asia/Yakutsk' - }, - { - tz: 'GMT+09:00', - gmt: 'Etc/GMT-9' - }, - { - tz: 'GMT+09:00', - gmt: 'Pacific/Palau' - }, - { - tz: 'GMT+09:30', - gmt: 'Australia/Darwin' - }, - { - tz: 'GMT+10:00', - gmt: 'Antarctica/DumontDUrville' - }, - { - tz: 'GMT+10:00', - gmt: 'Asia/Ust-Nera' - }, - { - tz: 'GMT+10:00', - gmt: 'Asia/Vladivostok' - }, - { - tz: 'GMT+10:00', - gmt: 'Australia/Brisbane' - }, - { - tz: 'GMT+10:00', - gmt: 'Australia/Lindeman' - }, - { - tz: 'GMT+10:00', - gmt: 'Etc/GMT-10' - }, - { - tz: 'GMT+10:00', - gmt: 'Pacific/Chuuk' - }, - { - tz: 'GMT+10:00', - gmt: 'Pacific/Guam' - }, - { - tz: 'GMT+10:00', - gmt: 'Pacific/Port Moresby' - }, - { - tz: 'GMT+10:00', - gmt: 'Pacific/Saipan' - }, - { - tz: 'GMT+10:30', - gmt: 'Australia/Adelaide' - }, - { - tz: 'GMT+10:30', - gmt: 'Australia/Broken Hill' - }, - { - tz: 'GMT+11:00', - gmt: 'Antarctica/Casey' - }, - { - tz: 'GMT+11:00', - gmt: 'Antarctica/Macquarie' - }, - { - tz: 'GMT+11:00', - gmt: 'Asia/Magadan' - }, - { - tz: 'GMT+11:00', - gmt: 'Asia/Sakhalin' - }, - { - tz: 'GMT+11:00', - gmt: 'Asia/Srednekolymsk' - }, - { - tz: 'GMT+11:00', - gmt: 'Australia/Currie' - }, - { - tz: 'GMT+11:00', - gmt: 'Australia/Hobart' - }, - { - tz: 'GMT+11:00', - gmt: 'Australia/Lord Howe' - }, - { - tz: 'GMT+11:00', - gmt: 'Australia/Melbourne' - }, - { - tz: 'GMT+11:00', - gmt: 'Australia/Sydney' - }, - { - tz: 'GMT+11:00', - gmt: 'Etc/GMT-11' - }, - { - tz: 'GMT+11:00', - gmt: 'Pacific/Bougainville' - }, - { - tz: 'GMT+11:00', - gmt: 'Pacific/Efate' - }, - { - tz: 'GMT+11:00', - gmt: 'Pacific/Guadalcanal' - }, - { - tz: 'GMT+11:00', - gmt: 'Pacific/Kosrae' - }, - { - tz: 'GMT+11:00', - gmt: 'Pacific/Noumea' - }, - { - tz: 'GMT+11:00', - gmt: 'Pacific/Pohnpei' - }, - { - tz: 'GMT+12:00', - gmt: 'Asia/Anadyr' - }, - { - tz: 'GMT+12:00', - gmt: 'Asia/Kamchatka' - }, - { - tz: 'GMT+12:00', - gmt: 'Etc/GMT-12' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Fiji' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Funafuti' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Kwajalein' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Majuro' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Nauru' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Norfolk' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Tarawa' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Wake' - }, - { - tz: 'GMT+12:00', - gmt: 'Pacific/Wallis' - }, - { - tz: 'GMT+13:00', - gmt: 'Antarctica/McMurdo' - }, - { - tz: 'GMT+13:00', - gmt: 'Etc/GMT-13' - }, - { - tz: 'GMT+13:00', - gmt: 'Pacific/Auckland' - }, - { - tz: 'GMT+13:00', - gmt: 'Pacific/Enderbury' - }, - { - tz: 'GMT+13:00', - gmt: 'Pacific/Fakaofo' - }, - { - tz: 'GMT+13:00', - gmt: 'Pacific/Tongatapu' - }, - { - tz: 'GMT+13:45', - gmt: 'Pacific/Chatham' - }, - { - tz: 'GMT+14:00', - gmt: 'Etc/GMT-14' - }, - { - tz: 'GMT+14:00', - gmt: 'Pacific/Apia' - }, - { - tz: 'GMT+14:00', - gmt: 'Pacific/Kiritimati' - } -]; - -module.exports = timezone; \ No newline at end of file diff --git a/log_manager.py b/log_manager.py new file mode 100644 index 0000000..4c7582e --- /dev/null +++ b/log_manager.py @@ -0,0 +1,254 @@ +""" +日志管理模块 +提供日志读取、过滤、搜索和导出功能 +""" +import os +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Optional, Tuple +from config import Config + + +class LogManager: + """日志管理器""" + + LOG_PATTERN = re.compile( + r'(\d{4}-\d{2}-\d{2}\s+\d{2}:\d{2}:\d{2})\s*\|\s*(\w+)\s*\|\s*(.*)', + re.DOTALL + ) + + LEVEL_PRIORITY = { + 'DEBUG': 0, + 'INFO': 1, + 'WARNING': 2, + 'ERROR': 3, + 'CRITICAL': 4 + } + + def __init__(self, log_dir: str = None): + self.log_dir = Path(log_dir or Config.LOG_DIR) + if not self.log_dir.exists(): + self.log_dir.mkdir(parents=True, exist_ok=True) + + def get_log_files(self) -> List[Dict]: + """获取所有日志文件列表""" + log_files = [] + for file_path in self.log_dir.glob('*.log'): + stat = file_path.stat() + log_files.append({ + 'name': file_path.name, + 'path': str(file_path), + 'size': stat.st_size, + 'size_human': self._format_size(stat.st_size), + 'modified_time': datetime.fromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S') + }) + return sorted(log_files, key=lambda x: x['modified_time'], reverse=True) + + def _format_size(self, size: int) -> str: + """格式化文件大小""" + for unit in ['B', 'KB', 'MB', 'GB']: + if size < 1024: + return f"{size:.1f} {unit}" + size /= 1024 + return f"{size:.1f} TB" + + def _parse_log_line(self, line: str) -> Optional[Dict]: + """解析单行日志""" + match = self.LOG_PATTERN.match(line.strip()) + if match: + return { + 'time': match.group(1), + 'level': match.group(2).upper(), + 'message': match.group(3).strip() + } + return None + + def _read_log_file_reverse(self, file_path: Path, limit: int = 500) -> List[str]: + """从文件末尾反向读取日志行""" + lines = [] + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + # 读取所有行 + all_lines = f.readlines() + # 取最后limit行 + lines = all_lines[-limit:] if len(all_lines) > limit else all_lines + except Exception: + pass + return lines + + def get_latest_logs(self, limit: int = 100, level: str = 'ALL') -> List[Dict]: + """获取最新的日志条目(用于实时流)""" + logs = [] + log_files = self.get_log_files() + + if not log_files: + return logs + + # 读取最新的日志文件 + latest_file = Path(log_files[0]['path']) + lines = self._read_log_file_reverse(latest_file, limit * 2) + + current_log = None + for line in lines: + parsed = self._parse_log_line(line) + if parsed: + if current_log: + logs.append(current_log) + current_log = parsed + elif current_log and line.strip(): + # 多行日志,追加到消息中 + current_log['message'] += '\n' + line.strip() + + if current_log: + logs.append(current_log) + + # 按级别过滤 + if level and level.upper() != 'ALL': + logs = [log for log in logs if log['level'] == level.upper()] + + # 返回最新的limit条 + return logs[-limit:] + + def search_logs( + self, + keyword: str = None, + level: str = 'ALL', + start_time: str = None, + end_time: str = None, + page: int = 1, + page_size: int = 100 + ) -> Tuple[List[Dict], int]: + """ + 搜索日志 + 返回: (日志列表, 总数) + """ + all_logs = [] + log_files = self.get_log_files() + + # 时间范围过滤 + start_dt = None + end_dt = None + if start_time: + try: + start_dt = datetime.strptime(start_time, '%Y-%m-%d %H:%M:%S') + except ValueError: + try: + start_dt = datetime.strptime(start_time, '%Y-%m-%d') + except ValueError: + pass + if end_time: + try: + end_dt = datetime.strptime(end_time, '%Y-%m-%d %H:%M:%S') + except ValueError: + try: + end_dt = datetime.strptime(end_time, '%Y-%m-%d') + end_dt = end_dt.replace(hour=23, minute=59, second=59) + except ValueError: + pass + + # 读取日志文件 + for log_file in log_files[:5]: # 最多读取最近5个日志文件 + file_path = Path(log_file['path']) + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + current_log = None + for line in f: + parsed = self._parse_log_line(line) + if parsed: + if current_log: + all_logs.append(current_log) + current_log = parsed + elif current_log and line.strip(): + current_log['message'] += '\n' + line.strip() + if current_log: + all_logs.append(current_log) + except Exception: + continue + + # 过滤 + filtered_logs = [] + for log in all_logs: + # 级别过滤 + if level and level.upper() != 'ALL' and log['level'] != level.upper(): + continue + + # 关键词过滤 + if keyword and keyword.lower() not in log['message'].lower(): + continue + + # 时间范围过滤 + try: + log_dt = datetime.strptime(log['time'], '%Y-%m-%d %H:%M:%S') + if start_dt and log_dt < start_dt: + continue + if end_dt and log_dt > end_dt: + continue + except ValueError: + pass + + filtered_logs.append(log) + + # 按时间倒序排序 + filtered_logs.sort(key=lambda x: x['time'], reverse=True) + + # 分页 + total = len(filtered_logs) + start_idx = (page - 1) * page_size + end_idx = start_idx + page_size + paginated_logs = filtered_logs[start_idx:end_idx] + + return paginated_logs, total + + def get_log_content(self, filename: str) -> str: + """获取指定日志文件的内容""" + file_path = self.log_dir / filename + if not file_path.exists(): + return "" + + try: + with open(file_path, 'r', encoding='utf-8', errors='ignore') as f: + return f.read() + except Exception: + return "" + + def get_log_stats(self) -> Dict: + """获取日志统计信息""" + stats = { + 'total_files': 0, + 'total_size': 0, + 'total_size_human': '0 B', + 'level_counts': { + 'DEBUG': 0, + 'INFO': 0, + 'WARNING': 0, + 'ERROR': 0, + 'CRITICAL': 0 + }, + 'latest_error': None, + 'latest_warning': None + } + + log_files = self.get_log_files() + stats['total_files'] = len(log_files) + stats['total_size'] = sum(f['size'] for f in log_files) + stats['total_size_human'] = self._format_size(stats['total_size']) + + # 统计最近日志文件的级别分布 + if log_files: + latest_file = Path(log_files[0]['path']) + lines = self._read_log_file_reverse(latest_file, 1000) + + for line in lines: + parsed = self._parse_log_line(line) + if parsed: + level = parsed['level'] + if level in stats['level_counts']: + stats['level_counts'][level] += 1 + + if level == 'ERROR' and not stats['latest_error']: + stats['latest_error'] = parsed + elif level == 'WARNING' and not stats['latest_warning']: + stats['latest_warning'] = parsed + + return stats diff --git a/main.py b/main.py index b9703fa..4dfdd8f 100644 --- a/main.py +++ b/main.py @@ -28,6 +28,7 @@ import signal import sys import threading import time +from concurrent.futures import ThreadPoolExecutor, as_completed from datetime import datetime from pathlib import Path from typing import Dict, List @@ -108,6 +109,10 @@ MIP广告点击服务 self.total_clicks_today = 0 self.error_count = 0 + # 线程安全锁(用于并发执行) + self._click_records_lock = threading.Lock() + self._stats_lock = threading.Lock() + # 健康检查API self.health_app = Flask(__name__) self.health_app.logger.disabled = True # 禁用Flask日志 @@ -116,10 +121,10 @@ MIP广告点击服务 logger.info(f"调度器初始化完成") logger.info(f"工作时间: {self.work_start_hour:02d}:00 - {self.work_end_hour:02d}:00") logger.info(f"点击间隔: {self.click_interval_minutes} 分钟") - logger.info(f"并发数: {max_workers}") + logger.info(f"并发模式: {'启用' if max_workers > 1 else '禁用'} (最大并发数: {max_workers})") def _setup_health_api(self): - """配置健康检查API""" + """配置健康检查API和调度器控制API""" @self.health_app.route('/health', methods=['GET']) def health_check(): """健康检查端点""" @@ -140,6 +145,44 @@ MIP广告点击服务 'work_hours': f"{self.work_start_hour:02d}:00-{self.work_end_hour:02d}:00", 'is_working_time': self.is_working_time() }) + + @self.health_app.route('/scheduler/status', methods=['GET']) + def scheduler_status(): + """获取调度器状态(供远程Web服务调用)""" + return jsonify({ + 'success': True, + 'data': { + 'status': 'running' if self.running else 'stopped', + 'is_working_time': self.is_working_time(), + 'total_sites': len(self.click_records), + 'completed_sites': sum(1 for r in self.click_records.values() if r['today_count'] >= r['target_count']), + 'total_clicks_today': sum(r['today_count'] for r in self.click_records.values()), + 'error_count': self.error_count + } + }) + + @self.health_app.route('/scheduler/start', methods=['POST']) + def scheduler_start(): + """启动调度器(供远程Web服务调用)""" + if self.running: + return jsonify({'success': True, 'message': '调度器已在运行中'}) + + # 重新初始化并启动 + self.running = True + self.start_time = datetime.now() + self.reset_daily_records() + logger.info("调度器已通过远程API启动") + return jsonify({'success': True, 'message': '调度器已启动'}) + + @self.health_app.route('/scheduler/stop', methods=['POST']) + def scheduler_stop(): + """停止调度器(供远程Web服务调用)""" + if not self.running: + return jsonify({'success': True, 'message': '调度器未运行'}) + + self.running = False + logger.info("调度器已通过远程API停止") + return jsonify({'success': True, 'message': '调度器已停止'}) def _acquire_lock(self) -> bool: """ @@ -222,7 +265,8 @@ MIP广告点击服务 'last_click': None, 'today_count': 0, 'target_count': target_count, - 'site_url': site.get('site_url') + 'site_url': site.get('site_url'), + 'click_count': site.get('click_count', 0) # 历史总点击次数 } logger.info(f"站点 {site_id}: {site.get('site_url')} - 今日目标 {target_count} 次") @@ -233,12 +277,40 @@ MIP广告点击服务 获取待点击的站点列表 Returns: - 待点击的站点列表 + 待点击的站点列表(优先返回今日未点击的站点) """ if not self.click_records: logger.warning("点击记录为空,执行重置") self.reset_daily_records() + # 动态检测新导入的站点 + current_sites = self.dm.get_active_urls() + current_site_ids = {site.get('id') for site in current_sites} + existing_site_ids = set(self.click_records.keys()) + + # 发现新站点,自动添加到点击记录 + new_site_ids = current_site_ids - existing_site_ids + if new_site_ids: + logger.info(f"发现 {len(new_site_ids)} 个新导入的站点,加入点击队列") + for site in current_sites: + site_id = site.get('id') + if site_id in new_site_ids: + target_count = random.randint(Config.MIN_CLICK_COUNT, Config.MAX_CLICK_COUNT) + self.click_records[site_id] = { + 'last_click': None, + 'today_count': 0, + 'target_count': target_count, + 'site_url': site.get('site_url'), + 'click_count': site.get('click_count', 0) # 历史总点击次数 + } + logger.info(f"新站点 {site_id}: {site.get('site_url')} - 今日目标 {target_count} 次") + + # 移除已删除的站点 + removed_site_ids = existing_site_ids - current_site_ids + for site_id in removed_site_ids: + del self.click_records[site_id] + logger.info(f"站点 {site_id} 已从数据库删除,移除出点击队列") + now = datetime.now() pending_sites = [] @@ -257,9 +329,21 @@ MIP广告点击服务 'id': site_id, 'site_url': record['site_url'], 'today_count': record['today_count'], - 'target_count': record['target_count'] + 'target_count': record['target_count'], + 'click_count': record.get('click_count', 0), # 历史总点击次数 + 'last_click': record['last_click'] }) + # 排序优先级: + # 1. 历史从未点击的(click_count=0)最优先 + # 2. 今日未点击的(today_count=0)次优先 + # 3. 按上次点击时间升序(最久未点击的优先) + pending_sites.sort(key=lambda x: ( + x['click_count'] > 0, # False(历史0次) 排在最前面 + x['today_count'] > 0, # False(今日0次) 排在前面 + x['last_click'] or datetime.min # 从未点击的排在前面 + )) + return pending_sites def execute_click_task(self, site: Dict): @@ -341,13 +425,28 @@ MIP广告点击服务 logger.info(f"找到 {len(pending_sites)} 个待点击站点") - # 随机打乱顺序(模拟真实行为) - random.shuffle(pending_sites) + # 分组:历史从未点击的 vs 历史点击过的 + never_clicked = [s for s in pending_sites if s.get('click_count', 0) == 0] + has_clicked = [s for s in pending_sites if s.get('click_count', 0) > 0] + + # 各组内随机打乱,但保持"历史从未点击优先"的整体顺序 + random.shuffle(never_clicked) + random.shuffle(has_clicked) + pending_sites = never_clicked + has_clicked + + if never_clicked: + logger.info(f"其中 {len(never_clicked)} 个站点历史从未点击(最优先处理)") # 根据并发数执行 if self.max_workers == 1: # 串行执行 for site in pending_sites: + # 每次执行前检查工作时间 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 已超出工作时间,停止执行剩余任务") + break + self.execute_click_task(site) # 任务间随机间隔(使用配置文件中的范围) @@ -356,11 +455,8 @@ MIP广告点击服务 logger.info(f"等待 {wait_minutes} 分钟后执行下一个任务...") time.sleep(wait_minutes * 60) else: - # 并发执行(暂不支持,避免资源冲突) - logger.warning("当前版本仅支持串行执行") - for site in pending_sites: - self.execute_click_task(site) - time.sleep(random.randint(Config.MIN_TASK_INTERVAL_MINUTES, Config.MAX_TASK_INTERVAL_MINUTES) * 60) + # 并发执行 + self._run_concurrent_cycle(pending_sites) # 显示今日进度 completed = sum(1 for r in self.click_records.values() if r['today_count'] >= r['target_count']) @@ -373,6 +469,150 @@ MIP广告点击服务 logger.info(f"点击次数: {total_clicks}/{target_clicks} 次") logger.info("-" * 60) + def _run_concurrent_cycle(self, pending_sites: List[Dict]): + """ + 并发执行点击任务 + + Args: + pending_sites: 待点击的站点列表 + """ + # 按 max_workers 分批 + batch_size = self.max_workers + batches = [pending_sites[i:i+batch_size] for i in range(0, len(pending_sites), batch_size)] + + logger.info(f"并发模式: 共 {len(pending_sites)} 个任务,分 {len(batches)} 批执行(每批最多 {batch_size} 个)") + + for batch_idx, batch in enumerate(batches): + # 检查工作时间 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 已超出工作时间,停止执行剩余批次") + break + + logger.info(f"=" * 40) + logger.info(f"开始批次 {batch_idx+1}/{len(batches)}: {len(batch)} 个任务并发执行") + for i, site in enumerate(batch, 1): + logger.info(f" - [Worker {i}] Site {site['id']}: {site['site_url'][:50]}...") + + batch_start_time = time.time() + success_count = 0 + fail_count = 0 + + # 使用 ThreadPoolExecutor 并发执行 + with ThreadPoolExecutor(max_workers=len(batch)) as executor: + futures = { + executor.submit(self._execute_click_task_wrapper, site, worker_id): site + for worker_id, site in enumerate(batch, 1) + } + + # 等待所有任务完成 + for future in as_completed(futures): + site = futures[future] + try: + result = future.result() + if result.get('success'): + success_count += 1 + else: + fail_count += 1 + except Exception as e: + fail_count += 1 + logger.error(f"任务异常: Site {site['id']} - {e}") + + batch_duration = (time.time() - batch_start_time) / 60 + logger.info(f"批次 {batch_idx+1} 完成: 成功 {success_count}, 失败 {fail_count}, 耗时 {batch_duration:.1f} 分钟") + + # 批次间随机间隔(不是最后一批) + if batch_idx < len(batches) - 1: + # 再次检查工作时间 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 已超出工作时间,停止执行") + break + + wait_minutes = random.randint(Config.MIN_TASK_INTERVAL_MINUTES, Config.MAX_TASK_INTERVAL_MINUTES) + logger.info(f"等待 {wait_minutes} 分钟后执行下一批次...") + time.sleep(wait_minutes * 60) + + def _execute_click_task_wrapper(self, site: Dict, worker_id: int) -> Dict: + """ + 线程安全的任务执行包装器 + + Args: + site: 站点信息 + worker_id: Worker编号(用于日志标识) + + Returns: + 执行结果字典 + """ + site_id = site['id'] + site_url = site['site_url'] + + # 错峰启动:每个 worker 间隔 5-10 秒,避免同时调用 AdsPower API 触发限频 + if worker_id > 1: + stagger_delay = (worker_id - 1) * random.randint(5, 10) + logger.info(f"[Worker {worker_id}] [Site {site_id}] 错峰等待 {stagger_delay} 秒后启动...") + time.sleep(stagger_delay) + + logger.info(f"[Worker {worker_id}] [Site {site_id}] 开始点击: {site_url[:50]}...") + + executor = None + try: + # 创建独立的 TaskExecutor 实例 + executor = TaskExecutor(max_workers=1, use_proxy=self.use_proxy) + + # 创建浏览器环境(每个 worker 独立的 Profile 和代理) + profile_info = executor.create_browser_profile(worker_id) + if not profile_info: + logger.error(f"[Worker {worker_id}] [Site {site_id}] 创建浏览器环境失败") + with self._stats_lock: + self.error_count += 1 + return {'success': False, 'error': '创建浏览器环境失败'} + + time.sleep(2) + + # 获取完整站点信息 + all_sites = self.dm.get_active_urls() + target_site = next((s for s in all_sites if s.get('id') == site_id), None) + + if not target_site: + logger.error(f"[Worker {worker_id}] [Site {site_id}] 未找到站点信息") + return {'success': False, 'error': '未找到站点信息'} + + # 执行任务 + result = executor.execute_single_task(target_site, worker_id, profile_info['profile_id']) + + if result['success']: + # 线程安全更新点击记录 + with self._click_records_lock: + if site_id in self.click_records: + self.click_records[site_id]['last_click'] = datetime.now() + self.click_records[site_id]['today_count'] += 1 + with self._stats_lock: + self.total_clicks_today += 1 + + logger.info(f"[Worker {worker_id}] [Site {site_id}] ✅ 点击完成") + else: + with self._stats_lock: + self.error_count += 1 + logger.warning(f"[Worker {worker_id}] [Site {site_id}] ⚠️ 点击失败: {result.get('error', '未知错误')}") + + return result + + except Exception as e: + with self._stats_lock: + self.error_count += 1 + logger.error(f"[Worker {worker_id}] [Site {site_id}] ❌ 异常: {str(e)}") + import traceback + traceback.print_exc() + return {'success': False, 'error': str(e)} + finally: + # 确保资源清理 + if executor: + try: + executor.close_browser() + except Exception as e: + logger.warning(f"[Worker {worker_id}] 清理资源失败: {e}") + def run_crawler_cycle(self): """执行一次爬虫循环""" if not self.crawler: @@ -397,6 +637,15 @@ MIP广告点击服务 import traceback traceback.print_exc() + def run_query_import_scan(self): + """扫描Query上传目录,导入待处理文件""" + try: + from query_keyword_importer import QueryKeywordImporter + importer = QueryKeywordImporter() + importer.scan_and_import() + except Exception as e: + logger.error(f"Query导入扫描异常: {e}") + def start(self): """启动调度器""" # 获取进程锁 @@ -457,6 +706,10 @@ MIP广告点击服务 else: logger.info(" - 网址爬取未启用") + # 4. Query挖掘目录扫描(每15分钟) + schedule.every(15).minutes.do(self.run_query_import_scan) + logger.info(" - 每 15 分钟扫描Query上传目录") + logger.info("") # 立即执行一次(如果在工作时间内) diff --git a/open_database_manager.bat b/open_database_manager.bat deleted file mode 100644 index bf886fd..0000000 --- a/open_database_manager.bat +++ /dev/null @@ -1,22 +0,0 @@ -@echo off -chcp 65001 >nul -echo ==================================== -echo MIP广告自动化 - 数据库管理页面 -echo ==================================== -echo. - -echo 正在启动浏览器打开管理页面... -echo. - -REM 检测操作系统并打开浏览器 -start http://localhost:5000/static/database.html - -echo. -echo ✅ 已在浏览器中打开数据库管理页面 -echo. -echo 📌 如果浏览器未自动打开,请手动访问: -echo http://localhost:5000/static/database.html -echo. -echo 💡 提示: 请确保后端服务已启动 (python app.py) -echo. -pause diff --git a/postman_collection.json b/postman_collection.json deleted file mode 100644 index c261cce..0000000 --- a/postman_collection.json +++ /dev/null @@ -1,166 +0,0 @@ -{ - "info": { - "name": "MIP广告点击服务 API", - "description": "MIP页面广告自动化点击服务的API接口集合", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" - }, - "item": [ - { - "name": "健康检查", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{base_url}}/health", - "host": ["{{base_url}}"], - "path": ["health"] - } - } - }, - { - "name": "添加单个URL", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"url\": \"https://example.com/mip-page\"\n}" - }, - "url": { - "raw": "{{base_url}}/api/urls", - "host": ["{{base_url}}"], - "path": ["api", "urls"] - } - } - }, - { - "name": "批量添加URL", - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"urls\": [\n \"https://example.com/mip-page-1\",\n \"https://example.com/mip-page-2\",\n \"https://example.com/mip-page-3\"\n ]\n}" - }, - "url": { - "raw": "{{base_url}}/api/urls", - "host": ["{{base_url}}"], - "path": ["api", "urls"] - } - } - }, - { - "name": "获取所有URL", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{base_url}}/api/urls", - "host": ["{{base_url}}"], - "path": ["api", "urls"] - } - } - }, - { - "name": "获取URL详情", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{base_url}}/api/urls/https://example.com/mip-page", - "host": ["{{base_url}}"], - "path": ["api", "urls", "https://example.com/mip-page"] - } - } - }, - { - "name": "删除URL", - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{base_url}}/api/urls/https://example.com/mip-page", - "host": ["{{base_url}}"], - "path": ["api", "urls", "https://example.com/mip-page"] - } - } - }, - { - "name": "重置URL", - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{base_url}}/api/urls/https://example.com/mip-page/reset", - "host": ["{{base_url}}"], - "path": ["api", "urls", "https://example.com/mip-page", "reset"] - } - } - }, - { - "name": "获取统计数据", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{base_url}}/api/statistics", - "host": ["{{base_url}}"], - "path": ["api", "statistics"] - } - } - }, - { - "name": "启动调度器", - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{base_url}}/api/scheduler/start", - "host": ["{{base_url}}"], - "path": ["api", "scheduler", "start"] - } - } - }, - { - "name": "停止调度器", - "request": { - "method": "POST", - "header": [], - "url": { - "raw": "{{base_url}}/api/scheduler/stop", - "host": ["{{base_url}}"], - "path": ["api", "scheduler", "stop"] - } - } - }, - { - "name": "查询调度器状态", - "request": { - "method": "GET", - "header": [], - "url": { - "raw": "{{base_url}}/api/scheduler/status", - "host": ["{{base_url}}"], - "path": ["api", "scheduler", "status"] - } - } - } - ], - "variable": [ - { - "key": "base_url", - "value": "http://localhost:5000", - "type": "string" - } - ] -} diff --git a/query_keyword_importer.py b/query_keyword_importer.py new file mode 100644 index 0000000..45172f2 --- /dev/null +++ b/query_keyword_importer.py @@ -0,0 +1,204 @@ +""" +Query关键词导入模块 +从Excel文件读取关键词,批量导入到baidu_keyword表 +""" +import os +import glob +import time +import pandas as pd +from datetime import datetime +from loguru import logger +from config import Config +from db_manager import QueryKeywordManager, QueryImportLogManager + + +class QueryKeywordImporter: + """Query关键词导入器""" + + # 支持的query列名 + QUERY_COLUMNS = ['query', 'Query', '查询词', 'keyword', '关键词'] + # 支持的科室列名 + DEPT_COLUMNS = ['科室', 'department', 'Department', '部门'] + # 批量插入大小 + BATCH_SIZE = 500 + + def __init__(self): + self.keyword_mgr = QueryKeywordManager() + self.log_mgr = QueryImportLogManager() + + def _detect_column(self, df_columns, candidates): + """从DataFrame列中检测匹配的列名""" + for col in candidates: + if col in df_columns: + return col + return None + + def import_file(self, filepath: str, log_id: int = None, import_mode: str = 'query_only') -> dict: + """ + 导入单个Excel文件到baidu_keyword表(批量模式) + + Args: + filepath: Excel文件路径 + log_id: 导入日志ID(可选,如果已创建) + import_mode: 导入模式 + - 'query_only': 仅导入query列,query_status=draft + - 'full_import': 三列(科室/关键字/query),query_status=manual_review + + Returns: + {'success': bool, 'stats': {...}, 'error': str} + """ + filename = os.path.basename(filepath) + stats = {'total': 0, 'success': 0, 'skip': 0, 'fail': 0} + + # 根据模式确定 query_status + query_status = 'draft' if import_mode == 'query_only' else 'manual_review' + + # 创建或获取日志记录 + if log_id is None: + log_id = self.log_mgr.create_log(filename, filepath) + + try: + # 更新状态为运行中 + if log_id: + self.log_mgr.update_status(log_id, 'running') + + logger.info(f"[Query导入] 开始导入文件: {filename}, 模式: {import_mode}, query_status: {query_status}") + + # 1. 读取Excel + if not os.path.exists(filepath): + raise FileNotFoundError(f"文件不存在: {filepath}") + + df = pd.read_excel(filepath) + logger.info(f"[Query导入] 文件 {filename} 包含 {len(df)} 行, 列名: {df.columns.tolist()}") + + # 2. 根据模式确定列映射 + if import_mode == 'query_only': + # 模式1:仅一列query + query_col = df.columns[0] + dept_col = None + keyword_col = None + logger.info(f"[Query导入] 仅导入Query模式,使用列: {query_col}") + else: + # 模式2:三列 - 科室/关键字/query(按位置) + dept_col = df.columns[0] + keyword_col = df.columns[1] + query_col = df.columns[2] + logger.info(f"[Query导入] 完整导入模式,科室列: {dept_col}, 关键字列: {keyword_col}, query列: {query_col}") + + # 3. 预处理数据:提取所有有效关键词 + keyword_list = [] + for idx, row in df.iterrows(): + query_val = str(row[query_col]).strip() if pd.notna(row[query_col]) else '' + if not query_val or query_val == 'nan': + stats['fail'] += 1 + continue + + item = {'keyword': query_val, 'department': ''} + + if import_mode == 'full_import': + # 提取科室 + if dept_col and pd.notna(row[dept_col]): + dept_val = str(row[dept_col]).strip() + if dept_val != 'nan': + item['department'] = dept_val + + keyword_list.append(item) + + stats['total'] = len(df) + total_valid = len(keyword_list) + logger.info(f"[Query导入] 有效关键词: {total_valid} / {stats['total']}") + + # 5. 分批插入 + batch_count = (total_valid + self.BATCH_SIZE - 1) // self.BATCH_SIZE + processed = 0 + + for batch_idx in range(batch_count): + start = batch_idx * self.BATCH_SIZE + end = min(start + self.BATCH_SIZE, total_valid) + batch = keyword_list[start:end] + + batch_stats = self.keyword_mgr.batch_insert_keywords(batch, query_status=query_status) + stats['success'] += batch_stats['success'] + stats['skip'] += batch_stats['skip'] + stats['fail'] += batch_stats['fail'] + processed += len(batch) + + progress = (processed / total_valid) * 100 if total_valid > 0 else 100 + logger.info( + f"[Query导入] [{filename}] 批次 {batch_idx+1}/{batch_count} | " + f"进度: {processed}/{total_valid} ({progress:.1f}%) | " + f"成功: {stats['success']} | 跳过: {stats['skip']} | 失败: {stats['fail']}" + ) + + # 6. 更新日志 + if log_id: + self.log_mgr.update_status( + log_id, 'completed', + total_count=stats['total'], + success_count=stats['success'], + skip_count=stats['skip'], + fail_count=stats['fail'] + ) + + logger.info( + f"[Query导入] 文件 {filename} 导入完成 | " + f"总数: {stats['total']} | 成功: {stats['success']} | " + f"跳过: {stats['skip']} | 失败: {stats['fail']}" + ) + + return {'success': True, 'stats': stats} + + except Exception as e: + error_msg = str(e) + logger.error(f"[Query导入] 文件 {filename} 导入失败: {error_msg}") + + if log_id: + self.log_mgr.update_status( + log_id, 'failed', + total_count=stats['total'], + success_count=stats['success'], + skip_count=stats['skip'], + fail_count=stats['fail'], + error_message=error_msg + ) + + return {'success': False, 'stats': stats, 'error': error_msg} + + def scan_and_import(self): + """ + 扫描上传目录,导入待处理文件 + + 1. 处理数据库中 status='pending' 的记录 + 2. 扫描目录中未登记的Excel文件并导入 + """ + upload_dir = Config.QUERY_UPLOAD_DIR + + if not os.path.exists(upload_dir): + os.makedirs(upload_dir, exist_ok=True) + return + + # 1. 处理数据库中 pending 状态的记录 + pending_logs = self.log_mgr.get_pending_logs() + if pending_logs: + logger.info(f"[Query扫描] 发现 {len(pending_logs)} 个待处理导入任务") + for log in pending_logs: + filepath = log['filepath'] + if os.path.exists(filepath): + logger.info(f"[Query扫描] 处理待导入文件: {log['filename']}") + self.import_file(filepath, log_id=log['id']) + else: + logger.warning(f"[Query扫描] 文件不存在,标记为失败: {filepath}") + self.log_mgr.update_status(log['id'], 'failed', error_message='文件不存在') + + # 2. 扫描目录中未登记的文件 + excel_files = [] + for pattern in ['*.xlsx', '*.xls']: + excel_files.extend(glob.glob(os.path.join(upload_dir, pattern))) + + for filepath in excel_files: + filepath = os.path.abspath(filepath) + if not self.log_mgr.is_file_logged(filepath): + filename = os.path.basename(filepath) + logger.info(f"[Query扫描] 发现未登记文件: {filename}") + log_id = self.log_mgr.create_log(filename, filepath) + self.import_file(filepath, log_id=log_id) diff --git a/restart.sh b/restart.sh deleted file mode 100644 index 1b809cf..0000000 --- a/restart.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/bin/bash -# AI MIP 重启脚本 - -PROJECT_DIR="/opt/ai_mip" - -echo "[INFO] 正在停止服务..." -bash ${PROJECT_DIR}/stop.sh - -sleep 2 - -echo "[INFO] 正在启动服务..." -bash ${PROJECT_DIR}/start.sh diff --git a/scheduler.py b/scheduler.py new file mode 100644 index 0000000..2b841a5 --- /dev/null +++ b/scheduler.py @@ -0,0 +1,680 @@ +""" +调度器模块 - 集成真正的任务执行 +提供Web界面所需的调度器功能,并执行实际点击任务 +""" +import random +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from datetime import datetime +from typing import Dict, List, Optional +from loguru import logger + +from config import Config +from data_manager import DataManager + + +class ClickScheduler: + """ + 点击调度器 - Web界面集成版 + + 提供调度器的控制和状态查询功能,并在后台线程中执行实际点击任务。 + 优先执行从未被点击过的链接。 + """ + + def __init__(self): + self.data_manager = DataManager() + self.running = False + self.start_time = None + + # 点击记录: {site_id: {'last_click': datetime, 'today_count': int, 'target_count': int}} + self.click_records: Dict[int, dict] = {} + + # 配置 + self.work_start_hour = getattr(Config, 'WORK_START_HOUR', 9) + self.work_end_hour = getattr(Config, 'WORK_END_HOUR', 21) + self.click_interval_minutes = getattr(Config, 'CLICK_INTERVAL_MINUTES', 30) + + # 后台线程 + self._scheduler_thread: Optional[threading.Thread] = None + self._stop_event = threading.Event() + + # 运行状态 + self.current_task = None # 当前执行的任务 + self.current_executor = None # 当前执行器实例 + self.current_profile_id = None # 当前浏览器profile ID + self.last_cycle_time = None + self.total_clicks_today = 0 + self.error_count = 0 + + # 并发配置 + self.max_workers = getattr(Config, 'MAX_CONCURRENT_WORKERS', 2) + + # 线程安全锁 + self._click_records_lock = threading.Lock() + self._stats_lock = threading.Lock() + + # 使用代理 + self.use_proxy = True + + logger.info("Web调度器初始化完成(集成任务执行)") + + def start_scheduler(self): + """启动调度器""" + if self.running: + logger.warning("调度器已在运行中") + return + + self.running = True + self.start_time = datetime.now() + self._stop_event.clear() + + # 初始化每日记录 + self.reset_daily_records() + + # 启动后台调度线程 + self._scheduler_thread = threading.Thread( + target=self._scheduler_loop, + name="SchedulerThread", + daemon=True + ) + self._scheduler_thread.start() + + logger.info("调度器已启动,后台任务线程运行中") + + def stop_scheduler(self): + """停止调度器,同时结束当前所有任务""" + if not self.running: + logger.warning("调度器未运行") + return + + logger.info("正在停止调度器...") + self.running = False + self._stop_event.set() + + # 关闭当前运行的浏览器 + if self.current_profile_id and self.current_executor: + logger.info(f"正在关闭浏览器 (profile: {self.current_profile_id})...") + try: + self.current_executor.client.close_browser(self.current_profile_id) + logger.info("浏览器已关闭") + except Exception as e: + logger.warning(f"关闭浏览器失败: {str(e)}") + + # 清理当前任务状态 + self.current_executor = None + self.current_profile_id = None + self.current_task = None + + # 等待线程结束(最多等待10秒) + if self._scheduler_thread and self._scheduler_thread.is_alive(): + self._scheduler_thread.join(timeout=10) + + logger.info("调度器已停止") + + def is_working_time(self) -> bool: + """检查当前是否是工作时间""" + now = datetime.now() + return self.work_start_hour <= now.hour < self.work_end_hour + + def reset_daily_records(self): + """重置每日点击记录""" + logger.info("=" * 50) + logger.info("重置每日点击记录") + logger.info("=" * 50) + + # 获取所有活跃站点 + sites = self.data_manager.get_active_urls() + + # 为每个站点随机生成今日目标点击次数 + self.click_records = {} + for site in sites: + site_id = site.get('id') + target_count = random.randint( + getattr(Config, 'MIN_CLICK_COUNT', 1), + getattr(Config, 'MAX_CLICK_COUNT', 3) + ) + self.click_records[site_id] = { + 'last_click': None, + 'today_count': 0, + 'target_count': target_count, + 'site_url': site.get('site_url'), + 'click_count': site.get('click_count', 0) # 历史总点击次数 + } + + total_target = sum(r['target_count'] for r in self.click_records.values()) + logger.info(f"共 {len(sites)} 个站点,总目标点击次数: {total_target}") + + def get_pending_sites(self) -> List[Dict]: + """ + 获取待点击的站点列表 + + 优先级排序: + 1. 历史点击次数为0的站点优先 + 2. 今日点击次数少的站点优先 + 3. 距离上次点击时间长的站点优先 + + Returns: + 待点击的站点列表(已按优先级排序) + """ + if not self.click_records: + logger.warning("点击记录为空,执行重置") + self.reset_daily_records() + + # 动态检测新导入的站点 + current_sites = self.data_manager.get_active_urls() + current_site_ids = {site.get('id') for site in current_sites} + existing_site_ids = set(self.click_records.keys()) + + # 发现新站点,自动添加到点击记录 + new_site_ids = current_site_ids - existing_site_ids + if new_site_ids: + logger.info(f"发现 {len(new_site_ids)} 个新导入的站点,加入点击队列") + for site in current_sites: + site_id = site.get('id') + if site_id in new_site_ids: + target_count = random.randint( + getattr(Config, 'MIN_CLICK_COUNT', 1), + getattr(Config, 'MAX_CLICK_COUNT', 3) + ) + self.click_records[site_id] = { + 'last_click': None, + 'today_count': 0, + 'target_count': target_count, + 'site_url': site.get('site_url'), + 'click_count': site.get('click_count', 0) + } + logger.info(f"新站点 {site_id}: {site.get('site_url')} - 今日目标 {target_count} 次") + + # 移除已删除的站点 + removed_site_ids = existing_site_ids - current_site_ids + for site_id in removed_site_ids: + del self.click_records[site_id] + logger.info(f"站点 {site_id} 已从数据库删除,移除出点击队列") + + now = datetime.now() + pending_sites = [] + + for site_id, record in self.click_records.items(): + # 检查是否已完成今日目标 + if record['today_count'] >= record['target_count']: + continue + + # 检查点击间隔(≥30分钟) + if record['last_click']: + elapsed = (now - record['last_click']).total_seconds() / 60 + if elapsed < self.click_interval_minutes: + continue + + pending_sites.append({ + 'id': site_id, + 'site_url': record['site_url'], + 'today_count': record['today_count'], + 'target_count': record['target_count'], + 'click_count': record.get('click_count', 0), # 历史总点击次数 + 'last_click': record['last_click'] + }) + + # 按优先级排序: + # 1. 历史点击次数为0的优先(从未点击过) + # 2. 今日点击次数少的优先 + # 3. 距离上次点击时间长的优先(last_click 为 None 排最前) + def sort_key(site): + click_count = site.get('click_count', 0) + today_count = site.get('today_count', 0) + last_click = site.get('last_click') + + # 从未点击过的排最前 (0),点击过的按点击次数排 (1 + click_count) + priority1 = 0 if click_count == 0 else (1 + click_count) + + # 今日点击次数少的优先 + priority2 = today_count + + # 距离上次点击时间长的优先(None表示从未点击,排最前) + if last_click is None: + priority3 = 0 + else: + # 将时间转换为负数,时间越早数值越小 + priority3 = last_click.timestamp() + + return (priority1, priority2, priority3) + + pending_sites.sort(key=sort_key) + + return pending_sites + + def execute_click_task(self, site: Dict) -> bool: + """ + 执行单个站点的点击任务 + + Args: + site: 站点信息 + + Returns: + 是否成功 + """ + site_id = site['id'] + site_url = site['site_url'] + + self.current_task = { + 'site_id': site_id, + 'site_url': site_url, + 'start_time': datetime.now() + } + + logger.info(f"[站点 {site_id}] 开始点击: {site_url}") + logger.info(f"[站点 {site_id}] 今日进度: {site['today_count'] + 1}/{site['target_count']}, 历史总点击: {site.get('click_count', 0)}") + + try: + # 检查是否被停止 + if self._stop_event.is_set(): + logger.info(f"[站点 {site_id}] 调度器已停止,跳过任务") + return False + + # 延迟导入避免循环依赖 + from task_executor import TaskExecutor + + # 创建任务执行器 + executor = TaskExecutor( + max_workers=1, + use_proxy=self.use_proxy + ) + self.current_executor = executor # 保存执行器引用 + + # 获取完整站点信息 + all_sites = self.data_manager.get_active_urls() + target_site = next((s for s in all_sites if s.get('id') == site_id), None) + + if not target_site: + logger.error(f"[站点 {site_id}] 未找到站点信息") + return False + + # 检查是否被停止 + if self._stop_event.is_set(): + logger.info(f"[站点 {site_id}] 调度器已停止,跳过任务") + return False + + # 创建浏览器环境 + logger.info(f"[站点 {site_id}] 创建浏览器环境...") + profile_info = executor.create_browser_profile(1) + if not profile_info: + logger.error(f"[站点 {site_id}] 创建浏览器环境失败") + return False + + self.current_profile_id = profile_info['profile_id'] # 保存profile ID + + # 检查是否被停止 + if self._stop_event.is_set(): + logger.info(f"[站点 {site_id}] 调度器已停止,关闭浏览器") + executor.client.close_browser(profile_info['profile_id']) + return False + + time.sleep(2) + + # 执行点击任务 + logger.info(f"[站点 {site_id}] 执行点击任务...") + result = executor.execute_single_task(target_site, 1, profile_info['profile_id']) + + # 清理当前任务状态 + self.current_executor = None + self.current_profile_id = None + + if result['success']: + # 更新点击记录 + self.click_records[site_id]['last_click'] = datetime.now() + self.click_records[site_id]['today_count'] += 1 + self.click_records[site_id]['click_count'] = self.click_records[site_id].get('click_count', 0) + 1 + self.total_clicks_today += 1 + + logger.info(f"[站点 {site_id}] 点击完成: {self.click_records[site_id]['today_count']}/{self.click_records[site_id]['target_count']}") + return True + else: + self.error_count += 1 + logger.warning(f"[站点 {site_id}] 点击失败: {result.get('error', '未知错误')}") + return False + + except Exception as e: + self.error_count += 1 + logger.error(f"[站点 {site_id}] 点击异常: {str(e)}") + import traceback + traceback.print_exc() + return False + finally: + self.current_task = None + self.current_executor = None + self.current_profile_id = None + + def _scheduler_loop(self): + """调度器主循环(后台线程)""" + logger.info("调度器线程启动") + + # 检查是否需要立即执行 + check_interval = 60 # 每60秒检查一次 + cycle_interval = 10 * 60 # 每10分钟执行一次点击循环 + last_cycle = 0 + + while not self._stop_event.is_set(): + try: + now = time.time() + + # 检查是否到了执行周期 + if now - last_cycle >= cycle_interval: + self._run_click_cycle() + last_cycle = now + + # 检查是否需要重置每日记录(跨天) + current_date = datetime.now().date() + if hasattr(self, '_last_reset_date') and self._last_reset_date != current_date: + self.reset_daily_records() + self._last_reset_date = current_date + else: + self._last_reset_date = current_date + + # 等待下一次检查 + self._stop_event.wait(timeout=check_interval) + + except Exception as e: + logger.error(f"调度器循环异常: {str(e)}") + import traceback + traceback.print_exc() + time.sleep(30) # 出错后等待30秒再继续 + + logger.info("调度器线程结束") + + def _run_click_cycle(self): + """执行一次点击循环""" + # 检查工作时间 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 不在工作时间 ({self.work_start_hour}:00-{self.work_end_hour}:00),跳过") + return + + self.last_cycle_time = datetime.now() + + logger.info("-" * 50) + logger.info(f"开始点击循环 - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + logger.info("-" * 50) + + # 获取待点击站点(已按优先级排序) + pending_sites = self.get_pending_sites() + + if not pending_sites: + logger.info("没有待点击的站点") + return + + # 显示优先级信息 + never_clicked = sum(1 for s in pending_sites if s.get('click_count', 0) == 0) + logger.info(f"找到 {len(pending_sites)} 个待点击站点,其中 {never_clicked} 个从未点击过(优先执行)") + + # 根据并发数执行 + if self.max_workers == 1: + # 串行执行 + for site in pending_sites: + # 检查是否被停止 + if self._stop_event.is_set() or not self.running: + logger.info("调度器已停止,终止点击循环") + break + + # 检查工作时间 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 已超出工作时间,停止执行剩余任务") + break + + # 执行点击 + self.execute_click_task(site) + + # 任务间随机间隔 + if site != pending_sites[-1] and not self._stop_event.is_set(): + wait_minutes = random.randint( + getattr(Config, 'MIN_TASK_INTERVAL_MINUTES', 3), + getattr(Config, 'MAX_TASK_INTERVAL_MINUTES', 5) + ) + logger.info(f"等待 {wait_minutes} 分钟后执行下一个任务...") + self._stop_event.wait(timeout=wait_minutes * 60) + else: + # 并发执行 + self._run_concurrent_cycle(pending_sites) + + # 显示今日进度 + completed = sum(1 for r in self.click_records.values() if r['today_count'] >= r['target_count']) + total = len(self.click_records) + total_clicks = sum(r['today_count'] for r in self.click_records.values()) + target_clicks = sum(r['target_count'] for r in self.click_records.values()) + + logger.info("-" * 50) + logger.info(f"今日进度: {completed}/{total} 个站点完成") + logger.info(f"点击次数: {total_clicks}/{target_clicks} 次") + logger.info("-" * 50) + + def _run_concurrent_cycle(self, pending_sites: List[Dict]): + """ + 并发执行点击任务 + + Args: + pending_sites: 待点击的站点列表 + """ + # 按 max_workers 分批 + batch_size = self.max_workers + batches = [pending_sites[i:i+batch_size] for i in range(0, len(pending_sites), batch_size)] + + logger.info(f"并发模式: 共 {len(pending_sites)} 个任务,分 {len(batches)} 批执行(每批最多 {batch_size} 个)") + + for batch_idx, batch in enumerate(batches): + # 检查是否被停止 + if self._stop_event.is_set() or not self.running: + logger.info("调度器已停止,终止点击循环") + break + + # 检查工作时间 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 已超出工作时间,停止执行剩余批次") + break + + logger.info(f"=" * 40) + logger.info(f"开始批次 {batch_idx+1}/{len(batches)}: {len(batch)} 个任务并发执行") + for i, site in enumerate(batch, 1): + logger.info(f" - [Worker {i}] Site {site['id']}: {site['site_url'][:50]}...") + + batch_start_time = time.time() + success_count = 0 + fail_count = 0 + + # 使用 ThreadPoolExecutor 并发执行 + with ThreadPoolExecutor(max_workers=len(batch)) as executor: + futures = { + executor.submit(self._execute_click_task_wrapper, site, worker_id): site + for worker_id, site in enumerate(batch, 1) + } + + # 等待所有任务完成 + for future in as_completed(futures): + site = futures[future] + try: + result = future.result() + if result.get('success'): + success_count += 1 + else: + fail_count += 1 + except Exception as e: + fail_count += 1 + logger.error(f"任务异常: Site {site['id']} - {e}") + + batch_duration = (time.time() - batch_start_time) / 60 + logger.info(f"批次 {batch_idx+1} 完成: 成功 {success_count}, 失败 {fail_count}, 耗时 {batch_duration:.1f} 分钟") + + # 批次间随机间隔(不是最后一批) + if batch_idx < len(batches) - 1 and not self._stop_event.is_set(): + # 再次检查工作时间和停止状态 + if not self.is_working_time(): + current_time = datetime.now().strftime('%H:%M') + logger.info(f"当前时间 {current_time} 已超出工作时间,停止执行") + break + + wait_minutes = random.randint( + getattr(Config, 'MIN_TASK_INTERVAL_MINUTES', 3), + getattr(Config, 'MAX_TASK_INTERVAL_MINUTES', 5) + ) + logger.info(f"等待 {wait_minutes} 分钟后执行下一批次...") + self._stop_event.wait(timeout=wait_minutes * 60) + + def _execute_click_task_wrapper(self, site: Dict, worker_id: int) -> Dict: + """ + 线程安全的任务执行包装器 + + Args: + site: 站点信息 + worker_id: Worker编号(用于日志标识) + + Returns: + 执行结果字典 + """ + from task_executor import TaskExecutor + + site_id = site['id'] + site_url = site['site_url'] + + # 错峰启动:每个 worker 间隔 5-10 秒,避免同时调用 AdsPower API 触发限频 + if worker_id > 1: + stagger_delay = (worker_id - 1) * random.randint(5, 10) + logger.info(f"[Worker {worker_id}] [Site {site_id}] 错峰等待 {stagger_delay} 秒后启动...") + time.sleep(stagger_delay) + + logger.info(f"[Worker {worker_id}] [Site {site_id}] 开始点击: {site_url[:50]}...") + + executor = None + try: + # 创建独立的 TaskExecutor 实例 + executor = TaskExecutor(max_workers=1, use_proxy=self.use_proxy) + + # 创建浏览器环境(每个 worker 独立的 Profile 和代理) + profile_info = executor.create_browser_profile(worker_id) + if not profile_info: + logger.error(f"[Worker {worker_id}] [Site {site_id}] 创建浏览器环境失败") + with self._stats_lock: + self.error_count += 1 + return {'success': False, 'error': '创建浏览器环境失败'} + + time.sleep(2) + + # 获取完整站点信息 + all_sites = self.data_manager.get_active_urls() + target_site = next((s for s in all_sites if s.get('id') == site_id), None) + + if not target_site: + logger.error(f"[Worker {worker_id}] [Site {site_id}] 未找到站点信息") + return {'success': False, 'error': '未找到站点信息'} + + # 执行任务 + result = executor.execute_single_task(target_site, worker_id, profile_info['profile_id']) + + if result['success']: + # 线程安全更新点击记录 + with self._click_records_lock: + if site_id in self.click_records: + self.click_records[site_id]['last_click'] = datetime.now() + self.click_records[site_id]['today_count'] += 1 + self.click_records[site_id]['click_count'] = self.click_records[site_id].get('click_count', 0) + 1 + with self._stats_lock: + self.total_clicks_today += 1 + + logger.info(f"[Worker {worker_id}] [Site {site_id}] ✅ 点击完成") + else: + with self._stats_lock: + self.error_count += 1 + logger.warning(f"[Worker {worker_id}] [Site {site_id}] ⚠️ 点击失败: {result.get('error', '未知错误')}") + + return result + + except Exception as e: + with self._stats_lock: + self.error_count += 1 + logger.error(f"[Worker {worker_id}] [Site {site_id}] ❌ 异常: {str(e)}") + import traceback + traceback.print_exc() + return {'success': False, 'error': str(e)} + finally: + # 确保资源清理 + if executor: + try: + executor.close_browser() + except Exception as e: + logger.warning(f"[Worker {worker_id}] 清理资源失败: {e}") + + # ========== 以下是原有的Web接口方法 ========== + + def add_url(self, url: str) -> bool: + """添加URL到调度队列""" + try: + site_id = self.data_manager.add_url(url) + if site_id: + # 初始化点击记录 + self.click_records[site_id] = { + 'last_click': None, + 'today_count': 0, + 'target_count': random.randint( + getattr(Config, 'MIN_CLICK_COUNT', 1), + getattr(Config, 'MAX_CLICK_COUNT', 3) + ), + 'click_count': 0 + } + return True + return False + except Exception as e: + logger.error(f"添加URL失败: {str(e)}") + return False + + def add_urls(self, urls: List[str]) -> int: + """批量添加URL""" + count = 0 + for url in urls: + if self.add_url(url): + count += 1 + return count + + def get_url_detail(self, url: str) -> Optional[Dict]: + """获取URL详情""" + return self.data_manager.get_url_detail(url) + + def get_statistics(self) -> Dict: + """获取统计数据""" + return self.data_manager.get_statistics() + + def get_queue_status(self) -> Dict: + """获取任务队列状态""" + sites = self.data_manager.get_active_urls() + + pending = [] + completed = [] + + for site in sites: + site_id = site.get('id') + record = self.click_records.get(site_id, {}) + + site_info = { + 'site_id': site_id, + 'site_url': site.get('site_url'), + 'site_name': site.get('site_name'), + 'today_count': record.get('today_count', 0), + 'target_count': record.get('target_count', 0), + 'click_count': site.get('click_count', 0), # 历史总点击次数 + 'last_click': record.get('last_click') + } + + if record.get('today_count', 0) >= record.get('target_count', 0): + completed.append(site_info) + else: + pending.append(site_info) + + return { + 'pending': pending[:20], + 'running': self.current_task, + 'completed': completed[:20], + 'scheduler_status': 'running' if self.running else 'stopped', + 'is_working_time': self.is_working_time(), + 'total_pending': len(pending), + 'total_completed': len(completed), + 'total_clicks_today': self.total_clicks_today, + 'error_count': self.error_count + } diff --git a/start.bat b/start.bat deleted file mode 100644 index deab07c..0000000 --- a/start.bat +++ /dev/null @@ -1,58 +0,0 @@ -@echo off -chcp 65001 >nul -echo ======================================== -echo MIP广告点击服务 - 快速启动脚本 -echo ======================================== -echo. - -REM 检查Python是否安装 -python --version >nul 2>&1 -if %errorlevel% neq 0 ( - echo 错误: 未检测到Python,请先安装Python 3.8+ - pause - exit /b 1 -) - -REM 检查是否存在虚拟环境 -if not exist "venv" ( - echo 未检测到虚拟环境,正在创建... - python -m venv venv - echo 虚拟环境创建完成 - echo. -) - -REM 激活虚拟环境 -echo 激活虚拟环境... -call venv\Scripts\activate.bat - -REM 检查是否已安装依赖 -pip show flask >nul 2>&1 -if %errorlevel% neq 0 ( - echo 正在安装依赖包... - pip install -r requirements.txt - echo 依赖安装完成 - echo. - echo 正在安装 Playwright 浏览器... - python -m playwright install chromium - echo Playwright 浏览器安装完成 - echo. -) - -REM 检查.env文件 -if not exist ".env" ( - echo 警告: 未检测到.env配置文件 - echo 请复制.env.example为.env并配置相关参数 - echo. - echo 是否继续启动服务? - pause -) - -REM 启动服务 -echo 正在启动MIP广告点击服务... -echo. -echo 提示:默认使用开发环境配置 -echo 如需使用生产环境,请设置环境变量:set ENV=production -echo. -python app.py - -pause diff --git a/start.sh b/start.sh deleted file mode 100644 index 5d0bf08..0000000 --- a/start.sh +++ /dev/null @@ -1,45 +0,0 @@ -#!/bin/bash -# AI MIP 后台启动脚本 - -PROJECT_DIR="/home/work/ai_mip" -cd ${PROJECT_DIR} - -echo "[INFO] 检查是否有运行中的服务..." -# 查找并停止旧进程 -OLD_PID=$(pgrep -f "python main.py") - -if [ ! -z "$OLD_PID" ]; then - echo "[WARN] 发现运行中的服务 (PID: $OLD_PID),正在停止..." - pkill -f "python main.py" - sleep 2 - echo "[INFO] 旧服务已停止" -else - echo "[INFO] 没有运行中的服务" -fi - -echo "[INFO] 正在启动服务..." -# 激活虚拟环境并后台运行 -if [ ! -d "venv" ]; then - echo "[ERROR] 虚拟环境不存在,请先执行: python3 -m venv venv" - exit 1 -fi - -if [ ! -f "venv/bin/activate" ]; then - echo "[ERROR] 虚拟环境激活脚本不存在" - exit 1 -fi - -source venv/bin/activate - -# 检查依赖是否安装 -if ! python -c "import schedule" 2>/dev/null; then - echo "[WARN] 依赖未安装,正在安装..." - pip install -r requirements.txt -fi - -nohup python main.py --workers 3 --health-port 8899 > logs/service.log 2>&1 & - -NEW_PID=$! -echo "[INFO] 服务已启动" -echo "[INFO] 进程ID: $NEW_PID" -echo "[INFO] 查看日志: tail -f ${PROJECT_DIR}/logs/service.log" diff --git a/start_production.bat b/start_production.bat deleted file mode 100644 index c2d1e6e..0000000 --- a/start_production.bat +++ /dev/null @@ -1,57 +0,0 @@ -@echo off -chcp 65001 >nul -echo ======================================== -echo MIP广告点击服务 - 生产环境启动 -echo ======================================== -echo. - -REM 设置生产环境 -set ENV=production - -REM 检查Python是否安装 -python --version >nul 2>&1 -if %errorlevel% neq 0 ( - echo 错误: 未检测到Python,请先安装Python 3.8+ - pause - exit /b 1 -) - -REM 检查是否存在虚拟环境 -if not exist "venv" ( - echo 未检测到虚拟环境,正在创建... - python -m venv venv - echo 虚拟环境创建完成 - echo. -) - -REM 激活虚拟环境 -echo 激活虚拟环境... -call venv\Scripts\activate.bat - -REM 检查是否已安装依赖 -pip show flask >nul 2>&1 -if %errorlevel% neq 0 ( - echo 正在安装依赖包... - pip install -r requirements.txt - echo 依赖安装完成 - echo. - echo 正在安装 Playwright 浏览器... - python -m playwright install chromium - echo Playwright 浏览器安装完成 - echo. -) - -REM 检查生产环境配置文件 -if not exist ".env.production" ( - echo 错误: 未检测到生产环境配置文件 .env.production - echo 请先配置生产环境参数 - pause - exit /b 1 -) - -REM 启动服务 -echo 正在启动MIP广告点击服务(生产环境)... -echo. -python app.py - -pause diff --git a/start_production.sh b/start_production.sh deleted file mode 100644 index 5e8bde2..0000000 --- a/start_production.sh +++ /dev/null @@ -1,259 +0,0 @@ -#!/bin/bash - -# MIP广告自动点击系统 - 生产环境启动脚本 -# 适用于 Ubuntu/Debian 系统 - -set -e # 遇到错误立即退出 - -echo "============================================================" -echo "MIP广告自动点击系统 - 生产环境启动" -echo "============================================================" - -# 颜色定义 -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -NC='\033[0m' # No Color - -# 项目目录(脚本所在目录) -PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$PROJECT_DIR" - -echo -e "${GREEN}项目目录: $PROJECT_DIR${NC}" - -# 检查Python版本 -echo "" -echo "============================================================" -echo "检查Python环境" -echo "============================================================" - -if ! command -v python3 &> /dev/null; then - echo -e "${RED}错误: 未找到 python3${NC}" - echo "请安装Python 3.8+: sudo apt-get install python3 python3-pip python3-venv" - exit 1 -fi - -PYTHON_VERSION=$(python3 --version | awk '{print $2}') -echo -e "${GREEN}Python版本: $PYTHON_VERSION${NC}" - -# 检查Python版本是否 >= 3.8 -PYTHON_MAJOR=$(echo $PYTHON_VERSION | cut -d. -f1) -PYTHON_MINOR=$(echo $PYTHON_VERSION | cut -d. -f2) - -if [ "$PYTHON_MAJOR" -lt 3 ] || ([ "$PYTHON_MAJOR" -eq 3 ] && [ "$PYTHON_MINOR" -lt 8 ]); then - echo -e "${RED}错误: Python版本过低,需要 3.8+${NC}" - echo "当前版本: $PYTHON_VERSION" - exit 1 -fi - -# 虚拟环境目录 -VENV_DIR="$PROJECT_DIR/venv" - -# 创建虚拟环境(如果不存在) -if [ ! -d "$VENV_DIR" ]; then - echo "" - echo "============================================================" - echo "创建Python虚拟环境" - echo "============================================================" - - # 检查是否安装了 venv 模块 - if ! python3 -m venv --help &> /dev/null; then - echo -e "${YELLOW}警告: python3-venv 未安装,正在尝试安装...${NC}" - - # 尝试安装(需要sudo权限) - if command -v apt-get &> /dev/null; then - sudo apt-get update - sudo apt-get install -y python3-venv - else - echo -e "${RED}错误: 无法自动安装 python3-venv${NC}" - echo "请手动执行: sudo apt-get install python3-venv" - exit 1 - fi - fi - - echo -e "${GREEN}正在创建虚拟环境...${NC}" - python3 -m venv "$VENV_DIR" - echo -e "${GREEN}✓ 虚拟环境创建成功${NC}" -else - echo "" - echo -e "${GREEN}✓ 虚拟环境已存在: $VENV_DIR${NC}" -fi - -# 激活虚拟环境 -echo "" -echo "============================================================" -echo "激活虚拟环境" -echo "============================================================" - -source "$VENV_DIR/bin/activate" - -if [ "$VIRTUAL_ENV" != "" ]; then - echo -e "${GREEN}✓ 虚拟环境已激活: $VIRTUAL_ENV${NC}" - echo -e "${GREEN}Python路径: $(which python)${NC}" -else - echo -e "${RED}错误: 虚拟环境激活失败${NC}" - exit 1 -fi - -# 升级pip -echo "" -echo "============================================================" -echo "升级pip" -echo "============================================================" -python -m pip install --upgrade pip -i https://pypi.tuna.tsinghua.edu.cn/simple - -# 安装依赖 -echo "" -echo "============================================================" -echo "安装项目依赖" -echo "============================================================" - -if [ -f "requirements.txt" ]; then - echo -e "${GREEN}从 requirements.txt 安装依赖...${NC}" - pip install -r requirements.txt -i https://pypi.tuna.tsinghua.edu.cn/simple -else - echo -e "${YELLOW}警告: requirements.txt 不存在${NC}" - echo "手动安装核心依赖..." - pip install flask playwright requests loguru apscheduler python-dotenv -i https://pypi.tuna.tsinghua.edu.cn/simple - - # 安装Playwright浏览器 - echo "安装Playwright浏览器驱动..." - playwright install chromium -fi - -# 检查配置文件 -echo "" -echo "============================================================" -echo "检查配置文件" -echo "============================================================" - -if [ ! -f ".env.production" ]; then - echo -e "${YELLOW}警告: .env.production 不存在${NC}" - - if [ -f ".env.example" ]; then - echo "从 .env.example 创建配置文件..." - cp .env.example .env.production - echo -e "${GREEN}✓ 已创建 .env.production${NC}" - echo -e "${RED}请编辑 .env.production 配置文件后重新启动${NC}" - exit 1 - else - echo -e "${RED}错误: 缺少配置文件模板${NC}" - exit 1 - fi -else - echo -e "${GREEN}✓ 配置文件存在: .env.production${NC}" -fi - -# 初始化数据库 -echo "" -echo "============================================================" -echo "初始化数据库" -echo "============================================================" - -if [ ! -f "db/ai_mip_prod.db" ]; then - echo -e "${YELLOW}数据库不存在,正在初始化...${NC}" - - if [ -f "db/init_databases.py" ]; then - # 自动创建生产数据库(跳过交互) - python << EOF -from pathlib import Path -import sqlite3 - -db_dir = Path('db') -db_path = db_dir / 'ai_mip_prod.db' - -if not db_path.exists(): - print(f"创建数据库: {db_path}") - - # 读取并执行SQL脚本 - init_sql = db_dir / 'init_sqlite.sql' - if init_sql.exists(): - with open(init_sql, 'r', encoding='utf-8') as f: - sql_script = f.read() - - conn = sqlite3.connect(str(db_path)) - conn.executescript(sql_script) - conn.commit() - conn.close() - print("✓ 数据库初始化完成") - else: - print("错误: 找不到 init_sqlite.sql") - exit(1) -EOF - echo -e "${GREEN}✓ 数据库初始化成功${NC}" - else - echo -e "${RED}错误: 找不到数据库初始化脚本${NC}" - exit 1 - fi -else - echo -e "${GREEN}✓ 数据库已存在: db/ai_mip_prod.db${NC}" -fi - -# 创建必要的目录 -echo "" -echo "============================================================" -echo "创建必要目录" -echo "============================================================" - -mkdir -p logs data -echo -e "${GREEN}✓ 目录创建完成${NC}" - -# 检查AdsPower连接 -echo "" -echo "============================================================" -echo "检查AdsPower连接" -echo "============================================================" - -python << EOF -import os -os.environ['ENV'] = 'production' - -try: - from adspower_client import AdsPowerClient - client = AdsPowerClient() - profiles = client.list_profiles() - - if profiles: - print("\033[0;32m✓ AdsPower连接正常\033[0m") - profile_count = len(profiles.get('data', {}).get('list', [])) - print(f"\033[0;32m Profile数量: {profile_count}\033[0m") - else: - print("\033[1;33m警告: AdsPower连接失败,请检查配置\033[0m") -except Exception as e: - print(f"\033[1;33m警告: AdsPower连接异常: {str(e)}\033[0m") - print("\033[1;33m 请确保AdsPower客户端已启动\033[0m") -EOF - -# 启动服务 -echo "" -echo "============================================================" -echo "启动Flask服务" -echo "============================================================" - -export ENV=production - -# 检查端口是否被占用 -PORT=5000 -if command -v netstat &> /dev/null; then - if netstat -tuln | grep ":$PORT " > /dev/null; then - echo -e "${YELLOW}警告: 端口 $PORT 已被占用${NC}" - echo "尝试查找占用进程..." - lsof -i :$PORT || true - fi -fi - -echo "" -echo -e "${GREEN}启动服务中...${NC}" -echo "访问地址: http://127.0.0.1:5000" -echo "按 Ctrl+C 停止服务" -echo "" - -# 使用nohup在后台运行(可选) -# nohup python app.py > logs/app.log 2>&1 & -# echo $! > app.pid -# echo -e "${GREEN}✓ 服务已启动(后台运行)${NC}" -# echo "PID: $(cat app.pid)" -# echo "日志: logs/app.log" - -# 前台运行(便于调试) -python app.py diff --git a/static/app.html b/static/app.html index 7794fb9..7a0f15b 100644 --- a/static/app.html +++ b/static/app.html @@ -4,560 +4,1919 @@ MIP广告自动化管理系统 - - - - - - - - + + + + + -
+
+ + + + +