import json import re import threading from collections import defaultdict from datetime import datetime, timedelta from typing import Any, Dict, List, Tuple, Optional import pytz from apscheduler.schedulers.background import BackgroundScheduler from apscheduler.triggers.cron import CronTrigger from app.core.config import settings from app.helper.sites import SitesHelper from app.log import logger from app.plugins import _PluginBase from app.schemas.types import EventType, NotificationType from app.utils.http import RequestUtils class AutoAuction(_PluginBase): plugin_name = "朱雀交易行自动上架" plugin_desc = "自动上架灵石或上传到交易行" plugin_icon = "auction.png" plugin_version = "1.0.1" plugin_author = "no_reply" author_url = "https://github.com/jxxghp/MoviePilot-Plugins" plugin_config_prefix = "autoauction_" plugin_order = 50 auth_level = 2 _enabled: bool = False _onlyonce: bool = False _tasks: List[Dict[str, Any]] = [] _history: List[Dict[str, Any]] = [] _global_cron: str = "" _csrf_token: str = "" _notify_enabled: bool = True _scheduler: Optional[BackgroundScheduler] = None _is_running: bool = False _running_lock: threading.Lock = threading.Lock() _last_run_time: float = 0 _min_interval_seconds: int = 60 ZHUQUE_DOMAIN = "zhuque.in" LIST_API = "https://zhuque.in/api/transaction/list" CREATE_API = "https://zhuque.in/api/transaction/create" CREATE_SUCCESS_CODE = "CREATE_TRANSACTION_SUCCESS" def init_plugin(self, config: dict = None): config = config or {} self._enabled = config.get("enabled", False) onlyonce = config.get("onlyonce", False) tasks_json = config.get("tasks_json") if tasks_json is None: self._tasks = [] elif isinstance(tasks_json, str): try: parsed = json.loads(tasks_json) if tasks_json.strip() else [] self._tasks = parsed if isinstance(parsed, list) else [] except json.JSONDecodeError: logger.error(f"任务配置JSON解析失败: {tasks_json}") self._tasks = [] elif isinstance(tasks_json, list): self._tasks = tasks_json else: self._tasks = [] self._global_cron = config.get("global_cron", "") or "" self._csrf_token = config.get("csrf_token", "") or "" self._notify_enabled = config.get("notify_enabled", True) self._history = self.get_data("history") or [] if self._scheduler: self._scheduler.shutdown() self._scheduler = None if onlyonce and self._tasks: logger.info("拍卖行上架立即执行一次") self._scheduler = BackgroundScheduler(timezone=settings.TZ) self._scheduler.add_job( func=self.run_all_tasks, trigger='date', run_date=datetime.now(tz=pytz.timezone(settings.TZ)) + timedelta(seconds=3), name="拍卖行上架-立即执行" ) self._scheduler.start() logger.info("调度器已启动,等待3秒后执行") self._onlyonce = False self.update_config({"onlyonce": False}) logger.info("已重置onlyonce状态") self._save_config() def _save_config(self): tasks_json = json.dumps(self._tasks, ensure_ascii=False) self.update_config({ "enabled": self._enabled, "onlyonce": self._onlyonce, "notify_enabled": self._notify_enabled, "global_cron": self._global_cron, "csrf_token": self._csrf_token, "tasks_json": tasks_json }) logger.info(f"配置已保存: tasks_json={tasks_json[:100]}...") def get_state(self) -> bool: return self._enabled def get_api(self) -> List[Dict[str, Any]]: return [ { "path": "/list", "endpoint": self.get_listings, "methods": ["GET"], "auth": "bear", "summary": "获取当前挂单列表", "description": "获取拍卖行当前挂单列表", }, { "path": "/create", "endpoint": self.create_listing, "methods": ["POST"], "auth": "bear", "summary": "手动上架商品", "description": "手动上架商品到拍卖行", }, { "path": "/run", "endpoint": self.run_all_tasks, "methods": ["POST"], "auth": "bear", "summary": "执行所有配置", "description": "执行所有上架配置", } ] def get_form(self) -> Tuple[List[dict], Dict[str, Any]]: return [ { "component": "VForm", "content": [ { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "enabled", "label": "启用插件", } } ] }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "onlyonce", "label": "立即执行一次", } } ] }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VSwitch", "props": { "model": "notify_enabled", "label": "发送通知", "hide-details": True } } ] }, { "component": "VCol", "props": {"cols": 12, "md": 4}, "content": [ { "component": "VCronField", "props": { "model": "global_cron", "label": "执行周期", "placeholder": "0 9 * * *" } } ] } ] }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextField", "props": { "model": "csrf_token", "label": "CSRF Token", "hint": "从浏览器开发者工具获取" } } ] } ] }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VCardText", "props": { "class": "text-pre-wrap" }, "text": "配置格式:[{\"bonus\": 146285, \"unit\": \"TiB\", \"upload\": 1, \"type\": 2}]\n\n说明:\n- bonus: 挂牌灵石数量\n- unit: 单位,可选值为 \"TiB\"、\"GiB\"\n- upload: 挂牌上传量\n- type: 1出售灵石/2出售上传" } ] } ] }, { "component": "VRow", "content": [ { "component": "VCol", "props": {"cols": 12}, "content": [ { "component": "VTextarea", "props": { "model": "tasks_json", "label": "上架配置列表 (JSON)", "rows": 8 } } ] } ] } ] } ], { "enabled": False, "onlyonce": False, "notify_enabled": True, "global_cron": "", "csrf_token": "", "tasks_json": "[]" } def get_service(self) -> List[Dict[str, Any]]: if not self._enabled: return [] services = [] if self._global_cron and self._global_cron.strip(): cron = self._global_cron.strip() if cron.count(" ") == 4: try: services.append({ "id": "AutoAuction.Global", "name": "拍卖行上架-全局任务", "trigger": CronTrigger.from_crontab(cron), "func": self.run_all_tasks, "kwargs": {}, "misfire_grace_time": self._min_interval_seconds * 2, "max_instances": 1, "coalesce": True }) except Exception as e: logger.error(f"全局cron配置错误: {str(e)}") return services def get_page(self) -> List[dict]: history_by_date = defaultdict(list) for record in self._history: date = record.get("time", "")[:10] history_by_date[date].append(record) items = [] for date in sorted(history_by_date.keys(), reverse=True): records = history_by_date[date] if records: type_text = "出售上传" if records[0].get("type") == 2 else "出售灵石" items.append({ "component": "VListSubheader", "props": {"class": "text-grey"}, "text": f"{date} {type_text}" }) for record in records: time_str = record.get("time", "")[11:] items.append({ "component": "VListItem", "props": { "title": f"上传 {record.get('upload')} {record.get('unit')} | 灵石 {record.get('bonus')} | 上架时间: {time_str}" } }) if not items: items.append({ "component": "VListItem", "props": { "title": "暂无上架记录", "subtitle": "执行上架后将显示历史记录" } }) return [{"component": "VList", "props": {"nav": True}, "content": items}] def stop_service(self): if self._scheduler: self._scheduler.shutdown() self._scheduler = None def _get_zhuque_site(self) -> Optional[Dict[str, Any]]: for site in SitesHelper().get_indexers(): site_url = site.get("url", "") or "" if site_url and self.ZHUQUE_DOMAIN in site_url: return site return None def _get_zhuque_cookie(self) -> Optional[str]: site = self._get_zhuque_site() if site: return site.get("cookie") return None def _get_csrf_token(self) -> Optional[str]: if self._csrf_token: return self._csrf_token cookie = self._get_zhuque_cookie() if not cookie: return None try: req = RequestUtils(cookies=cookie, headers={"User-Agent": "Mozilla/5.0"}) res = req.get_res(url="https://zhuque.in/bonus/transaction/upload") if res and res.status_code == 200: html = res.text patterns = [ r'x-csrf-token["\']?\s*[:=]\s*["\']([^"\']+)["\']', r']*name=["\']csrf-token["\'][^>]*content=["\']([^"\']+)["\']', ] for pattern in patterns: match = re.search(pattern, html, re.IGNORECASE) if match: return match.group(1) return None except Exception as e: logger.error(f"获取CSRF Token异常: {str(e)}") return None def get_listings(self, page: int = 1, size: int = 20, type: int = 2) -> Dict[str, Any]: cookie = self._get_zhuque_cookie() if not cookie: return {"success": False, "message": "站点Cookie不存在"} try: res = RequestUtils( cookies=cookie, headers={"User-Agent": "Mozilla/5.0"} ).get_res(url=f"{self.LIST_API}?page={page}&size={size}&type={type}&onlyUnsold=true&onlyRelated=false") if res and res.status_code == 200: data = res.json() return {"success": True, "data": data} else: return {"success": False, "message": f"获取列表失败: {res.status_code if res else '无响应'}"} except Exception as e: logger.error(f"获取挂单列表失败: {str(e)}") return {"success": False, "message": f"获取列表异常: {str(e)}"} def create_listing(self, bonus: int = None, unit: str = None, upload: int = None, type: int = 2) -> Dict[str, Any]: cookie = self._get_zhuque_cookie() csrf_token = self._get_csrf_token() if not cookie: logger.error("站点Cookie不存在") return {"success": False, "message": "站点Cookie不存在"} payload = { "type": type, "unit": unit or "TiB", "bonus": bonus or 0, "upload": upload or 1 } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36 Edg/147.0.0.0", "Content-Type": "application/json", "Accept": "application/json, text/plain, */*", "Origin": "https://zhuque.in", "Referer": "https://zhuque.in/bonus/transaction/upload", "Sec-Fetch-Mode": "cors", "Sec-Fetch-Site": "same-origin", } if csrf_token: headers["x-csrf-token"] = csrf_token try: req = RequestUtils(cookies=cookie, headers=headers) res = req.post_res(url=self.CREATE_API, json=payload) if not res: logger.error("上架请求无响应") return {"success": False, "message": "上架失败: 无响应"} if res.status_code == 200: result = res.json() logger.info(f"上架响应内容: {result}") if result.get("status") == 200: code = result.get("data", {}).get("code") if code == self.CREATE_SUCCESS_CODE: transaction_id = result.get("data", {}).get("transactionId") logger.info(f"上架成功: transactionId={transaction_id}") return {"success": True, "data": result.get("data")} else: logger.error(f"上架失败: code={code}") return {"success": False, "message": f"上架失败: {code}"} else: logger.error(f"上架失败: status={result.get('status')}") return {"success": False, "message": f"上架失败: status={result.get('status')}"} else: logger.error(f"上架失败: {res.status_code if res else '无响应'}") return {"success": False, "message": f"上架失败: {res.status_code if res else '无响应'}"} except Exception as e: logger.error(f"上架异常: {str(e)}") return {"success": False, "message": f"上架异常: {str(e)}"} def run_all_tasks(self) -> Dict[str, Any]: tz = pytz.timezone(settings.TZ) now = datetime.now(tz=tz) current_time = now.timestamp() with self._running_lock: if self._is_running: logger.warn("上架任务正在执行中,跳过本次调用") return {"success": False, "message": "任务正在执行中"} if current_time - self._last_run_time < self._min_interval_seconds: logger.warn(f"上架任务执行间隔太短({self._min_interval_seconds}秒),跳过本次调用") return {"success": False, "message": f"执行间隔太短,请等待{self._min_interval_seconds}秒"} today = now.strftime('%Y-%m-%d') last_run_date = self.get_data("last_run_date") or "" if last_run_date == today: logger.warn(f"今日({today})已执行过上架任务,跳过本次调用") return {"success": False, "message": f"今日({today})已执行过上架任务"} self._is_running = True self._last_run_time = current_time try: logger.info(f"开始执行上架任务,共 {len(self._tasks)} 个配置") results = [] success_records = [] for idx, task in enumerate(self._tasks): result = self.create_listing( bonus=task.get("bonus"), unit=task.get("unit"), upload=task.get("upload"), type=task.get("type", 2) ) if result.get("success"): transaction_id = result.get("data", {}).get("transactionId") record_time = now.strftime("%Y-%m-%d %H:%M:%S") self._history.insert(0, { "upload": task.get("upload"), "bonus": task.get("bonus"), "unit": task.get("unit"), "type": task.get("type", 2), "time": record_time, "transactionId": transaction_id }) if len(self._history) > 50: self._history = self._history[:50] success_records.append(f"上传 {task.get('upload')} {task.get('unit')} | 灵石 {task.get('bonus')} | 上架时间: {record_time[11:]}") results.append({"index": idx + 1, "success": True}) logger.info(f"配置 {idx + 1} 上架成功") else: logger.error(f"配置 {idx + 1} 上架失败: {result.get('message')}") results.append({"index": idx + 1, "success": False, "error": result.get('message')}) if self._history: self.save_data("history", self._history) self.save_data("last_run_date", today) if self._notify_enabled and success_records: try: type_text = "出售上传" if self._tasks[0].get("type", 2) == 2 else "出售灵石" if self._tasks[0].get("type", 2) == 1 else "" text_lines = [f"{today} {type_text}"] for record in success_records: text_lines.append(record) text = "\n".join(text_lines) self.post_message( mtype=NotificationType.Plugin, title="拍卖行上架", text=text ) except Exception as e: logger.error(f"发送通知异常: {str(e)}") return {"success": True, "results": results} finally: self._is_running = False