Upload 33 files
Browse files- Dockerfile +26 -0
- app.py +998 -0
- config.json +23 -0
- core/__init__.py +0 -0
- core/__pycache__/__init__.cpython-313.pyc +0 -0
- core/__pycache__/browser.cpython-313.pyc +0 -0
- core/__pycache__/login.cpython-313.pyc +0 -0
- core/__pycache__/msg_builder.cpython-313.pyc +0 -0
- core/__pycache__/tasks.cpython-313.pyc +0 -0
- core/browser.py +70 -0
- core/login.py +87 -0
- core/msg_builder.py +18 -0
- core/tasks.py +237 -0
- main.py +26 -0
- requirements.txt +20 -0
- static/style.css +447 -0
- templates/admin.html +194 -0
- templates/admin_login.html +69 -0
- templates/dashboard.html +430 -0
- templates/login.html +73 -0
- templates/register.html +82 -0
- utils/__init__.py +0 -0
- utils/__pycache__/__init__.cpython-313.pyc +0 -0
- utils/__pycache__/chinese_new_year_2026_mare.cpython-313.pyc +0 -0
- utils/__pycache__/config.cpython-313.pyc +0 -0
- utils/__pycache__/github_action_config.cpython-313.pyc +0 -0
- utils/__pycache__/hitokoto.cpython-313.pyc +0 -0
- utils/__pycache__/logger.cpython-313.pyc +0 -0
- utils/chinese_new_year_2026_mare.py +933 -0
- utils/config.py +92 -0
- utils/github_action_config.py +49 -0
- utils/hitokoto.py +48 -0
- utils/logger.py +67 -0
Dockerfile
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.11-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
PIP_NO_CACHE_DIR=1 \
|
| 6 |
+
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
|
| 7 |
+
PORT=7860
|
| 8 |
+
|
| 9 |
+
WORKDIR /app
|
| 10 |
+
|
| 11 |
+
COPY requirements.txt /app/requirements.txt
|
| 12 |
+
|
| 13 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 14 |
+
ca-certificates \
|
| 15 |
+
curl \
|
| 16 |
+
fonts-liberation \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/* \
|
| 18 |
+
&& pip install --upgrade pip \
|
| 19 |
+
&& pip install -r /app/requirements.txt \
|
| 20 |
+
&& python -m playwright install --with-deps chromium
|
| 21 |
+
|
| 22 |
+
COPY . /app
|
| 23 |
+
|
| 24 |
+
EXPOSE 7860
|
| 25 |
+
|
| 26 |
+
CMD ["python", "main.py"]
|
app.py
ADDED
|
@@ -0,0 +1,998 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import atexit
|
| 3 |
+
import hashlib
|
| 4 |
+
import json
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
import secrets
|
| 8 |
+
import shutil
|
| 9 |
+
import threading
|
| 10 |
+
import traceback
|
| 11 |
+
from collections import deque
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Optional
|
| 15 |
+
|
| 16 |
+
import uvicorn
|
| 17 |
+
from apscheduler.schedulers.background import BackgroundScheduler
|
| 18 |
+
from apscheduler.triggers.cron import CronTrigger
|
| 19 |
+
from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile, status
|
| 20 |
+
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
| 21 |
+
from fastapi.staticfiles import StaticFiles
|
| 22 |
+
from fastapi.templating import Jinja2Templates
|
| 23 |
+
from pydantic import BaseModel, Field
|
| 24 |
+
|
| 25 |
+
from core.tasks import runTasks
|
| 26 |
+
from utils.logger import setup_logger
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
logger = setup_logger(level=logging.DEBUG)
|
| 30 |
+
|
| 31 |
+
BASE_DIR = Path(__file__).resolve().parent
|
| 32 |
+
TEMPLATES_DIR = BASE_DIR / "templates"
|
| 33 |
+
STATIC_DIR = BASE_DIR / "static"
|
| 34 |
+
ROOT_CONFIG_PATH = BASE_DIR / "config.json"
|
| 35 |
+
DATA_DIR = BASE_DIR / "data"
|
| 36 |
+
TENANTS_DIR = DATA_DIR / "tenants"
|
| 37 |
+
USERS_META_PATH = DATA_DIR / "users.json"
|
| 38 |
+
SESSION_COOKIE_NAME = "sparkflow_auth"
|
| 39 |
+
DEFAULT_TIMEZONE = "Asia/Shanghai"
|
| 40 |
+
MAX_LOG_LINES = 1200
|
| 41 |
+
MAX_TEMPLATE_LENGTH = 2000
|
| 42 |
+
PASSWORD_ITERATIONS = 210000
|
| 43 |
+
|
| 44 |
+
DEFAULT_USER_CONFIG = {
|
| 45 |
+
"multiTask": True,
|
| 46 |
+
"taskCount": 5,
|
| 47 |
+
"proxyAddress": "",
|
| 48 |
+
"messageTemplate": "续火花!!!",
|
| 49 |
+
"hitokotoTypes": ["文学", "影视", "诗词", "哲学"],
|
| 50 |
+
"scheduler": {
|
| 51 |
+
"enabled": True,
|
| 52 |
+
"timezone": DEFAULT_TIMEZONE,
|
| 53 |
+
"hour": 9,
|
| 54 |
+
"minute": 0,
|
| 55 |
+
"runOnStartup": False,
|
| 56 |
+
},
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
AUTH_SESSIONS: dict[str, dict[str, str]] = {}
|
| 60 |
+
data_file_lock = threading.Lock()
|
| 61 |
+
scheduler_lock = threading.Lock()
|
| 62 |
+
runtime_map_lock = threading.Lock()
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class UserRuntimeState:
|
| 66 |
+
def __init__(self, username: str):
|
| 67 |
+
self.username = username
|
| 68 |
+
self._run_lock = threading.Lock()
|
| 69 |
+
self._state_lock = threading.Lock()
|
| 70 |
+
self.is_running = False
|
| 71 |
+
self.last_status = "未开始"
|
| 72 |
+
self.last_error = ""
|
| 73 |
+
self.last_trigger = "-"
|
| 74 |
+
self.last_start = None
|
| 75 |
+
self.last_end = None
|
| 76 |
+
self.next_run = None
|
| 77 |
+
self.schedule_hour = 9
|
| 78 |
+
self.schedule_minute = 0
|
| 79 |
+
self.schedule_timezone = DEFAULT_TIMEZONE
|
| 80 |
+
self.history = deque(maxlen=50)
|
| 81 |
+
self.logs = deque(maxlen=2000)
|
| 82 |
+
|
| 83 |
+
def _format_ts(self, value: Optional[datetime]):
|
| 84 |
+
if not value:
|
| 85 |
+
return "-"
|
| 86 |
+
return value.strftime("%Y-%m-%d %H:%M:%S")
|
| 87 |
+
|
| 88 |
+
def schedule_time(self):
|
| 89 |
+
return f"{self.schedule_hour:02d}:{self.schedule_minute:02d}"
|
| 90 |
+
|
| 91 |
+
def _set_running(self, value: bool):
|
| 92 |
+
with self._state_lock:
|
| 93 |
+
self.is_running = value
|
| 94 |
+
|
| 95 |
+
def add_log(self, message: str):
|
| 96 |
+
ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 97 |
+
with self._state_lock:
|
| 98 |
+
self.logs.append(f"{ts} [{self.username}] {message}")
|
| 99 |
+
|
| 100 |
+
def update_schedule(self, hour: int, minute: int, timezone: str):
|
| 101 |
+
with self._state_lock:
|
| 102 |
+
self.schedule_hour = hour
|
| 103 |
+
self.schedule_minute = minute
|
| 104 |
+
self.schedule_timezone = timezone
|
| 105 |
+
|
| 106 |
+
def update_next_run(self, next_run):
|
| 107 |
+
with self._state_lock:
|
| 108 |
+
self.next_run = next_run
|
| 109 |
+
|
| 110 |
+
def snapshot(self, account_count: int, target_count: int):
|
| 111 |
+
with self._state_lock:
|
| 112 |
+
return {
|
| 113 |
+
"is_running": self.is_running,
|
| 114 |
+
"last_status": self.last_status,
|
| 115 |
+
"last_error": self.last_error,
|
| 116 |
+
"last_trigger": self.last_trigger,
|
| 117 |
+
"last_start": self._format_ts(self.last_start),
|
| 118 |
+
"last_end": self._format_ts(self.last_end),
|
| 119 |
+
"next_run": self._format_ts(self.next_run),
|
| 120 |
+
"account_count": account_count,
|
| 121 |
+
"target_count": target_count,
|
| 122 |
+
"schedule_time": self.schedule_time(),
|
| 123 |
+
"schedule_timezone": self.schedule_timezone,
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
def history_rows(self):
|
| 127 |
+
with self._state_lock:
|
| 128 |
+
return list(self.history)[::-1]
|
| 129 |
+
|
| 130 |
+
def recent_logs(self, limit=MAX_LOG_LINES):
|
| 131 |
+
with self._state_lock:
|
| 132 |
+
lines = list(self.logs)[-max(1, limit):]
|
| 133 |
+
return "\n".join(lines) if lines else "暂无日志。"
|
| 134 |
+
|
| 135 |
+
def run_once(self, trigger: str):
|
| 136 |
+
if not self._run_lock.acquire(blocking=False):
|
| 137 |
+
self.add_log(f"任务已在运行中,忽略触发:{trigger}")
|
| 138 |
+
return False, "已有任务在运行,本次触发已跳过。"
|
| 139 |
+
|
| 140 |
+
self._set_running(True)
|
| 141 |
+
with self._state_lock:
|
| 142 |
+
self.last_trigger = trigger
|
| 143 |
+
self.last_start = datetime.now()
|
| 144 |
+
self.last_end = None
|
| 145 |
+
self.last_error = ""
|
| 146 |
+
self.last_status = "运行中"
|
| 147 |
+
self.add_log(f"任务开始执行,触发方式:{trigger}")
|
| 148 |
+
|
| 149 |
+
ok = True
|
| 150 |
+
message = "任务执行完成。"
|
| 151 |
+
try:
|
| 152 |
+
asyncio.run(_run_user_tasks(self.username))
|
| 153 |
+
with self._state_lock:
|
| 154 |
+
self.last_status = "成功"
|
| 155 |
+
except Exception as exc:
|
| 156 |
+
ok = False
|
| 157 |
+
message = f"任务执行失败:{exc}"
|
| 158 |
+
with self._state_lock:
|
| 159 |
+
self.last_status = "失败"
|
| 160 |
+
self.last_error = repr(exc)
|
| 161 |
+
self.add_log(f"任务失败:{exc}")
|
| 162 |
+
logger.error("Task failed. user=%s trigger=%s error=%s", self.username, trigger, exc)
|
| 163 |
+
logger.debug("Task traceback:\n%s", traceback.format_exc())
|
| 164 |
+
finally:
|
| 165 |
+
end_at = datetime.now()
|
| 166 |
+
with self._state_lock:
|
| 167 |
+
self.last_end = end_at
|
| 168 |
+
duration = (self.last_end - self.last_start).total_seconds()
|
| 169 |
+
self.history.append(
|
| 170 |
+
{
|
| 171 |
+
"trigger": trigger,
|
| 172 |
+
"start": self._format_ts(self.last_start),
|
| 173 |
+
"end": self._format_ts(self.last_end),
|
| 174 |
+
"status": self.last_status,
|
| 175 |
+
"duration": f"{duration:.2f}s",
|
| 176 |
+
"message": self.last_error or "OK",
|
| 177 |
+
}
|
| 178 |
+
)
|
| 179 |
+
current_status = self.last_status
|
| 180 |
+
self.add_log(f"任务结束,状态={current_status},耗时={duration:.2f}s")
|
| 181 |
+
self._set_running(False)
|
| 182 |
+
self._run_lock.release()
|
| 183 |
+
return ok, message
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
runtime_map: dict[str, UserRuntimeState] = {}
|
| 187 |
+
scheduler = None
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
class UserLoginPayload(BaseModel):
|
| 191 |
+
username: str
|
| 192 |
+
password: str
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
class AdminLoginPayload(BaseModel):
|
| 196 |
+
password: str
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
class SchedulePayload(BaseModel):
|
| 200 |
+
time: str
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class MessageTemplatePayload(BaseModel):
|
| 204 |
+
message: str
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
class UserTargetsItem(BaseModel):
|
| 208 |
+
unique_id: str
|
| 209 |
+
targets: list[str] = Field(default_factory=list)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
class UserTargetsPayload(BaseModel):
|
| 213 |
+
users: list[UserTargetsItem]
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def _ensure_data_layout():
|
| 217 |
+
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
| 218 |
+
TENANTS_DIR.mkdir(parents=True, exist_ok=True)
|
| 219 |
+
if not USERS_META_PATH.exists():
|
| 220 |
+
_save_json(USERS_META_PATH, {"users": []})
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
def _load_json(path: Path, default):
|
| 224 |
+
if not path.exists():
|
| 225 |
+
return default
|
| 226 |
+
with path.open("r", encoding="utf-8") as f:
|
| 227 |
+
return json.load(f)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def _save_json(path: Path, payload):
|
| 231 |
+
with data_file_lock:
|
| 232 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 233 |
+
with path.open("w", encoding="utf-8") as f:
|
| 234 |
+
json.dump(payload, f, ensure_ascii=False, indent=2)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _safe_slug(text: str):
|
| 238 |
+
allowed = []
|
| 239 |
+
for ch in text:
|
| 240 |
+
if ch.isalnum() or ch in ("-", "_"):
|
| 241 |
+
allowed.append(ch)
|
| 242 |
+
else:
|
| 243 |
+
allowed.append("_")
|
| 244 |
+
slug = "".join(allowed).strip("_")
|
| 245 |
+
return slug or "user"
|
| 246 |
+
|
| 247 |
+
|
| 248 |
+
def _hash_password(password: str, salt_hex: Optional[str] = None):
|
| 249 |
+
salt = bytes.fromhex(salt_hex) if salt_hex else secrets.token_bytes(16)
|
| 250 |
+
digest = hashlib.pbkdf2_hmac(
|
| 251 |
+
"sha256",
|
| 252 |
+
password.encode("utf-8"),
|
| 253 |
+
salt,
|
| 254 |
+
PASSWORD_ITERATIONS,
|
| 255 |
+
)
|
| 256 |
+
return {
|
| 257 |
+
"salt": salt.hex(),
|
| 258 |
+
"hash": digest.hex(),
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _verify_password(password: str, salt_hex: str, expected_hash: str):
|
| 263 |
+
data = _hash_password(password, salt_hex=salt_hex)
|
| 264 |
+
return secrets.compare_digest(data["hash"], expected_hash)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _load_users_meta():
|
| 268 |
+
_ensure_data_layout()
|
| 269 |
+
raw = _load_json(USERS_META_PATH, {"users": []})
|
| 270 |
+
users = raw.get("users", []) if isinstance(raw, dict) else []
|
| 271 |
+
result = {}
|
| 272 |
+
for item in users:
|
| 273 |
+
username = str(item.get("username", "")).strip()
|
| 274 |
+
if username:
|
| 275 |
+
result[username] = item
|
| 276 |
+
return result
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _save_users_meta(users_map: dict[str, dict[str, Any]]):
|
| 280 |
+
payload = {"users": sorted(users_map.values(), key=lambda x: x.get("username", ""))}
|
| 281 |
+
_save_json(USERS_META_PATH, payload)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def _get_user_meta_or_404(username: str):
|
| 285 |
+
users_map = _load_users_meta()
|
| 286 |
+
user = users_map.get(username)
|
| 287 |
+
if not user:
|
| 288 |
+
raise HTTPException(status_code=404, detail="用户不存在")
|
| 289 |
+
return user
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def _get_tenant_dir(user_meta: dict[str, Any]):
|
| 293 |
+
tenant_rel = user_meta.get("tenant_dir", "")
|
| 294 |
+
if not tenant_rel:
|
| 295 |
+
raise RuntimeError(f"用户 {user_meta.get('username')} 缺少 tenant_dir")
|
| 296 |
+
return (BASE_DIR / tenant_rel).resolve()
|
| 297 |
+
|
| 298 |
+
|
| 299 |
+
def _get_user_config_path(username: str):
|
| 300 |
+
user_meta = _get_user_meta_or_404(username)
|
| 301 |
+
return _get_tenant_dir(user_meta) / "config.json"
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def _get_user_data_path(username: str):
|
| 305 |
+
user_meta = _get_user_meta_or_404(username)
|
| 306 |
+
return _get_tenant_dir(user_meta) / "usersData.json"
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
def _get_default_user_config():
|
| 310 |
+
if ROOT_CONFIG_PATH.exists():
|
| 311 |
+
try:
|
| 312 |
+
return _load_json(ROOT_CONFIG_PATH, DEFAULT_USER_CONFIG)
|
| 313 |
+
except Exception:
|
| 314 |
+
logger.warning("Failed to read root config.json. fallback to DEFAULT_USER_CONFIG")
|
| 315 |
+
return json.loads(json.dumps(DEFAULT_USER_CONFIG, ensure_ascii=False))
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _load_user_config(username: str):
|
| 319 |
+
path = _get_user_config_path(username)
|
| 320 |
+
if not path.exists():
|
| 321 |
+
cfg = _get_default_user_config()
|
| 322 |
+
_save_json(path, cfg)
|
| 323 |
+
return cfg
|
| 324 |
+
return _load_json(path, _get_default_user_config())
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _save_user_config(username: str, cfg: dict):
|
| 328 |
+
path = _get_user_config_path(username)
|
| 329 |
+
_save_json(path, cfg)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
def _load_user_users_data(username: str):
|
| 333 |
+
path = _get_user_data_path(username)
|
| 334 |
+
if not path.exists():
|
| 335 |
+
raise FileNotFoundError(f"用户 {username} 的 usersData.json 不存在")
|
| 336 |
+
data = _load_json(path, [])
|
| 337 |
+
if not isinstance(data, list):
|
| 338 |
+
raise ValueError("usersData.json 必须是数组")
|
| 339 |
+
return data
|
| 340 |
+
|
| 341 |
+
|
| 342 |
+
def _save_user_users_data(username: str, users_data: list):
|
| 343 |
+
path = _get_user_data_path(username)
|
| 344 |
+
_save_json(path, users_data)
|
| 345 |
+
|
| 346 |
+
|
| 347 |
+
def _sanitize_targets(values):
|
| 348 |
+
cleaned = []
|
| 349 |
+
seen = set()
|
| 350 |
+
for value in values or []:
|
| 351 |
+
text = str(value).strip()
|
| 352 |
+
if not text or text in seen:
|
| 353 |
+
continue
|
| 354 |
+
seen.add(text)
|
| 355 |
+
cleaned.append(text)
|
| 356 |
+
return cleaned
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
def _validate_and_normalize_users_data(raw_bytes: bytes):
|
| 360 |
+
try:
|
| 361 |
+
payload = json.loads(raw_bytes.decode("utf-8"))
|
| 362 |
+
except Exception as exc:
|
| 363 |
+
raise ValueError(f"上传文件不是合法 JSON:{exc}")
|
| 364 |
+
|
| 365 |
+
if not isinstance(payload, list) or not payload:
|
| 366 |
+
raise ValueError("usersData.json 必须是非空数组")
|
| 367 |
+
|
| 368 |
+
normalized = []
|
| 369 |
+
for idx, item in enumerate(payload):
|
| 370 |
+
if not isinstance(item, dict):
|
| 371 |
+
raise ValueError(f"第 {idx + 1} 条用户数据格式错误(必须是对象)")
|
| 372 |
+
|
| 373 |
+
unique_id = str(item.get("unique_id", "")).strip()
|
| 374 |
+
username = str(item.get("username", "")).strip()
|
| 375 |
+
cookies = item.get("cookies", [])
|
| 376 |
+
targets = item.get("targets", [])
|
| 377 |
+
|
| 378 |
+
if not unique_id:
|
| 379 |
+
raise ValueError(f"第 {idx + 1} 条缺少 unique_id")
|
| 380 |
+
if not username:
|
| 381 |
+
raise ValueError(f"第 {idx + 1} 条缺少 username")
|
| 382 |
+
if not isinstance(cookies, list) or not cookies:
|
| 383 |
+
raise ValueError(f"第 {idx + 1} 条 cookies 不能为空且必须是数组")
|
| 384 |
+
if not isinstance(targets, list):
|
| 385 |
+
raise ValueError(f"第 {idx + 1} 条 targets 必须是数组")
|
| 386 |
+
|
| 387 |
+
normalized.append(
|
| 388 |
+
{
|
| 389 |
+
"unique_id": unique_id,
|
| 390 |
+
"username": username,
|
| 391 |
+
"cookies": cookies,
|
| 392 |
+
"targets": _sanitize_targets(targets),
|
| 393 |
+
}
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
primary_username = normalized[0]["username"]
|
| 397 |
+
primary_unique_id = normalized[0]["unique_id"]
|
| 398 |
+
return normalized, primary_username, primary_unique_id
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _count_targets(users_data: list):
|
| 402 |
+
return sum(len(user.get("targets", [])) for user in users_data)
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _get_runtime(username: str):
|
| 406 |
+
with runtime_map_lock:
|
| 407 |
+
runtime = runtime_map.get(username)
|
| 408 |
+
if runtime is None:
|
| 409 |
+
runtime = UserRuntimeState(username=username)
|
| 410 |
+
runtime_map[username] = runtime
|
| 411 |
+
return runtime
|
| 412 |
+
|
| 413 |
+
|
| 414 |
+
def _delete_runtime(username: str):
|
| 415 |
+
with runtime_map_lock:
|
| 416 |
+
runtime_map.pop(username, None)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
def _session_from_request(request: Request):
|
| 420 |
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 421 |
+
if not token:
|
| 422 |
+
return None
|
| 423 |
+
return AUTH_SESSIONS.get(token)
|
| 424 |
+
|
| 425 |
+
|
| 426 |
+
def _require_user_session(request: Request):
|
| 427 |
+
session = _session_from_request(request)
|
| 428 |
+
if not session or session.get("role") != "user":
|
| 429 |
+
raise HTTPException(
|
| 430 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 431 |
+
detail="未登录或登录已失效",
|
| 432 |
+
)
|
| 433 |
+
return session
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
def _require_admin_session(request: Request):
|
| 437 |
+
session = _session_from_request(request)
|
| 438 |
+
if not session or session.get("role") != "admin":
|
| 439 |
+
raise HTTPException(
|
| 440 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 441 |
+
detail="未登录或登录已失效",
|
| 442 |
+
)
|
| 443 |
+
return session
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
def _parse_time_string(value: str):
|
| 447 |
+
parts = value.strip().split(":")
|
| 448 |
+
if len(parts) not in (2, 3):
|
| 449 |
+
raise ValueError("时间格式错误,必须是 HH:MM")
|
| 450 |
+
hour = int(parts[0])
|
| 451 |
+
minute = int(parts[1])
|
| 452 |
+
if hour < 0 or hour > 23 or minute < 0 or minute > 59:
|
| 453 |
+
raise ValueError("时间范围错误,小时 0-23,分钟 0-59")
|
| 454 |
+
return hour, minute
|
| 455 |
+
|
| 456 |
+
|
| 457 |
+
def _build_editor_state(username: str):
|
| 458 |
+
cfg = _load_user_config(username)
|
| 459 |
+
users = _load_user_users_data(username)
|
| 460 |
+
return {
|
| 461 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 462 |
+
"users": [
|
| 463 |
+
{
|
| 464 |
+
"unique_id": str(user.get("unique_id", "")),
|
| 465 |
+
"username": str(user.get("username", "未知用户")),
|
| 466 |
+
"targets": _sanitize_targets(user.get("targets", [])),
|
| 467 |
+
}
|
| 468 |
+
for user in users
|
| 469 |
+
],
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
|
| 473 |
+
def _scheduler_job_id(username: str):
|
| 474 |
+
return f"daily_task::{username}"
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _run_scheduled_once(username: str):
|
| 478 |
+
runtime = _get_runtime(username)
|
| 479 |
+
runtime.run_once("schedule")
|
| 480 |
+
if scheduler:
|
| 481 |
+
job = scheduler.get_job(_scheduler_job_id(username))
|
| 482 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 483 |
+
|
| 484 |
+
|
| 485 |
+
async def _run_user_tasks(username: str):
|
| 486 |
+
cfg = _load_user_config(username)
|
| 487 |
+
users_data = _load_user_users_data(username)
|
| 488 |
+
await runTasks(config=cfg, userData=users_data)
|
| 489 |
+
|
| 490 |
+
|
| 491 |
+
def _schedule_user_job(username: str):
|
| 492 |
+
global scheduler
|
| 493 |
+
|
| 494 |
+
cfg = _load_user_config(username)
|
| 495 |
+
scheduler_cfg = cfg.get("scheduler", {}) if isinstance(cfg, dict) else {}
|
| 496 |
+
enabled = bool(scheduler_cfg.get("enabled", True))
|
| 497 |
+
timezone = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 498 |
+
hour = int(scheduler_cfg.get("hour", 9))
|
| 499 |
+
minute = int(scheduler_cfg.get("minute", 0))
|
| 500 |
+
|
| 501 |
+
runtime = _get_runtime(username)
|
| 502 |
+
runtime.update_schedule(hour, minute, timezone)
|
| 503 |
+
|
| 504 |
+
with scheduler_lock:
|
| 505 |
+
if scheduler is None:
|
| 506 |
+
scheduler = BackgroundScheduler(timezone=timezone)
|
| 507 |
+
scheduler.start()
|
| 508 |
+
|
| 509 |
+
job_id = _scheduler_job_id(username)
|
| 510 |
+
if not enabled:
|
| 511 |
+
if scheduler.get_job(job_id):
|
| 512 |
+
scheduler.remove_job(job_id)
|
| 513 |
+
runtime.update_next_run(None)
|
| 514 |
+
runtime.add_log("定时任务已禁用")
|
| 515 |
+
return
|
| 516 |
+
|
| 517 |
+
scheduler.add_job(
|
| 518 |
+
_run_scheduled_once,
|
| 519 |
+
args=[username],
|
| 520 |
+
trigger=CronTrigger(hour=hour, minute=minute, timezone=timezone),
|
| 521 |
+
id=job_id,
|
| 522 |
+
replace_existing=True,
|
| 523 |
+
max_instances=1,
|
| 524 |
+
coalesce=True,
|
| 525 |
+
)
|
| 526 |
+
job = scheduler.get_job(job_id)
|
| 527 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 528 |
+
runtime.add_log(f"定时任务更新为 {hour:02d}:{minute:02d} ({timezone})")
|
| 529 |
+
|
| 530 |
+
|
| 531 |
+
def _remove_user_schedule_job(username: str):
|
| 532 |
+
with scheduler_lock:
|
| 533 |
+
if scheduler is None:
|
| 534 |
+
return
|
| 535 |
+
job_id = _scheduler_job_id(username)
|
| 536 |
+
if scheduler.get_job(job_id):
|
| 537 |
+
scheduler.remove_job(job_id)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def _start_background_run(username: str, trigger: str):
|
| 541 |
+
runtime = _get_runtime(username)
|
| 542 |
+
|
| 543 |
+
def _worker():
|
| 544 |
+
runtime.run_once(trigger)
|
| 545 |
+
if scheduler:
|
| 546 |
+
job = scheduler.get_job(_scheduler_job_id(username))
|
| 547 |
+
runtime.update_next_run(job.next_run_time if job else None)
|
| 548 |
+
|
| 549 |
+
thread = threading.Thread(target=_worker, daemon=True)
|
| 550 |
+
thread.start()
|
| 551 |
+
return True
|
| 552 |
+
|
| 553 |
+
|
| 554 |
+
def _start_scheduler():
|
| 555 |
+
global scheduler
|
| 556 |
+
_ensure_data_layout()
|
| 557 |
+
with scheduler_lock:
|
| 558 |
+
if scheduler is None:
|
| 559 |
+
scheduler = BackgroundScheduler(timezone=DEFAULT_TIMEZONE)
|
| 560 |
+
scheduler.start()
|
| 561 |
+
|
| 562 |
+
users_map = _load_users_meta()
|
| 563 |
+
for username in users_map.keys():
|
| 564 |
+
_schedule_user_job(username)
|
| 565 |
+
cfg = _load_user_config(username)
|
| 566 |
+
run_on_startup = bool(cfg.get("scheduler", {}).get("runOnStartup", False))
|
| 567 |
+
if run_on_startup:
|
| 568 |
+
_start_background_run(username, "startup")
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
def _stop_scheduler():
|
| 572 |
+
global scheduler
|
| 573 |
+
with scheduler_lock:
|
| 574 |
+
if scheduler and scheduler.running:
|
| 575 |
+
scheduler.shutdown(wait=False)
|
| 576 |
+
logger.info("Scheduler stopped.")
|
| 577 |
+
scheduler = None
|
| 578 |
+
|
| 579 |
+
|
| 580 |
+
app = FastAPI(title="DouYin Spark Flow Dashboard")
|
| 581 |
+
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
| 582 |
+
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
| 583 |
+
|
| 584 |
+
|
| 585 |
+
@app.on_event("startup")
|
| 586 |
+
async def on_startup():
|
| 587 |
+
_ensure_data_layout()
|
| 588 |
+
_start_scheduler()
|
| 589 |
+
atexit.register(_stop_scheduler)
|
| 590 |
+
|
| 591 |
+
|
| 592 |
+
@app.on_event("shutdown")
|
| 593 |
+
async def on_shutdown():
|
| 594 |
+
_stop_scheduler()
|
| 595 |
+
|
| 596 |
+
|
| 597 |
+
@app.get("/", response_class=HTMLResponse)
|
| 598 |
+
async def dashboard(request: Request):
|
| 599 |
+
session = _session_from_request(request)
|
| 600 |
+
if not session:
|
| 601 |
+
return RedirectResponse(url="/login", status_code=303)
|
| 602 |
+
if session.get("role") == "admin":
|
| 603 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 604 |
+
|
| 605 |
+
username = session.get("username")
|
| 606 |
+
runtime = _get_runtime(username)
|
| 607 |
+
return templates.TemplateResponse(
|
| 608 |
+
"dashboard.html",
|
| 609 |
+
{
|
| 610 |
+
"request": request,
|
| 611 |
+
"default_time": runtime.schedule_time(),
|
| 612 |
+
"username": username,
|
| 613 |
+
},
|
| 614 |
+
)
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
@app.get("/login", response_class=HTMLResponse)
|
| 618 |
+
async def login_page(request: Request):
|
| 619 |
+
session = _session_from_request(request)
|
| 620 |
+
if session:
|
| 621 |
+
if session.get("role") == "admin":
|
| 622 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 623 |
+
return RedirectResponse(url="/", status_code=303)
|
| 624 |
+
return templates.TemplateResponse("login.html", {"request": request})
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
@app.get("/register", response_class=HTMLResponse)
|
| 628 |
+
async def register_page(request: Request):
|
| 629 |
+
session = _session_from_request(request)
|
| 630 |
+
if session:
|
| 631 |
+
if session.get("role") == "admin":
|
| 632 |
+
return RedirectResponse(url="/admin", status_code=303)
|
| 633 |
+
return RedirectResponse(url="/", status_code=303)
|
| 634 |
+
return templates.TemplateResponse("register.html", {"request": request})
|
| 635 |
+
|
| 636 |
+
|
| 637 |
+
@app.get("/admin", response_class=HTMLResponse)
|
| 638 |
+
async def admin_page(request: Request):
|
| 639 |
+
session = _session_from_request(request)
|
| 640 |
+
if not session or session.get("role") != "admin":
|
| 641 |
+
return templates.TemplateResponse(
|
| 642 |
+
"admin_login.html",
|
| 643 |
+
{
|
| 644 |
+
"request": request,
|
| 645 |
+
"password_missing": not bool(os.getenv("PASSWORD")),
|
| 646 |
+
},
|
| 647 |
+
)
|
| 648 |
+
return templates.TemplateResponse("admin.html", {"request": request})
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
@app.post("/api/login")
|
| 652 |
+
async def api_login(payload: UserLoginPayload):
|
| 653 |
+
username = payload.username.strip()
|
| 654 |
+
if not username:
|
| 655 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "用户名不能为空。"})
|
| 656 |
+
|
| 657 |
+
users_map = _load_users_meta()
|
| 658 |
+
user = users_map.get(username)
|
| 659 |
+
if not user:
|
| 660 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 661 |
+
|
| 662 |
+
if not _verify_password(payload.password, user.get("password_salt", ""), user.get("password_hash", "")):
|
| 663 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "用户名或密码错误。"})
|
| 664 |
+
|
| 665 |
+
token = secrets.token_urlsafe(32)
|
| 666 |
+
AUTH_SESSIONS[token] = {"role": "user", "username": username}
|
| 667 |
+
|
| 668 |
+
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 669 |
+
response.set_cookie(
|
| 670 |
+
key=SESSION_COOKIE_NAME,
|
| 671 |
+
value=token,
|
| 672 |
+
httponly=True,
|
| 673 |
+
samesite="lax",
|
| 674 |
+
max_age=7 * 24 * 3600,
|
| 675 |
+
)
|
| 676 |
+
return response
|
| 677 |
+
|
| 678 |
+
|
| 679 |
+
@app.post("/api/admin/login")
|
| 680 |
+
async def api_admin_login(payload: AdminLoginPayload):
|
| 681 |
+
expected_password = os.getenv("PASSWORD")
|
| 682 |
+
if not expected_password:
|
| 683 |
+
return JSONResponse(
|
| 684 |
+
status_code=500,
|
| 685 |
+
content={"ok": False, "message": "服务端未配置 PASSWORD 环境变量。"},
|
| 686 |
+
)
|
| 687 |
+
|
| 688 |
+
if payload.password != expected_password:
|
| 689 |
+
return JSONResponse(status_code=401, content={"ok": False, "message": "密码错误。"})
|
| 690 |
+
|
| 691 |
+
token = secrets.token_urlsafe(32)
|
| 692 |
+
AUTH_SESSIONS[token] = {"role": "admin", "username": "admin"}
|
| 693 |
+
response = JSONResponse({"ok": True, "message": "登录成功。"})
|
| 694 |
+
response.set_cookie(
|
| 695 |
+
key=SESSION_COOKIE_NAME,
|
| 696 |
+
value=token,
|
| 697 |
+
httponly=True,
|
| 698 |
+
samesite="lax",
|
| 699 |
+
max_age=7 * 24 * 3600,
|
| 700 |
+
)
|
| 701 |
+
return response
|
| 702 |
+
|
| 703 |
+
|
| 704 |
+
@app.post("/api/register")
|
| 705 |
+
async def api_register(password: str = Form(...), users_file: UploadFile = File(...)):
|
| 706 |
+
if len(password.strip()) < 4:
|
| 707 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "密码至少 4 位。"})
|
| 708 |
+
|
| 709 |
+
if not users_file.filename.lower().endswith(".json"):
|
| 710 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "请上传 usersData.json 文件。"})
|
| 711 |
+
|
| 712 |
+
try:
|
| 713 |
+
raw = await users_file.read()
|
| 714 |
+
users_data, username, unique_id = _validate_and_normalize_users_data(raw)
|
| 715 |
+
except Exception as exc:
|
| 716 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 717 |
+
|
| 718 |
+
users_map = _load_users_meta()
|
| 719 |
+
if username in users_map:
|
| 720 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": f"用户名 {username} 已注册。"})
|
| 721 |
+
|
| 722 |
+
for existing in users_map.values():
|
| 723 |
+
if str(existing.get("unique_id", "")).strip() == unique_id:
|
| 724 |
+
return JSONResponse(status_code=409, content={"ok": False, "message": f"unique_id {unique_id} 已注册。"})
|
| 725 |
+
|
| 726 |
+
tenant_slug = _safe_slug(f"{username}_{unique_id}_{secrets.token_hex(3)}")
|
| 727 |
+
tenant_dir = TENANTS_DIR / tenant_slug
|
| 728 |
+
tenant_dir.mkdir(parents=True, exist_ok=True)
|
| 729 |
+
|
| 730 |
+
_save_json(tenant_dir / "usersData.json", users_data)
|
| 731 |
+
default_config = _get_default_user_config()
|
| 732 |
+
default_config.setdefault("scheduler", {})
|
| 733 |
+
default_config["scheduler"].setdefault("enabled", True)
|
| 734 |
+
default_config["scheduler"].setdefault("timezone", DEFAULT_TIMEZONE)
|
| 735 |
+
default_config["scheduler"].setdefault("hour", 9)
|
| 736 |
+
default_config["scheduler"].setdefault("minute", 0)
|
| 737 |
+
default_config["scheduler"].setdefault("runOnStartup", False)
|
| 738 |
+
_save_json(tenant_dir / "config.json", default_config)
|
| 739 |
+
|
| 740 |
+
hash_data = _hash_password(password.strip())
|
| 741 |
+
users_map[username] = {
|
| 742 |
+
"username": username,
|
| 743 |
+
"unique_id": unique_id,
|
| 744 |
+
"password_hash": hash_data["hash"],
|
| 745 |
+
"password_salt": hash_data["salt"],
|
| 746 |
+
"tenant_dir": str(tenant_dir.relative_to(BASE_DIR)).replace("\\", "/"),
|
| 747 |
+
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 748 |
+
}
|
| 749 |
+
_save_users_meta(users_map)
|
| 750 |
+
|
| 751 |
+
_schedule_user_job(username)
|
| 752 |
+
_get_runtime(username).add_log("用户已注册并完成定时任务初始化")
|
| 753 |
+
|
| 754 |
+
return {
|
| 755 |
+
"ok": True,
|
| 756 |
+
"message": "注册成功,请使用用户名和密码登录。",
|
| 757 |
+
"username": username,
|
| 758 |
+
}
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
@app.post("/api/logout")
|
| 762 |
+
async def api_logout(request: Request):
|
| 763 |
+
token = request.cookies.get(SESSION_COOKIE_NAME)
|
| 764 |
+
if token:
|
| 765 |
+
AUTH_SESSIONS.pop(token, None)
|
| 766 |
+
response = JSONResponse({"ok": True})
|
| 767 |
+
response.delete_cookie(SESSION_COOKIE_NAME)
|
| 768 |
+
return response
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
@app.get("/api/status")
|
| 772 |
+
async def api_status(request: Request):
|
| 773 |
+
session = _require_user_session(request)
|
| 774 |
+
username = session["username"]
|
| 775 |
+
runtime = _get_runtime(username)
|
| 776 |
+
users_data = _load_user_users_data(username)
|
| 777 |
+
return {
|
| 778 |
+
"ok": True,
|
| 779 |
+
"runtime": runtime.snapshot(
|
| 780 |
+
account_count=len(users_data),
|
| 781 |
+
target_count=_count_targets(users_data),
|
| 782 |
+
),
|
| 783 |
+
"history": runtime.history_rows(),
|
| 784 |
+
}
|
| 785 |
+
|
| 786 |
+
|
| 787 |
+
@app.get("/api/logs")
|
| 788 |
+
async def api_logs(request: Request, limit: int = MAX_LOG_LINES):
|
| 789 |
+
session = _require_user_session(request)
|
| 790 |
+
username = session["username"]
|
| 791 |
+
runtime = _get_runtime(username)
|
| 792 |
+
limit = min(max(100, limit), 3000)
|
| 793 |
+
return {"ok": True, "logs": runtime.recent_logs(limit=limit)}
|
| 794 |
+
|
| 795 |
+
|
| 796 |
+
@app.post("/api/run")
|
| 797 |
+
async def api_run(request: Request):
|
| 798 |
+
session = _require_user_session(request)
|
| 799 |
+
username = session["username"]
|
| 800 |
+
runtime = _get_runtime(username)
|
| 801 |
+
|
| 802 |
+
if runtime.is_running:
|
| 803 |
+
return JSONResponse(
|
| 804 |
+
status_code=409,
|
| 805 |
+
content={"ok": False, "message": "已有任务正在执行,请稍后再试。"},
|
| 806 |
+
)
|
| 807 |
+
|
| 808 |
+
_start_background_run(username, "manual")
|
| 809 |
+
return {"ok": True, "message": "任务已开始执行。"}
|
| 810 |
+
|
| 811 |
+
|
| 812 |
+
@app.post("/api/schedule")
|
| 813 |
+
async def api_schedule(request: Request, payload: SchedulePayload):
|
| 814 |
+
session = _require_user_session(request)
|
| 815 |
+
username = session["username"]
|
| 816 |
+
|
| 817 |
+
try:
|
| 818 |
+
hour, minute = _parse_time_string(payload.time)
|
| 819 |
+
except Exception as exc:
|
| 820 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": str(exc)})
|
| 821 |
+
|
| 822 |
+
cfg = _load_user_config(username)
|
| 823 |
+
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 824 |
+
scheduler_cfg["enabled"] = True
|
| 825 |
+
scheduler_cfg["hour"] = hour
|
| 826 |
+
scheduler_cfg["minute"] = minute
|
| 827 |
+
scheduler_cfg["timezone"] = str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE))
|
| 828 |
+
scheduler_cfg["runOnStartup"] = bool(scheduler_cfg.get("runOnStartup", False))
|
| 829 |
+
_save_user_config(username, cfg)
|
| 830 |
+
|
| 831 |
+
_schedule_user_job(username)
|
| 832 |
+
runtime = _get_runtime(username)
|
| 833 |
+
return {
|
| 834 |
+
"ok": True,
|
| 835 |
+
"message": f"定时任务已更新为每天 {hour:02d}:{minute:02d}。",
|
| 836 |
+
"time": f"{hour:02d}:{minute:02d}",
|
| 837 |
+
"next_run": runtime.snapshot(0, 0)["next_run"],
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
|
| 841 |
+
@app.get("/api/editor/state")
|
| 842 |
+
async def api_editor_state(request: Request):
|
| 843 |
+
session = _require_user_session(request)
|
| 844 |
+
username = session["username"]
|
| 845 |
+
return {"ok": True, **_build_editor_state(username)}
|
| 846 |
+
|
| 847 |
+
|
| 848 |
+
@app.post("/api/editor/message")
|
| 849 |
+
async def api_editor_message(request: Request, payload: MessageTemplatePayload):
|
| 850 |
+
session = _require_user_session(request)
|
| 851 |
+
username = session["username"]
|
| 852 |
+
|
| 853 |
+
message = payload.message.strip()
|
| 854 |
+
if not message:
|
| 855 |
+
return JSONResponse(status_code=400, content={"ok": False, "message": "消息内容不能为空。"})
|
| 856 |
+
if len(message) > MAX_TEMPLATE_LENGTH:
|
| 857 |
+
return JSONResponse(
|
| 858 |
+
status_code=400,
|
| 859 |
+
content={"ok": False, "message": f"消息内容过长,最多 {MAX_TEMPLATE_LENGTH} 字符。"},
|
| 860 |
+
)
|
| 861 |
+
|
| 862 |
+
cfg = _load_user_config(username)
|
| 863 |
+
cfg["messageTemplate"] = message
|
| 864 |
+
_save_user_config(username, cfg)
|
| 865 |
+
_get_runtime(username).add_log("消息模板已更新")
|
| 866 |
+
return {"ok": True, "message": "消息模板已保存。"}
|
| 867 |
+
|
| 868 |
+
|
| 869 |
+
@app.post("/api/editor/targets")
|
| 870 |
+
async def api_editor_targets(request: Request, payload: UserTargetsPayload):
|
| 871 |
+
session = _require_user_session(request)
|
| 872 |
+
username = session["username"]
|
| 873 |
+
|
| 874 |
+
users_data = _load_user_users_data(username)
|
| 875 |
+
updates = {item.unique_id: _sanitize_targets(item.targets) for item in payload.users}
|
| 876 |
+
|
| 877 |
+
updated = 0
|
| 878 |
+
for user in users_data:
|
| 879 |
+
uid = str(user.get("unique_id", ""))
|
| 880 |
+
if uid in updates:
|
| 881 |
+
user["targets"] = updates[uid]
|
| 882 |
+
updated += 1
|
| 883 |
+
|
| 884 |
+
_save_user_users_data(username, users_data)
|
| 885 |
+
_get_runtime(username).add_log(f"目标好友已更新,涉及账号数:{updated}")
|
| 886 |
+
return {"ok": True, "message": f"目标好友已保存({updated} 个账号)。"}
|
| 887 |
+
|
| 888 |
+
|
| 889 |
+
@app.get("/api/admin/overview")
|
| 890 |
+
async def api_admin_overview(request: Request):
|
| 891 |
+
_require_admin_session(request)
|
| 892 |
+
users_map = _load_users_meta()
|
| 893 |
+
|
| 894 |
+
rows = []
|
| 895 |
+
for username, meta in sorted(users_map.items(), key=lambda x: x[0]):
|
| 896 |
+
try:
|
| 897 |
+
cfg = _load_user_config(username)
|
| 898 |
+
users_data = _load_user_users_data(username)
|
| 899 |
+
except Exception as exc:
|
| 900 |
+
rows.append(
|
| 901 |
+
{
|
| 902 |
+
"username": username,
|
| 903 |
+
"unique_id": meta.get("unique_id", ""),
|
| 904 |
+
"created_at": meta.get("created_at", "-"),
|
| 905 |
+
"error": str(exc),
|
| 906 |
+
}
|
| 907 |
+
)
|
| 908 |
+
continue
|
| 909 |
+
|
| 910 |
+
scheduler_cfg = cfg.get("scheduler", {})
|
| 911 |
+
runtime = _get_runtime(username)
|
| 912 |
+
runtime_snapshot = runtime.snapshot(
|
| 913 |
+
account_count=len(users_data),
|
| 914 |
+
target_count=_count_targets(users_data),
|
| 915 |
+
)
|
| 916 |
+
|
| 917 |
+
receivers = []
|
| 918 |
+
for item in users_data:
|
| 919 |
+
receivers.extend(item.get("targets", []))
|
| 920 |
+
|
| 921 |
+
rows.append(
|
| 922 |
+
{
|
| 923 |
+
"username": username,
|
| 924 |
+
"unique_id": meta.get("unique_id", ""),
|
| 925 |
+
"created_at": meta.get("created_at", "-"),
|
| 926 |
+
"scheduler_enabled": bool(scheduler_cfg.get("enabled", True)),
|
| 927 |
+
"schedule_time": f"{int(scheduler_cfg.get('hour', 9)):02d}:{int(scheduler_cfg.get('minute', 0)):02d}",
|
| 928 |
+
"schedule_timezone": str(scheduler_cfg.get("timezone", DEFAULT_TIMEZONE)),
|
| 929 |
+
"message_template": str(cfg.get("messageTemplate", "")),
|
| 930 |
+
"targets": receivers,
|
| 931 |
+
"target_count": len(receivers),
|
| 932 |
+
"next_run": runtime_snapshot.get("next_run", "-"),
|
| 933 |
+
"last_status": runtime_snapshot.get("last_status", "-"),
|
| 934 |
+
"last_start": runtime_snapshot.get("last_start", "-"),
|
| 935 |
+
"is_running": runtime_snapshot.get("is_running", False),
|
| 936 |
+
}
|
| 937 |
+
)
|
| 938 |
+
|
| 939 |
+
return {
|
| 940 |
+
"ok": True,
|
| 941 |
+
"users": rows,
|
| 942 |
+
"task_count": len(rows),
|
| 943 |
+
}
|
| 944 |
+
|
| 945 |
+
|
| 946 |
+
@app.post("/api/admin/tasks/{username}/delete")
|
| 947 |
+
async def api_admin_delete_task(request: Request, username: str):
|
| 948 |
+
_require_admin_session(request)
|
| 949 |
+
username = username.strip()
|
| 950 |
+
_get_user_meta_or_404(username)
|
| 951 |
+
|
| 952 |
+
cfg = _load_user_config(username)
|
| 953 |
+
scheduler_cfg = cfg.setdefault("scheduler", {})
|
| 954 |
+
scheduler_cfg["enabled"] = False
|
| 955 |
+
_save_user_config(username, cfg)
|
| 956 |
+
|
| 957 |
+
_remove_user_schedule_job(username)
|
| 958 |
+
runtime = _get_runtime(username)
|
| 959 |
+
runtime.update_next_run(None)
|
| 960 |
+
runtime.add_log("管理员已删除(禁用)该用户定时任务")
|
| 961 |
+
|
| 962 |
+
return {"ok": True, "message": f"已删除用户 {username} 的定时任务。"}
|
| 963 |
+
|
| 964 |
+
|
| 965 |
+
@app.delete("/api/admin/users/{username}")
|
| 966 |
+
async def api_admin_delete_user(request: Request, username: str):
|
| 967 |
+
_require_admin_session(request)
|
| 968 |
+
username = username.strip()
|
| 969 |
+
|
| 970 |
+
users_map = _load_users_meta()
|
| 971 |
+
user = users_map.get(username)
|
| 972 |
+
if not user:
|
| 973 |
+
return JSONResponse(status_code=404, content={"ok": False, "message": "用户不存在。"})
|
| 974 |
+
|
| 975 |
+
_remove_user_schedule_job(username)
|
| 976 |
+
tenant_dir = _get_tenant_dir(user)
|
| 977 |
+
if tenant_dir.exists():
|
| 978 |
+
shutil.rmtree(tenant_dir, ignore_errors=True)
|
| 979 |
+
|
| 980 |
+
users_map.pop(username, None)
|
| 981 |
+
_save_users_meta(users_map)
|
| 982 |
+
_delete_runtime(username)
|
| 983 |
+
|
| 984 |
+
return {"ok": True, "message": f"用户 {username} 已删除。"}
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
@app.get("/health")
|
| 988 |
+
async def health():
|
| 989 |
+
return {"ok": True, "status": "alive"}
|
| 990 |
+
|
| 991 |
+
|
| 992 |
+
def run_server():
|
| 993 |
+
port = int(os.getenv("PORT", "7860"))
|
| 994 |
+
uvicorn.run("app:app", host="0.0.0.0", port=port, workers=1)
|
| 995 |
+
|
| 996 |
+
|
| 997 |
+
if __name__ == "__main__":
|
| 998 |
+
run_server()
|
config.json
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"multiTask": true,
|
| 3 |
+
"taskCount": 5,
|
| 4 |
+
"proxyAddress": "",
|
| 5 |
+
"messageTemplate": "续火花!!!",
|
| 6 |
+
"hitokotoTypes": [
|
| 7 |
+
"文学",
|
| 8 |
+
"影视",
|
| 9 |
+
"诗词",
|
| 10 |
+
"哲学"
|
| 11 |
+
],
|
| 12 |
+
"happyNewYear": {
|
| 13 |
+
"enabled": true,
|
| 14 |
+
"messageTemplate": "续火花!!!"
|
| 15 |
+
},
|
| 16 |
+
"scheduler": {
|
| 17 |
+
"enabled": true,
|
| 18 |
+
"timezone": "Asia/Shanghai",
|
| 19 |
+
"hour": 9,
|
| 20 |
+
"minute": 0,
|
| 21 |
+
"runOnStartup": false
|
| 22 |
+
}
|
| 23 |
+
}
|
core/__init__.py
ADDED
|
File without changes
|
core/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (166 Bytes). View file
|
|
|
core/__pycache__/browser.cpython-313.pyc
ADDED
|
Binary file (3.25 kB). View file
|
|
|
core/__pycache__/login.cpython-313.pyc
ADDED
|
Binary file (4.55 kB). View file
|
|
|
core/__pycache__/msg_builder.cpython-313.pyc
ADDED
|
Binary file (891 Bytes). View file
|
|
|
core/__pycache__/tasks.cpython-313.pyc
ADDED
|
Binary file (12.5 kB). View file
|
|
|
core/browser.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import sys
|
| 3 |
+
import traceback
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
|
| 6 |
+
from playwright.async_api import async_playwright
|
| 7 |
+
from rich.console import Console
|
| 8 |
+
|
| 9 |
+
from utils.config import DEBUG, Environment, get_environment
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
PLAYWRIGHT_BROWSERS_PATH = "../chrome"
|
| 13 |
+
console = Console()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def _looks_like_bundled_windows_playwright(path: Path):
|
| 17 |
+
if not path.exists():
|
| 18 |
+
return False
|
| 19 |
+
return any(p.name.startswith("chromium-") for p in path.iterdir())
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _configure_playwright_browser_path(env: Environment):
|
| 23 |
+
# Respect explicit runtime override first.
|
| 24 |
+
if os.getenv("PLAYWRIGHT_BROWSERS_PATH"):
|
| 25 |
+
return
|
| 26 |
+
|
| 27 |
+
# Hugging Face Docker should use browsers installed in the image.
|
| 28 |
+
if env == Environment.HUGGINGFACE:
|
| 29 |
+
return
|
| 30 |
+
|
| 31 |
+
# Local mode may have a bundled playwright browser directory.
|
| 32 |
+
if env == Environment.LOCAL:
|
| 33 |
+
bundled = Path(__file__).resolve().parent / PLAYWRIGHT_BROWSERS_PATH
|
| 34 |
+
# The repo bundles Windows binaries only. Avoid forcing this path on Linux/macOS.
|
| 35 |
+
if os.name == "nt" and _looks_like_bundled_windows_playwright(bundled):
|
| 36 |
+
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(bundled.resolve())
|
| 37 |
+
return
|
| 38 |
+
|
| 39 |
+
# Packed mode keeps runtime assets near executable.
|
| 40 |
+
if env == Environment.PACKED:
|
| 41 |
+
packed = Path(sys.executable).resolve().parent / PLAYWRIGHT_BROWSERS_PATH
|
| 42 |
+
if packed.exists():
|
| 43 |
+
os.environ["PLAYWRIGHT_BROWSERS_PATH"] = str(packed.resolve())
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
async def get_browser(GUI=False):
|
| 47 |
+
headless = True
|
| 48 |
+
env = get_environment()
|
| 49 |
+
_configure_playwright_browser_path(env)
|
| 50 |
+
|
| 51 |
+
if env == Environment.LOCAL and DEBUG:
|
| 52 |
+
headless = False
|
| 53 |
+
if GUI:
|
| 54 |
+
headless = False
|
| 55 |
+
|
| 56 |
+
try:
|
| 57 |
+
playwright = await async_playwright().start()
|
| 58 |
+
browser = await playwright.chromium.launch(
|
| 59 |
+
headless=headless,
|
| 60 |
+
args=[
|
| 61 |
+
"--no-sandbox",
|
| 62 |
+
"--disable-dev-shm-usage",
|
| 63 |
+
"--disable-gpu",
|
| 64 |
+
],
|
| 65 |
+
)
|
| 66 |
+
return playwright, browser
|
| 67 |
+
except Exception:
|
| 68 |
+
traceback.print_exc()
|
| 69 |
+
raise
|
| 70 |
+
|
core/login.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
+
from rich.console import Console
|
| 5 |
+
from core.browser import get_browser
|
| 6 |
+
|
| 7 |
+
xpaths = {
|
| 8 |
+
"unique_id": """xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[3]""",
|
| 9 |
+
"name": """xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]/div/div[2]/div/div[2]/div[1]/div[2]/div[1]/div[1]/div[1]""",
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
console = Console()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def userLogin():
|
| 16 |
+
playwright, browser = await get_browser(GUI=True)
|
| 17 |
+
try:
|
| 18 |
+
context = await browser.new_context()
|
| 19 |
+
page = await context.new_page()
|
| 20 |
+
|
| 21 |
+
# 打开目标页面
|
| 22 |
+
await page.goto("https://creator.douyin.com/")
|
| 23 |
+
|
| 24 |
+
print("请手动登录抖音创作者中心")
|
| 25 |
+
|
| 26 |
+
# 等待页面跳转或特定元素出现
|
| 27 |
+
# 等待 XPath 元素加载完成
|
| 28 |
+
await page.wait_for_selector(
|
| 29 |
+
'xpath=//*[contains(@id, "garfish_app_for_douyin_creator_pc_home")]/div/div[2]/div/div[2]/div[1]',
|
| 30 |
+
timeout=300000, # 设置输入超时时间为 5 分钟
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
# 等待 unique_id 元素加载完成
|
| 34 |
+
unique_id_element = await page.wait_for_selector(xpaths["unique_id"])
|
| 35 |
+
unique_id = (await unique_id_element.inner_text())[
|
| 36 |
+
4:
|
| 37 |
+
] # 去掉前四个字符 "抖音号:"
|
| 38 |
+
print("Unique ID:", unique_id)
|
| 39 |
+
|
| 40 |
+
# 等待 name 元素加载完成
|
| 41 |
+
name_element = await page.wait_for_selector(xpaths["name"])
|
| 42 |
+
username = await name_element.inner_text()
|
| 43 |
+
print("Name:", username)
|
| 44 |
+
|
| 45 |
+
# 获取所有 Cookie
|
| 46 |
+
cookies = await context.cookies()
|
| 47 |
+
print("Cookies:", f"找到 {len(cookies)} 条 Cookie")
|
| 48 |
+
|
| 49 |
+
if os.path.exists("usersData.json"):
|
| 50 |
+
with open("usersData.json", "r", encoding="utf-8") as f:
|
| 51 |
+
userdata = json.load(f)
|
| 52 |
+
else:
|
| 53 |
+
userdata = []
|
| 54 |
+
|
| 55 |
+
targets = input(
|
| 56 |
+
"点击互动管理->私信管理->朋友私信,查看并输入目标好友对应昵称(空格分割)"
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
for user in userdata:
|
| 60 |
+
if user["unique_id"] == unique_id:
|
| 61 |
+
print(f"用户 {unique_id} 已存在,更新信息。")
|
| 62 |
+
user["cookies"] = cookies
|
| 63 |
+
user["username"] = username
|
| 64 |
+
user["targets"] = [target.strip() for target in targets.split(" ")]
|
| 65 |
+
break
|
| 66 |
+
else:
|
| 67 |
+
print(f"添加新用户 {unique_id} 。")
|
| 68 |
+
userdata.append(
|
| 69 |
+
{
|
| 70 |
+
"unique_id": unique_id,
|
| 71 |
+
"username": username,
|
| 72 |
+
"cookies": cookies,
|
| 73 |
+
"targets": [target.strip() for target in targets.split(" ")],
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
with open("usersData.json", "w", encoding="utf-8") as f:
|
| 78 |
+
json.dump(userdata, f, ensure_ascii=False, indent=4)
|
| 79 |
+
|
| 80 |
+
console.print(f"[bold green]登录完成!已添加用户 {username}[/bold green]")
|
| 81 |
+
finally:
|
| 82 |
+
await playwright.stop()
|
| 83 |
+
await browser.close()
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
if __name__ == "__main__":
|
| 87 |
+
asyncio.run(userLogin())
|
core/msg_builder.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
core/msg_builder.py
|
| 3 |
+
解析消息模板构建具体发送的消息内容
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from utils.config import get_config
|
| 9 |
+
from utils.hitokoto import request_hitokoto
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def build_message(config: Optional[dict] = None) -> str:
|
| 13 |
+
active_config = config or get_config()
|
| 14 |
+
message = active_config.get("messageTemplate", "续火花")
|
| 15 |
+
if "[API]" in message:
|
| 16 |
+
api_content = request_hitokoto()
|
| 17 |
+
message = message.replace("[API]", api_content)
|
| 18 |
+
return message.strip()
|
core/tasks.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import traceback
|
| 3 |
+
import logging
|
| 4 |
+
from utils.logger import setup_logger
|
| 5 |
+
from utils.config import get_config, get_userData, reload_config, reload_userData
|
| 6 |
+
from core.msg_builder import build_message
|
| 7 |
+
from core.browser import get_browser
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
complates = {}
|
| 11 |
+
|
| 12 |
+
logger = setup_logger(level=logging.DEBUG)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
async def retry_operation(name, operation, retries=3, delay=2, *args, **kwargs):
|
| 16 |
+
"""
|
| 17 |
+
通用的重试逻辑
|
| 18 |
+
:param name: 操作名称(用于日志记录)
|
| 19 |
+
:param operation: 要执行的异步操作
|
| 20 |
+
:param retries: 最大重试次数
|
| 21 |
+
:param delay: 每次重试之间的延迟(秒)
|
| 22 |
+
:param args: 传递给操作的参数
|
| 23 |
+
:param kwargs: 传递给操作的关键字参数
|
| 24 |
+
"""
|
| 25 |
+
for attempt in range(retries):
|
| 26 |
+
try:
|
| 27 |
+
return await operation(*args, **kwargs)
|
| 28 |
+
except Exception as e:
|
| 29 |
+
if attempt < retries - 1:
|
| 30 |
+
logger.warning(f"{name} 失败,正在重试第 {attempt + 1} 次,错误:{e}")
|
| 31 |
+
await asyncio.sleep(delay)
|
| 32 |
+
else:
|
| 33 |
+
logger.error(f"{name} 失败,已达到最大重试次数,错误:{e}")
|
| 34 |
+
raise
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
async def scroll_and_select_user(page, username, targets):
|
| 38 |
+
"""尝试滚动并查找用户名"""
|
| 39 |
+
# 定义目标元素和滚动容器的选择器
|
| 40 |
+
friends_tab_selector = 'xpath=//*[@id="sub-app"]/div/div/div[1]/div[2]'
|
| 41 |
+
target_selector = 'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]//div[contains(@class, "semi-list-item-body semi-list-item-body-flex-start")]'
|
| 42 |
+
scrollable_friends_selector = 'xpath=//*[@id="sub-app"]/div/div[1]/div[2]/div[2]/div/div/div[3]/div/div/div/ul/div'
|
| 43 |
+
|
| 44 |
+
# [修改] 更加精确的状态选择器
|
| 45 |
+
no_more_selector = 'xpath=//div[contains(@class, "no-more-tip-ftdJnu")]'
|
| 46 |
+
loading_selector = 'xpath=//div[contains(@class, "semi-spin")]'
|
| 47 |
+
|
| 48 |
+
logger.debug(f"账号 {username} 开始查找目标好友列表")
|
| 49 |
+
logger.debug(f"账号 {username} 目标好友列表: {targets}")
|
| 50 |
+
|
| 51 |
+
logger.debug(f"账号 {username} 点击进入好友标签页")
|
| 52 |
+
# 点击好友标签页
|
| 53 |
+
await page.wait_for_selector(friends_tab_selector)
|
| 54 |
+
await page.locator(friends_tab_selector).click()
|
| 55 |
+
|
| 56 |
+
logger.debug(f"账号 {username} 进入好友列表页面")
|
| 57 |
+
|
| 58 |
+
# 确保第一个好友元素加载完成
|
| 59 |
+
first_friend_selector = 'xpath=//*[@id="sub-app"]/div/div/div[2]/div[2]/div/div/div[1]/div/div/div/ul/div/div/div[1]/li/div'
|
| 60 |
+
await page.wait_for_selector(first_friend_selector)
|
| 61 |
+
await page.locator(first_friend_selector).click() # 点击第一个好友,确保列表激活
|
| 62 |
+
|
| 63 |
+
logger.debug(f"账号 {username} 已激活好友列表,开始滚动查找目标好友")
|
| 64 |
+
|
| 65 |
+
await asyncio.sleep(2) # 等待好友列表加载
|
| 66 |
+
|
| 67 |
+
found_usernames = set()
|
| 68 |
+
# [修改] 复制一份目标列表用于追踪进度
|
| 69 |
+
remaining_targets = set(targets)
|
| 70 |
+
|
| 71 |
+
while True:
|
| 72 |
+
# 查找所有目标元素
|
| 73 |
+
target_elements = await page.locator(target_selector).all()
|
| 74 |
+
|
| 75 |
+
for element in target_elements:
|
| 76 |
+
try:
|
| 77 |
+
# 查找子元素 span,模糊匹配 class
|
| 78 |
+
span = element.locator(
|
| 79 |
+
"""xpath=.//span[contains(@class, "item-header-name-")]"""
|
| 80 |
+
)
|
| 81 |
+
targetName = await span.inner_text()
|
| 82 |
+
|
| 83 |
+
if targetName in found_usernames:
|
| 84 |
+
continue # 已处理过,跳过
|
| 85 |
+
found_usernames.add(targetName)
|
| 86 |
+
|
| 87 |
+
logger.debug(f"账号 {username} 找到好友 {targetName}")
|
| 88 |
+
# 检查是否是目标用户名
|
| 89 |
+
if targetName in targets:
|
| 90 |
+
await element.click()
|
| 91 |
+
logger.info(
|
| 92 |
+
f"账号 {username} 选中目标好友 {targetName} 准备开始交互"
|
| 93 |
+
)
|
| 94 |
+
yield targetName
|
| 95 |
+
|
| 96 |
+
# [修改] 标记已找到,如果全找到了直接退出
|
| 97 |
+
if targetName in remaining_targets:
|
| 98 |
+
remaining_targets.remove(targetName)
|
| 99 |
+
if len(remaining_targets) == 0:
|
| 100 |
+
logger.info(f"账号 {username} 所有目标好友均已找到,停止搜索")
|
| 101 |
+
return
|
| 102 |
+
break
|
| 103 |
+
except Exception as e:
|
| 104 |
+
traceback.print_exc()
|
| 105 |
+
else:
|
| 106 |
+
# [修改] 状态检测逻辑
|
| 107 |
+
|
| 108 |
+
# 1. 检查是否到底(没有更多了)
|
| 109 |
+
if await page.locator(no_more_selector).count() > 0:
|
| 110 |
+
logger.info(f"账号 {username} 检测到'没有更多了'标志,已到达底部")
|
| 111 |
+
if len(remaining_targets) > 0:
|
| 112 |
+
logger.warning(f"账号 {username} 搜索结束,仍有以下好友未找到: {remaining_targets}")
|
| 113 |
+
break
|
| 114 |
+
|
| 115 |
+
# 2. 检查是否正在���载
|
| 116 |
+
if await page.locator(loading_selector).count() > 0:
|
| 117 |
+
logger.debug(f"账号 {username} 列表正在加载中 (Loading)...")
|
| 118 |
+
await asyncio.sleep(1.5) # 给加载留点时间
|
| 119 |
+
# 不 break,继续去滚动以触发后续内容
|
| 120 |
+
|
| 121 |
+
# 3. 滚动容器
|
| 122 |
+
scrollable_element = await page.locator(
|
| 123 |
+
scrollable_friends_selector
|
| 124 |
+
).element_handle()
|
| 125 |
+
|
| 126 |
+
if scrollable_element:
|
| 127 |
+
# [修改] 加大滚动幅度
|
| 128 |
+
await page.evaluate(
|
| 129 |
+
"(element) => element.scrollTop += 800", scrollable_element
|
| 130 |
+
)
|
| 131 |
+
logger.debug(f"账号 {username} 滚动好友列表以加载更多好友")
|
| 132 |
+
await asyncio.sleep(1.5)
|
| 133 |
+
else:
|
| 134 |
+
logger.error(f"账号 {username} 未找到滚动容器,退出")
|
| 135 |
+
break
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
async def do_user_task(browser, username, cookies, targets, semaphore, config):
|
| 139 |
+
async with semaphore: # 使用信号量控制并发数量
|
| 140 |
+
context = await browser.new_context() # 每个任务使用独立的上下文
|
| 141 |
+
context.set_default_navigation_timeout(120000) # 设置导航超时时间为 90 秒
|
| 142 |
+
context.set_default_timeout(120000) # 设置所有操作的默认超时时间为 120 秒
|
| 143 |
+
|
| 144 |
+
page = await context.new_page()
|
| 145 |
+
# 打开抖音创作者中心
|
| 146 |
+
await retry_operation(
|
| 147 |
+
"打开抖音创作者中心",
|
| 148 |
+
page.goto,
|
| 149 |
+
retries=3,
|
| 150 |
+
delay=5,
|
| 151 |
+
url="https://creator.douyin.com/",
|
| 152 |
+
)
|
| 153 |
+
# 注入 Cookie
|
| 154 |
+
await context.add_cookies(cookies)
|
| 155 |
+
|
| 156 |
+
# 导航到消息页面
|
| 157 |
+
await retry_operation(
|
| 158 |
+
"导航到消息页面",
|
| 159 |
+
page.goto,
|
| 160 |
+
retries=3,
|
| 161 |
+
delay=5,
|
| 162 |
+
url="https://creator.douyin.com/creator-micro/data/following/chat",
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
logger.info(f"账号 {username} 开始发送消息")
|
| 166 |
+
# 滚动并选择用户
|
| 167 |
+
async for _target_name in scroll_and_select_user(page, username, targets):
|
| 168 |
+
logger.info(f"账号 {username} 已选中好友 {username} 发送消息")
|
| 169 |
+
# 等待 chat-input-dccKiL 元素加载完成
|
| 170 |
+
chat_input_selector = "xpath=//div[contains(@class, 'chat-input-dccKiL')]"
|
| 171 |
+
await page.wait_for_selector(chat_input_selector)
|
| 172 |
+
chat_input = page.locator(chat_input_selector)
|
| 173 |
+
|
| 174 |
+
# 在 chat-input-dccKiL 中输入内容
|
| 175 |
+
message = build_message(config=config)
|
| 176 |
+
for line in message.split("\n"):
|
| 177 |
+
await chat_input.type(line) # 输入每一行
|
| 178 |
+
# 如果不是最后一行,模拟 Shift+Enter 插入换行
|
| 179 |
+
if line != message.split("\n")[-1]:
|
| 180 |
+
await chat_input.press("Shift+Enter") # 模拟 Shift+Enter 插入换行
|
| 181 |
+
|
| 182 |
+
logger.debug(
|
| 183 |
+
f"账号 {username} 准备发送消息给好友 {username}:\n\t{message}"
|
| 184 |
+
)
|
| 185 |
+
logger.info(f"账号 {username} 给好友 {username} 发送消息完成")
|
| 186 |
+
# 模拟按下回车键发送消息
|
| 187 |
+
await chat_input.press("Enter")
|
| 188 |
+
await asyncio.sleep(2) # 发送完等待一会儿
|
| 189 |
+
|
| 190 |
+
await context.close() # 任务完成后关闭上下文
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
async def runTasks(config=None, userData=None):
|
| 194 |
+
active_config = config if config is not None else reload_config()
|
| 195 |
+
active_user_data = userData if userData is not None else reload_userData()
|
| 196 |
+
playwright, browser = await get_browser()
|
| 197 |
+
try:
|
| 198 |
+
# 检查是否启用多任务和任务数量
|
| 199 |
+
# 创建信号量以限制并发任务数量
|
| 200 |
+
logger.info("开始执行任务,当前配置如下:")
|
| 201 |
+
multi_task = bool(active_config.get("multiTask", True))
|
| 202 |
+
task_count = int(active_config.get("taskCount", 1) or 1)
|
| 203 |
+
logger.info(f"多任务模式: {multi_task}, 任务数量: {task_count}")
|
| 204 |
+
logger.info(f"消息模板: {active_config.get('messageTemplate', '')}")
|
| 205 |
+
logger.info(f"一言类型: {active_config.get('hitokotoTypes', [])}")
|
| 206 |
+
for user in active_user_data:
|
| 207 |
+
logger.info(
|
| 208 |
+
f"用户: {user.get('username', '未知用户')}, 目标好友: {user.get('targets', [])}"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
semaphore = asyncio.Semaphore(task_count if multi_task else 1)
|
| 212 |
+
|
| 213 |
+
tasks = []
|
| 214 |
+
for user in active_user_data:
|
| 215 |
+
cookies = user.get("cookies", [])
|
| 216 |
+
targets = user.get("targets", [])
|
| 217 |
+
unique_id = user.get("unique_id", "")
|
| 218 |
+
if not cookies:
|
| 219 |
+
logger.warning("用户 %s 缺少 cookies,已跳过。", user.get("username", "未知用户"))
|
| 220 |
+
continue
|
| 221 |
+
complates[unique_id] = [] # 初始化该用户的已完成列表
|
| 222 |
+
username = user.get("username", "未知用户")
|
| 223 |
+
# 创建任务
|
| 224 |
+
tasks.append(
|
| 225 |
+
do_user_task(browser, username, cookies, targets, semaphore, active_config)
|
| 226 |
+
)
|
| 227 |
+
|
| 228 |
+
# 并发执行任务
|
| 229 |
+
if tasks:
|
| 230 |
+
await asyncio.gather(*tasks)
|
| 231 |
+
else:
|
| 232 |
+
logger.warning("没有可执行的任务(用户数据为空或均缺少 cookies)。")
|
| 233 |
+
finally:
|
| 234 |
+
await playwright.stop()
|
| 235 |
+
|
| 236 |
+
# 关闭浏览器实例
|
| 237 |
+
await browser.close()
|
main.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import argparse
|
| 2 |
+
import asyncio
|
| 3 |
+
|
| 4 |
+
from core.tasks import runTasks
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main():
|
| 8 |
+
parser = argparse.ArgumentParser(description="DouYin Spark Flow")
|
| 9 |
+
parser.add_argument(
|
| 10 |
+
"--doTask",
|
| 11 |
+
action="store_true",
|
| 12 |
+
help="Run tasks once and exit.",
|
| 13 |
+
)
|
| 14 |
+
args = parser.parse_args()
|
| 15 |
+
|
| 16 |
+
if args.doTask:
|
| 17 |
+
asyncio.run(runTasks())
|
| 18 |
+
return
|
| 19 |
+
|
| 20 |
+
from app import run_server
|
| 21 |
+
|
| 22 |
+
run_server()
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
if __name__ == "__main__":
|
| 26 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
certifi==2025.11.12
|
| 2 |
+
charset-normalizer==3.4.4
|
| 3 |
+
colorama==0.4.6
|
| 4 |
+
APScheduler>=3.10,<4
|
| 5 |
+
fastapi>=0.115,<1
|
| 6 |
+
uvicorn[standard]>=0.30,<1
|
| 7 |
+
jinja2>=3.1,<4
|
| 8 |
+
greenlet==3.2.4
|
| 9 |
+
idna==3.11
|
| 10 |
+
markdown-it-py==4.0.0
|
| 11 |
+
mdurl==0.1.2
|
| 12 |
+
playwright==1.56.0
|
| 13 |
+
pyee==13.0.0
|
| 14 |
+
pyperclip==1.11.0
|
| 15 |
+
Pygments==2.19.2
|
| 16 |
+
qrcode==8.2
|
| 17 |
+
requests==2.32.5
|
| 18 |
+
rich==14.2.0
|
| 19 |
+
typing_extensions==4.15.0
|
| 20 |
+
urllib3==2.5.0
|
static/style.css
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--bg-1: #edf5ef;
|
| 3 |
+
--bg-2: #f8efe6;
|
| 4 |
+
--ink: #1f2d2b;
|
| 5 |
+
--muted: #5d6f6b;
|
| 6 |
+
--line: #d2dfda;
|
| 7 |
+
--panel: #ffffff;
|
| 8 |
+
--brand: #106d5d;
|
| 9 |
+
--brand-deep: #0b5347;
|
| 10 |
+
--danger: #b9402b;
|
| 11 |
+
--shadow: 0 14px 30px rgba(16, 83, 71, 0.12);
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
* {
|
| 15 |
+
box-sizing: border-box;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
body {
|
| 19 |
+
margin: 0;
|
| 20 |
+
color: var(--ink);
|
| 21 |
+
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
|
| 22 |
+
background:
|
| 23 |
+
radial-gradient(1200px 600px at 10% 0%, #d9f0e8 0%, transparent 70%),
|
| 24 |
+
radial-gradient(900px 500px at 100% 10%, #fae6d3 0%, transparent 68%),
|
| 25 |
+
linear-gradient(180deg, var(--bg-1), var(--bg-2));
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
.topbar {
|
| 29 |
+
max-width: 1180px;
|
| 30 |
+
margin: 28px auto 16px;
|
| 31 |
+
padding: 18px 22px;
|
| 32 |
+
border-radius: 16px;
|
| 33 |
+
background: linear-gradient(120deg, #0f6959 0%, #157a67 55%, #1f8b73 100%);
|
| 34 |
+
color: #fff;
|
| 35 |
+
box-shadow: 0 18px 36px rgba(15, 105, 89, 0.24);
|
| 36 |
+
display: flex;
|
| 37 |
+
align-items: center;
|
| 38 |
+
justify-content: space-between;
|
| 39 |
+
gap: 16px;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.topbar h1 {
|
| 43 |
+
margin: 0;
|
| 44 |
+
font-size: 28px;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
.topbar p {
|
| 48 |
+
margin: 6px 0 0;
|
| 49 |
+
opacity: 0.92;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.container {
|
| 53 |
+
max-width: 1180px;
|
| 54 |
+
margin: 0 auto 40px;
|
| 55 |
+
display: grid;
|
| 56 |
+
gap: 14px;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.panel,
|
| 60 |
+
.card,
|
| 61 |
+
.login-card {
|
| 62 |
+
background: var(--panel);
|
| 63 |
+
border: 1px solid var(--line);
|
| 64 |
+
border-radius: 14px;
|
| 65 |
+
box-shadow: var(--shadow);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.panel {
|
| 69 |
+
padding: 16px;
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
.panel h2 {
|
| 73 |
+
margin: 0 0 12px;
|
| 74 |
+
font-size: 19px;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.panel-header {
|
| 78 |
+
display: flex;
|
| 79 |
+
align-items: center;
|
| 80 |
+
justify-content: space-between;
|
| 81 |
+
margin-bottom: 10px;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.quick {
|
| 85 |
+
border-left: 5px solid #17856f;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.status-row {
|
| 89 |
+
display: flex;
|
| 90 |
+
align-items: center;
|
| 91 |
+
gap: 12px;
|
| 92 |
+
margin-bottom: 12px;
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
.status-label {
|
| 96 |
+
color: var(--muted);
|
| 97 |
+
font-size: 14px;
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
.badge {
|
| 101 |
+
display: inline-flex;
|
| 102 |
+
align-items: center;
|
| 103 |
+
border-radius: 999px;
|
| 104 |
+
font-size: 12px;
|
| 105 |
+
font-weight: 700;
|
| 106 |
+
padding: 4px 12px;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
.badge.running {
|
| 110 |
+
color: #8a3c0a;
|
| 111 |
+
background: #fde3cd;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
.badge.idle {
|
| 115 |
+
color: #0d5d4f;
|
| 116 |
+
background: #d8f2ec;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.control-grid {
|
| 120 |
+
display: grid;
|
| 121 |
+
grid-template-columns: minmax(220px, 320px) 140px 180px;
|
| 122 |
+
gap: 10px;
|
| 123 |
+
align-items: end;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
.field {
|
| 127 |
+
display: flex;
|
| 128 |
+
flex-direction: column;
|
| 129 |
+
gap: 6px;
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
.field label {
|
| 133 |
+
font-size: 13px;
|
| 134 |
+
color: var(--muted);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
input[type="time"],
|
| 138 |
+
input[type="password"],
|
| 139 |
+
input[type="text"],
|
| 140 |
+
input[type="file"] {
|
| 141 |
+
width: 100%;
|
| 142 |
+
border: 1px solid #bfd2cc;
|
| 143 |
+
border-radius: 10px;
|
| 144 |
+
padding: 10px 11px;
|
| 145 |
+
font-size: 14px;
|
| 146 |
+
background: #fbfffd;
|
| 147 |
+
color: var(--ink);
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
textarea {
|
| 151 |
+
width: 100%;
|
| 152 |
+
border: 1px solid #bfd2cc;
|
| 153 |
+
border-radius: 10px;
|
| 154 |
+
padding: 10px 11px;
|
| 155 |
+
font-size: 14px;
|
| 156 |
+
background: #fbfffd;
|
| 157 |
+
color: var(--ink);
|
| 158 |
+
resize: vertical;
|
| 159 |
+
min-height: 110px;
|
| 160 |
+
font-family: inherit;
|
| 161 |
+
}
|
| 162 |
+
|
| 163 |
+
input:focus {
|
| 164 |
+
outline: none;
|
| 165 |
+
border-color: #34a48e;
|
| 166 |
+
box-shadow: 0 0 0 3px rgba(52, 164, 142, 0.18);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
textarea:focus {
|
| 170 |
+
outline: none;
|
| 171 |
+
border-color: #34a48e;
|
| 172 |
+
box-shadow: 0 0 0 3px rgba(52, 164, 142, 0.18);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
.btn {
|
| 176 |
+
border: 1px solid #95b8af;
|
| 177 |
+
background: #f5fbf8;
|
| 178 |
+
color: #1d4d43;
|
| 179 |
+
border-radius: 10px;
|
| 180 |
+
padding: 9px 14px;
|
| 181 |
+
font-size: 14px;
|
| 182 |
+
cursor: pointer;
|
| 183 |
+
transition: transform .15s ease, box-shadow .2s ease, background .2s ease;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
+
.btn:hover {
|
| 187 |
+
transform: translateY(-1px);
|
| 188 |
+
box-shadow: 0 10px 18px rgba(21, 122, 103, 0.15);
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
.btn.primary {
|
| 192 |
+
border-color: #136957;
|
| 193 |
+
background: linear-gradient(120deg, #117664 0%, #0f8b73 100%);
|
| 194 |
+
color: #fff;
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
.btn.ghost {
|
| 198 |
+
background: rgba(255, 255, 255, 0.18);
|
| 199 |
+
border-color: rgba(255, 255, 255, 0.45);
|
| 200 |
+
color: #fff;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.top-actions {
|
| 204 |
+
display: inline-flex;
|
| 205 |
+
align-items: center;
|
| 206 |
+
gap: 8px;
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
.msg {
|
| 210 |
+
min-height: 18px;
|
| 211 |
+
margin: 10px 0 0;
|
| 212 |
+
font-size: 13px;
|
| 213 |
+
color: #146356;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
.muted {
|
| 217 |
+
color: var(--muted);
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
.mini {
|
| 221 |
+
font-size: 12px;
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
.target-editor {
|
| 225 |
+
display: grid;
|
| 226 |
+
grid-template-columns: repeat(auto-fill, minmax(290px, 1fr));
|
| 227 |
+
gap: 10px;
|
| 228 |
+
}
|
| 229 |
+
|
| 230 |
+
.target-user-card {
|
| 231 |
+
border: 1px solid #d8e5e0;
|
| 232 |
+
border-radius: 12px;
|
| 233 |
+
background: #fbfffd;
|
| 234 |
+
padding: 12px;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.target-user-head {
|
| 238 |
+
display: flex;
|
| 239 |
+
flex-direction: column;
|
| 240 |
+
gap: 4px;
|
| 241 |
+
margin-bottom: 8px;
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
.target-user-head strong {
|
| 245 |
+
font-size: 15px;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
.target-user-head span {
|
| 249 |
+
font-size: 12px;
|
| 250 |
+
color: var(--muted);
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
.target-list {
|
| 254 |
+
display: flex;
|
| 255 |
+
flex-direction: column;
|
| 256 |
+
gap: 6px;
|
| 257 |
+
border: 1px dashed #cadad4;
|
| 258 |
+
border-radius: 10px;
|
| 259 |
+
padding: 8px;
|
| 260 |
+
min-height: 56px;
|
| 261 |
+
margin-bottom: 8px;
|
| 262 |
+
}
|
| 263 |
+
|
| 264 |
+
.target-item {
|
| 265 |
+
display: inline-flex;
|
| 266 |
+
align-items: center;
|
| 267 |
+
gap: 7px;
|
| 268 |
+
font-size: 13px;
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
.add-target-row {
|
| 272 |
+
display: grid;
|
| 273 |
+
grid-template-columns: 1fr 76px;
|
| 274 |
+
gap: 8px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.new-target-input {
|
| 278 |
+
width: 100%;
|
| 279 |
+
border: 1px solid #bfd2cc;
|
| 280 |
+
border-radius: 10px;
|
| 281 |
+
padding: 8px 10px;
|
| 282 |
+
font-size: 13px;
|
| 283 |
+
background: #fff;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
.stats-grid {
|
| 287 |
+
display: grid;
|
| 288 |
+
grid-template-columns: repeat(6, 1fr);
|
| 289 |
+
gap: 10px;
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
.card {
|
| 293 |
+
padding: 14px;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
.card h3 {
|
| 297 |
+
margin: 0;
|
| 298 |
+
color: var(--muted);
|
| 299 |
+
font-size: 13px;
|
| 300 |
+
font-weight: 600;
|
| 301 |
+
}
|
| 302 |
+
|
| 303 |
+
.card p {
|
| 304 |
+
margin: 8px 0 0;
|
| 305 |
+
font-size: 20px;
|
| 306 |
+
font-weight: 700;
|
| 307 |
+
}
|
| 308 |
+
|
| 309 |
+
.table-wrap {
|
| 310 |
+
overflow: auto;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
table {
|
| 314 |
+
width: 100%;
|
| 315 |
+
border-collapse: collapse;
|
| 316 |
+
min-width: 860px;
|
| 317 |
+
}
|
| 318 |
+
|
| 319 |
+
th,
|
| 320 |
+
td {
|
| 321 |
+
text-align: left;
|
| 322 |
+
padding: 10px;
|
| 323 |
+
border-bottom: 1px solid #e3ece9;
|
| 324 |
+
font-size: 13px;
|
| 325 |
+
vertical-align: top;
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
th {
|
| 329 |
+
color: var(--muted);
|
| 330 |
+
font-weight: 600;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
pre {
|
| 334 |
+
margin: 0;
|
| 335 |
+
padding: 14px;
|
| 336 |
+
border-radius: 10px;
|
| 337 |
+
min-height: 340px;
|
| 338 |
+
max-height: 560px;
|
| 339 |
+
overflow: auto;
|
| 340 |
+
background: #0e1a17;
|
| 341 |
+
color: #c5f5e9;
|
| 342 |
+
border: 1px solid #183731;
|
| 343 |
+
font-size: 12px;
|
| 344 |
+
line-height: 1.48;
|
| 345 |
+
font-family: "Consolas", "Monaco", monospace;
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
.login-body {
|
| 349 |
+
min-height: 100vh;
|
| 350 |
+
display: grid;
|
| 351 |
+
place-items: center;
|
| 352 |
+
}
|
| 353 |
+
|
| 354 |
+
.login-shell {
|
| 355 |
+
width: 100%;
|
| 356 |
+
max-width: 420px;
|
| 357 |
+
padding: 16px;
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.login-card {
|
| 361 |
+
padding: 24px;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.login-card h1 {
|
| 365 |
+
margin: 0;
|
| 366 |
+
font-size: 26px;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.login-card .subtitle {
|
| 370 |
+
margin: 8px 0 18px;
|
| 371 |
+
color: var(--muted);
|
| 372 |
+
}
|
| 373 |
+
|
| 374 |
+
.auth-links {
|
| 375 |
+
display: flex;
|
| 376 |
+
justify-content: space-between;
|
| 377 |
+
gap: 10px;
|
| 378 |
+
margin-top: 10px;
|
| 379 |
+
font-size: 13px;
|
| 380 |
+
}
|
| 381 |
+
|
| 382 |
+
.auth-links a {
|
| 383 |
+
color: #116d5d;
|
| 384 |
+
text-decoration: none;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.auth-links a:hover {
|
| 388 |
+
text-decoration: underline;
|
| 389 |
+
}
|
| 390 |
+
|
| 391 |
+
.alert {
|
| 392 |
+
border-radius: 10px;
|
| 393 |
+
padding: 10px 12px;
|
| 394 |
+
margin-bottom: 12px;
|
| 395 |
+
font-size: 13px;
|
| 396 |
+
}
|
| 397 |
+
|
| 398 |
+
.alert.warning {
|
| 399 |
+
color: #7f4a00;
|
| 400 |
+
background: #fff1d9;
|
| 401 |
+
border: 1px solid #ffddad;
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
.admin-actions {
|
| 405 |
+
display: grid;
|
| 406 |
+
gap: 6px;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
.clamp-cell {
|
| 410 |
+
max-width: 260px;
|
| 411 |
+
white-space: pre-wrap;
|
| 412 |
+
word-break: break-word;
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
@media (max-width: 1100px) {
|
| 416 |
+
.stats-grid {
|
| 417 |
+
grid-template-columns: repeat(3, 1fr);
|
| 418 |
+
}
|
| 419 |
+
}
|
| 420 |
+
|
| 421 |
+
@media (max-width: 860px) {
|
| 422 |
+
.topbar {
|
| 423 |
+
margin: 16px 10px 12px;
|
| 424 |
+
border-radius: 14px;
|
| 425 |
+
flex-direction: column;
|
| 426 |
+
align-items: flex-start;
|
| 427 |
+
}
|
| 428 |
+
|
| 429 |
+
.container {
|
| 430 |
+
margin: 0 10px 24px;
|
| 431 |
+
}
|
| 432 |
+
|
| 433 |
+
.control-grid {
|
| 434 |
+
grid-template-columns: 1fr;
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
+
.stats-grid {
|
| 438 |
+
grid-template-columns: repeat(2, 1fr);
|
| 439 |
+
}
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
@media (max-width: 520px) {
|
| 443 |
+
.stats-grid {
|
| 444 |
+
grid-template-columns: 1fr;
|
| 445 |
+
}
|
| 446 |
+
}
|
| 447 |
+
|
templates/admin.html
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DouYin Spark Flow - Admin 控制台</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body class="dash-body">
|
| 10 |
+
<header class="topbar">
|
| 11 |
+
<div>
|
| 12 |
+
<h1>Admin 后台管理</h1>
|
| 13 |
+
<p>用户管理 + 定时任务总览</p>
|
| 14 |
+
</div>
|
| 15 |
+
<div class="top-actions">
|
| 16 |
+
<button id="refreshBtn" class="btn ghost">刷新</button>
|
| 17 |
+
<button id="logoutBtn" class="btn ghost">退出登录</button>
|
| 18 |
+
</div>
|
| 19 |
+
</header>
|
| 20 |
+
|
| 21 |
+
<main class="container">
|
| 22 |
+
<section class="panel">
|
| 23 |
+
<h2>用户与任务总览</h2>
|
| 24 |
+
<p id="summary" class="muted">加载中...</p>
|
| 25 |
+
<div class="table-wrap">
|
| 26 |
+
<table>
|
| 27 |
+
<thead>
|
| 28 |
+
<tr>
|
| 29 |
+
<th>发起用户</th>
|
| 30 |
+
<th>唯一标识</th>
|
| 31 |
+
<th>注册时间</th>
|
| 32 |
+
<th>定时状态</th>
|
| 33 |
+
<th>发送时间</th>
|
| 34 |
+
<th>消息内容</th>
|
| 35 |
+
<th>接收方</th>
|
| 36 |
+
<th>下次执行</th>
|
| 37 |
+
<th>最近状态</th>
|
| 38 |
+
<th>操作</th>
|
| 39 |
+
</tr>
|
| 40 |
+
</thead>
|
| 41 |
+
<tbody id="adminBody">
|
| 42 |
+
<tr><td colspan="10">暂无数据</td></tr>
|
| 43 |
+
</tbody>
|
| 44 |
+
</table>
|
| 45 |
+
</div>
|
| 46 |
+
<p id="adminMsg" class="msg"></p>
|
| 47 |
+
</section>
|
| 48 |
+
</main>
|
| 49 |
+
|
| 50 |
+
<script>
|
| 51 |
+
const adminBody = document.getElementById("adminBody");
|
| 52 |
+
const summary = document.getElementById("summary");
|
| 53 |
+
const adminMsg = document.getElementById("adminMsg");
|
| 54 |
+
|
| 55 |
+
function setMsg(msg, isError = false) {
|
| 56 |
+
adminMsg.textContent = msg || "";
|
| 57 |
+
adminMsg.style.color = isError ? "#c0392b" : "#146356";
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
async function requestJSON(url, options = {}) {
|
| 61 |
+
const resp = await fetch(url, {
|
| 62 |
+
credentials: "same-origin",
|
| 63 |
+
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
| 64 |
+
...options,
|
| 65 |
+
});
|
| 66 |
+
|
| 67 |
+
if (resp.status === 401) {
|
| 68 |
+
window.location.href = "/admin";
|
| 69 |
+
throw new Error("登录已失效,请重新登录。");
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
const data = await resp.json();
|
| 73 |
+
if (!resp.ok || data.ok === false) {
|
| 74 |
+
throw new Error(data.message || "请求失败");
|
| 75 |
+
}
|
| 76 |
+
return data;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
function escapeHtml(value) {
|
| 80 |
+
return String(value ?? "")
|
| 81 |
+
.replace(/&/g, "&")
|
| 82 |
+
.replace(/</g, "<")
|
| 83 |
+
.replace(/>/g, ">")
|
| 84 |
+
.replace(/\"/g, """)
|
| 85 |
+
.replace(/'/g, "'");
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
function renderRows(users) {
|
| 89 |
+
if (!users || users.length === 0) {
|
| 90 |
+
adminBody.innerHTML = '<tr><td colspan="10">暂无用户</td></tr>';
|
| 91 |
+
return;
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
adminBody.innerHTML = users.map((u) => {
|
| 95 |
+
if (u.error) {
|
| 96 |
+
return `
|
| 97 |
+
<tr>
|
| 98 |
+
<td>${escapeHtml(u.username)}</td>
|
| 99 |
+
<td>${escapeHtml(u.unique_id || "-")}</td>
|
| 100 |
+
<td>${escapeHtml(u.created_at || "-")}</td>
|
| 101 |
+
<td colspan="6" style="color:#c0392b;">数据异常:${escapeHtml(u.error)}</td>
|
| 102 |
+
<td>
|
| 103 |
+
<button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删除用户</button>
|
| 104 |
+
</td>
|
| 105 |
+
</tr>
|
| 106 |
+
`;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
const targets = Array.isArray(u.targets) ? u.targets : [];
|
| 110 |
+
const targetText = targets.length ? targets.join(" / ") : "-";
|
| 111 |
+
|
| 112 |
+
return `
|
| 113 |
+
<tr>
|
| 114 |
+
<td>${escapeHtml(u.username)}</td>
|
| 115 |
+
<td>${escapeHtml(u.unique_id)}</td>
|
| 116 |
+
<td>${escapeHtml(u.created_at)}</td>
|
| 117 |
+
<td>${u.scheduler_enabled ? "启用" : "禁用"}</td>
|
| 118 |
+
<td>${escapeHtml(u.schedule_time)} (${escapeHtml(u.schedule_timezone)})</td>
|
| 119 |
+
<td class="clamp-cell">${escapeHtml(u.message_template || "")}</td>
|
| 120 |
+
<td class="clamp-cell">${escapeHtml(targetText)}</td>
|
| 121 |
+
<td>${escapeHtml(u.next_run || "-")}</td>
|
| 122 |
+
<td>${u.is_running ? "运行中" : escapeHtml(u.last_status || "-")}</td>
|
| 123 |
+
<td>
|
| 124 |
+
<div class="admin-actions">
|
| 125 |
+
<button class="btn admin-del-task" data-user="${escapeHtml(u.username)}">删任务</button>
|
| 126 |
+
<button class="btn admin-delete-user" data-user="${escapeHtml(u.username)}">删用户</button>
|
| 127 |
+
</div>
|
| 128 |
+
</td>
|
| 129 |
+
</tr>
|
| 130 |
+
`;
|
| 131 |
+
}).join("");
|
| 132 |
+
|
| 133 |
+
document.querySelectorAll(".admin-del-task").forEach((btn) => {
|
| 134 |
+
btn.addEventListener("click", async () => {
|
| 135 |
+
const username = btn.dataset.user;
|
| 136 |
+
if (!confirm(`确认删除用户 ${username} 的定时任务?`)) return;
|
| 137 |
+
setMsg("正在删除任务...");
|
| 138 |
+
try {
|
| 139 |
+
const data = await requestJSON(`/api/admin/tasks/${encodeURIComponent(username)}/delete`, {
|
| 140 |
+
method: "POST",
|
| 141 |
+
body: "{}",
|
| 142 |
+
});
|
| 143 |
+
setMsg(data.message || "已删除任务");
|
| 144 |
+
await loadOverview();
|
| 145 |
+
} catch (err) {
|
| 146 |
+
setMsg(err.message, true);
|
| 147 |
+
}
|
| 148 |
+
});
|
| 149 |
+
});
|
| 150 |
+
|
| 151 |
+
document.querySelectorAll(".admin-delete-user").forEach((btn) => {
|
| 152 |
+
btn.addEventListener("click", async () => {
|
| 153 |
+
const username = btn.dataset.user;
|
| 154 |
+
if (!confirm(`确认删除用户 ${username}?该操作不可恢复。`)) return;
|
| 155 |
+
setMsg("正在删除用户...");
|
| 156 |
+
try {
|
| 157 |
+
const data = await requestJSON(`/api/admin/users/${encodeURIComponent(username)}`, {
|
| 158 |
+
method: "DELETE",
|
| 159 |
+
});
|
| 160 |
+
setMsg(data.message || "用户已删除");
|
| 161 |
+
await loadOverview();
|
| 162 |
+
} catch (err) {
|
| 163 |
+
setMsg(err.message, true);
|
| 164 |
+
}
|
| 165 |
+
});
|
| 166 |
+
});
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
async function loadOverview() {
|
| 170 |
+
const data = await requestJSON("/api/admin/overview");
|
| 171 |
+
summary.textContent = `共 ${data.task_count || 0} 个用户任务。`;
|
| 172 |
+
renderRows(data.users || []);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
document.getElementById("refreshBtn").addEventListener("click", async () => {
|
| 176 |
+
try {
|
| 177 |
+
await loadOverview();
|
| 178 |
+
} catch (err) {
|
| 179 |
+
setMsg(err.message, true);
|
| 180 |
+
}
|
| 181 |
+
});
|
| 182 |
+
|
| 183 |
+
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
| 184 |
+
await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
|
| 185 |
+
window.location.href = "/admin";
|
| 186 |
+
});
|
| 187 |
+
|
| 188 |
+
loadOverview().catch((err) => setMsg(err.message, true));
|
| 189 |
+
setInterval(() => {
|
| 190 |
+
loadOverview().catch(() => {});
|
| 191 |
+
}, 8000);
|
| 192 |
+
</script>
|
| 193 |
+
</body>
|
| 194 |
+
</html>
|
templates/admin_login.html
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DouYin Spark Flow - Admin 登录</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body class="login-body">
|
| 10 |
+
<main class="login-shell">
|
| 11 |
+
<section class="login-card">
|
| 12 |
+
<h1>管理员登录</h1>
|
| 13 |
+
<p class="subtitle">账号固定为 <code>admin</code>,密码为环境变量 <code>PASSWORD</code></p>
|
| 14 |
+
{% if password_missing %}
|
| 15 |
+
<div class="alert warning">服务端未设置 <code>PASSWORD</code> 环境变量,当前无法登录。</div>
|
| 16 |
+
{% endif %}
|
| 17 |
+
<div class="field">
|
| 18 |
+
<label for="password">管理员密码</label>
|
| 19 |
+
<input id="password" type="password" placeholder="请输入 PASSWORD" autocomplete="current-password">
|
| 20 |
+
</div>
|
| 21 |
+
<button id="loginBtn" class="btn primary">进入后台</button>
|
| 22 |
+
<p id="loginMsg" class="msg"></p>
|
| 23 |
+
<div class="auth-links">
|
| 24 |
+
<a href="/login">返回用户登录</a>
|
| 25 |
+
</div>
|
| 26 |
+
</section>
|
| 27 |
+
</main>
|
| 28 |
+
|
| 29 |
+
<script>
|
| 30 |
+
const loginBtn = document.getElementById("loginBtn");
|
| 31 |
+
const passwordInput = document.getElementById("password");
|
| 32 |
+
const loginMsg = document.getElementById("loginMsg");
|
| 33 |
+
|
| 34 |
+
async function doLogin() {
|
| 35 |
+
const password = passwordInput.value.trim();
|
| 36 |
+
if (!password) {
|
| 37 |
+
loginMsg.textContent = "请输入管理员密码。";
|
| 38 |
+
return;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
loginBtn.disabled = true;
|
| 42 |
+
loginMsg.textContent = "正在校验...";
|
| 43 |
+
try {
|
| 44 |
+
const resp = await fetch("/api/admin/login", {
|
| 45 |
+
method: "POST",
|
| 46 |
+
headers: { "Content-Type": "application/json" },
|
| 47 |
+
body: JSON.stringify({ password }),
|
| 48 |
+
credentials: "same-origin",
|
| 49 |
+
});
|
| 50 |
+
const data = await resp.json();
|
| 51 |
+
if (!resp.ok || !data.ok) {
|
| 52 |
+
throw new Error(data.message || "登录失败");
|
| 53 |
+
}
|
| 54 |
+
loginMsg.textContent = "登录成功,正在跳转...";
|
| 55 |
+
window.location.href = "/admin";
|
| 56 |
+
} catch (err) {
|
| 57 |
+
loginMsg.textContent = "登录失败:" + err.message;
|
| 58 |
+
} finally {
|
| 59 |
+
loginBtn.disabled = false;
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
loginBtn.addEventListener("click", doLogin);
|
| 64 |
+
passwordInput.addEventListener("keydown", (e) => {
|
| 65 |
+
if (e.key === "Enter") doLogin();
|
| 66 |
+
});
|
| 67 |
+
</script>
|
| 68 |
+
</body>
|
| 69 |
+
</html>
|
templates/dashboard.html
ADDED
|
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DouYin Spark Flow - 控制台</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body class="dash-body">
|
| 10 |
+
<header class="topbar">
|
| 11 |
+
<div>
|
| 12 |
+
<h1>DouYin Spark Flow 控制台</h1>
|
| 13 |
+
<p>每日自动任务 + 手动执行 + 实时日志{% if username %} · 当前用户:{{ username }}{% endif %}</p>
|
| 14 |
+
</div>
|
| 15 |
+
<button id="logoutBtn" class="btn ghost">退出登录</button>
|
| 16 |
+
</header>
|
| 17 |
+
|
| 18 |
+
<main class="container">
|
| 19 |
+
<section class="panel quick">
|
| 20 |
+
<div class="status-row">
|
| 21 |
+
<span class="status-label">运行状态</span>
|
| 22 |
+
<span id="runBadge" class="badge idle">空闲</span>
|
| 23 |
+
</div>
|
| 24 |
+
<div class="control-grid">
|
| 25 |
+
<div class="field">
|
| 26 |
+
<label for="taskTime">每日执行时间(北京时间)</label>
|
| 27 |
+
<input id="taskTime" type="time" value="{{ default_time }}">
|
| 28 |
+
</div>
|
| 29 |
+
<button id="saveScheduleBtn" class="btn">保存定时</button>
|
| 30 |
+
<button id="runNowBtn" class="btn primary">立即执行任务</button>
|
| 31 |
+
</div>
|
| 32 |
+
<p id="actionMsg" class="msg"></p>
|
| 33 |
+
</section>
|
| 34 |
+
|
| 35 |
+
<section class="stats-grid">
|
| 36 |
+
<article class="card">
|
| 37 |
+
<h3>账号数量</h3>
|
| 38 |
+
<p id="accountCount">-</p>
|
| 39 |
+
</article>
|
| 40 |
+
<article class="card">
|
| 41 |
+
<h3>目标好友总数</h3>
|
| 42 |
+
<p id="targetCount">-</p>
|
| 43 |
+
</article>
|
| 44 |
+
<article class="card">
|
| 45 |
+
<h3>最近触发方式</h3>
|
| 46 |
+
<p id="lastTrigger">-</p>
|
| 47 |
+
</article>
|
| 48 |
+
<article class="card">
|
| 49 |
+
<h3>最近执行结果</h3>
|
| 50 |
+
<p id="lastStatus">-</p>
|
| 51 |
+
</article>
|
| 52 |
+
<article class="card">
|
| 53 |
+
<h3>最近开始时间</h3>
|
| 54 |
+
<p id="lastStart">-</p>
|
| 55 |
+
</article>
|
| 56 |
+
<article class="card">
|
| 57 |
+
<h3>下一次执行时间</h3>
|
| 58 |
+
<p id="nextRun">-</p>
|
| 59 |
+
</article>
|
| 60 |
+
</section>
|
| 61 |
+
|
| 62 |
+
<section class="panel">
|
| 63 |
+
<div class="panel-header">
|
| 64 |
+
<h2>消息内容编辑</h2>
|
| 65 |
+
<button id="saveMessageBtn" class="btn">保存消息内容</button>
|
| 66 |
+
</div>
|
| 67 |
+
<div class="field">
|
| 68 |
+
<label for="messageTemplate">消息模板(支持换行,支持 [API] 占位符)</label>
|
| 69 |
+
<textarea id="messageTemplate" rows="5" placeholder="请输入发送内容模板"></textarea>
|
| 70 |
+
</div>
|
| 71 |
+
</section>
|
| 72 |
+
|
| 73 |
+
<section class="panel">
|
| 74 |
+
<div class="panel-header">
|
| 75 |
+
<h2>目标好友编辑(勾选即本次生效)</h2>
|
| 76 |
+
<button id="saveTargetsBtn" class="btn">保存目标好友</button>
|
| 77 |
+
</div>
|
| 78 |
+
<div id="targetEditor" class="target-editor">
|
| 79 |
+
<p class="muted">加载中...</p>
|
| 80 |
+
</div>
|
| 81 |
+
<p id="editorMsg" class="msg"></p>
|
| 82 |
+
</section>
|
| 83 |
+
|
| 84 |
+
<section class="panel">
|
| 85 |
+
<h2>运行历史(最多 50 条)</h2>
|
| 86 |
+
<div class="table-wrap">
|
| 87 |
+
<table>
|
| 88 |
+
<thead>
|
| 89 |
+
<tr>
|
| 90 |
+
<th>触发方式</th>
|
| 91 |
+
<th>开始时间</th>
|
| 92 |
+
<th>结束时间</th>
|
| 93 |
+
<th>状态</th>
|
| 94 |
+
<th>耗时</th>
|
| 95 |
+
<th>信息</th>
|
| 96 |
+
</tr>
|
| 97 |
+
</thead>
|
| 98 |
+
<tbody id="historyBody">
|
| 99 |
+
<tr><td colspan="6">暂无记录</td></tr>
|
| 100 |
+
</tbody>
|
| 101 |
+
</table>
|
| 102 |
+
</div>
|
| 103 |
+
</section>
|
| 104 |
+
|
| 105 |
+
<section class="panel">
|
| 106 |
+
<div class="panel-header">
|
| 107 |
+
<h2>实时日志</h2>
|
| 108 |
+
<button id="refreshBtn" class="btn ghost">立即刷新</button>
|
| 109 |
+
</div>
|
| 110 |
+
<pre id="logBox">加载中...</pre>
|
| 111 |
+
</section>
|
| 112 |
+
</main>
|
| 113 |
+
|
| 114 |
+
<script>
|
| 115 |
+
const runBadge = document.getElementById("runBadge");
|
| 116 |
+
const actionMsg = document.getElementById("actionMsg");
|
| 117 |
+
const historyBody = document.getElementById("historyBody");
|
| 118 |
+
const taskTimeInput = document.getElementById("taskTime");
|
| 119 |
+
const logBox = document.getElementById("logBox");
|
| 120 |
+
const accountCount = document.getElementById("accountCount");
|
| 121 |
+
const targetCount = document.getElementById("targetCount");
|
| 122 |
+
const lastTrigger = document.getElementById("lastTrigger");
|
| 123 |
+
const lastStatus = document.getElementById("lastStatus");
|
| 124 |
+
const lastStart = document.getElementById("lastStart");
|
| 125 |
+
const nextRun = document.getElementById("nextRun");
|
| 126 |
+
const messageTemplateInput = document.getElementById("messageTemplate");
|
| 127 |
+
const targetEditor = document.getElementById("targetEditor");
|
| 128 |
+
const editorMsg = document.getElementById("editorMsg");
|
| 129 |
+
let isEditingTime = false;
|
| 130 |
+
|
| 131 |
+
function escapeHtml(value) {
|
| 132 |
+
return String(value ?? "")
|
| 133 |
+
.replace(/&/g, "&")
|
| 134 |
+
.replace(/</g, "<")
|
| 135 |
+
.replace(/>/g, ">")
|
| 136 |
+
.replace(/\"/g, """)
|
| 137 |
+
.replace(/'/g, "'");
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
function targetRowHtml(target, checked = true) {
|
| 141 |
+
const safeTarget = escapeHtml(target);
|
| 142 |
+
return `
|
| 143 |
+
<label class="target-item">
|
| 144 |
+
<input type="checkbox" class="target-checkbox" data-target="${safeTarget}" ${checked ? "checked" : ""}>
|
| 145 |
+
<span>${safeTarget}</span>
|
| 146 |
+
</label>
|
| 147 |
+
`;
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
function setMessage(msg, isError = false) {
|
| 151 |
+
actionMsg.textContent = msg || "";
|
| 152 |
+
actionMsg.style.color = isError ? "#c0392b" : "#146356";
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
function setEditorMessage(msg, isError = false) {
|
| 156 |
+
editorMsg.textContent = msg || "";
|
| 157 |
+
editorMsg.style.color = isError ? "#c0392b" : "#146356";
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
async function requestJSON(url, options = {}) {
|
| 161 |
+
const resp = await fetch(url, {
|
| 162 |
+
credentials: "same-origin",
|
| 163 |
+
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
| 164 |
+
...options,
|
| 165 |
+
});
|
| 166 |
+
if (resp.status === 401) {
|
| 167 |
+
window.location.href = "/login";
|
| 168 |
+
throw new Error("登录已失效,请重新登录。");
|
| 169 |
+
}
|
| 170 |
+
const data = await resp.json();
|
| 171 |
+
if (!resp.ok || data.ok === false) {
|
| 172 |
+
throw new Error(data.message || ("请求失败: " + resp.status));
|
| 173 |
+
}
|
| 174 |
+
return data;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
function renderStatus(runtime) {
|
| 178 |
+
runBadge.textContent = runtime.is_running ? "运行中" : "空闲";
|
| 179 |
+
runBadge.className = runtime.is_running ? "badge running" : "badge idle";
|
| 180 |
+
accountCount.textContent = runtime.account_count;
|
| 181 |
+
targetCount.textContent = runtime.target_count;
|
| 182 |
+
lastTrigger.textContent = runtime.last_trigger;
|
| 183 |
+
lastStatus.textContent = runtime.last_status;
|
| 184 |
+
lastStart.textContent = runtime.last_start;
|
| 185 |
+
nextRun.textContent = runtime.next_run;
|
| 186 |
+
if (!isEditingTime) {
|
| 187 |
+
taskTimeInput.value = runtime.schedule_time;
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function renderHistory(rows) {
|
| 192 |
+
if (!rows || rows.length === 0) {
|
| 193 |
+
historyBody.innerHTML = '<tr><td colspan="6">暂无记录</td></tr>';
|
| 194 |
+
return;
|
| 195 |
+
}
|
| 196 |
+
historyBody.innerHTML = rows
|
| 197 |
+
.map(
|
| 198 |
+
(row) => `
|
| 199 |
+
<tr>
|
| 200 |
+
<td>${row.trigger}</td>
|
| 201 |
+
<td>${row.start}</td>
|
| 202 |
+
<td>${row.end}</td>
|
| 203 |
+
<td>${row.status}</td>
|
| 204 |
+
<td>${row.duration}</td>
|
| 205 |
+
<td>${row.message}</td>
|
| 206 |
+
</tr>
|
| 207 |
+
`,
|
| 208 |
+
)
|
| 209 |
+
.join("");
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
function renderTargetEditor(users) {
|
| 213 |
+
if (!users || users.length === 0) {
|
| 214 |
+
targetEditor.innerHTML = '<p class="muted">暂无账号数据,请先完成登录并写入 usersData.json。</p>';
|
| 215 |
+
return;
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
targetEditor.innerHTML = users
|
| 219 |
+
.map((user) => {
|
| 220 |
+
const username = escapeHtml(user.username || "未知用户");
|
| 221 |
+
const uniqueId = escapeHtml(user.unique_id || "");
|
| 222 |
+
const targets = Array.isArray(user.targets) ? user.targets : [];
|
| 223 |
+
const targetList = targets.length
|
| 224 |
+
? targets.map((item) => targetRowHtml(item, true)).join("")
|
| 225 |
+
: '<p class="muted mini">暂无目标,可在下方手动添加。</p>';
|
| 226 |
+
return `
|
| 227 |
+
<article class="target-user-card" data-uid="${uniqueId}">
|
| 228 |
+
<div class="target-user-head">
|
| 229 |
+
<strong>${username}</strong>
|
| 230 |
+
<span>${uniqueId}</span>
|
| 231 |
+
</div>
|
| 232 |
+
<div class="target-list">${targetList}</div>
|
| 233 |
+
<div class="add-target-row">
|
| 234 |
+
<input type="text" class="new-target-input" placeholder="输入好友昵称后点击添加">
|
| 235 |
+
<button type="button" class="btn add-target-btn">添加</button>
|
| 236 |
+
</div>
|
| 237 |
+
</article>
|
| 238 |
+
`;
|
| 239 |
+
})
|
| 240 |
+
.join("");
|
| 241 |
+
|
| 242 |
+
targetEditor.querySelectorAll(".add-target-btn").forEach((btn) => {
|
| 243 |
+
btn.addEventListener("click", () => {
|
| 244 |
+
const card = btn.closest(".target-user-card");
|
| 245 |
+
const input = card.querySelector(".new-target-input");
|
| 246 |
+
const list = card.querySelector(".target-list");
|
| 247 |
+
const value = input.value.trim();
|
| 248 |
+
if (!value) {
|
| 249 |
+
setEditorMessage("请输入要添加的目标昵称。", true);
|
| 250 |
+
return;
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
const exists = Array.from(card.querySelectorAll(".target-checkbox")).find(
|
| 254 |
+
(el) => (el.dataset.target || "").trim() === value,
|
| 255 |
+
);
|
| 256 |
+
if (exists) {
|
| 257 |
+
exists.checked = true;
|
| 258 |
+
setEditorMessage(`目标「${value}」已存在,已重新勾选。`);
|
| 259 |
+
} else {
|
| 260 |
+
const muted = list.querySelector(".muted");
|
| 261 |
+
if (muted) muted.remove();
|
| 262 |
+
list.insertAdjacentHTML("beforeend", targetRowHtml(value, true));
|
| 263 |
+
setEditorMessage(`已添加目标「${value}」。`);
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
input.value = "";
|
| 267 |
+
input.focus();
|
| 268 |
+
});
|
| 269 |
+
});
|
| 270 |
+
|
| 271 |
+
targetEditor.querySelectorAll(".new-target-input").forEach((input) => {
|
| 272 |
+
input.addEventListener("keydown", (e) => {
|
| 273 |
+
if (e.key === "Enter") {
|
| 274 |
+
e.preventDefault();
|
| 275 |
+
input.closest(".add-target-row").querySelector(".add-target-btn").click();
|
| 276 |
+
}
|
| 277 |
+
});
|
| 278 |
+
});
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
function collectTargetsPayload() {
|
| 282 |
+
const users = Array.from(document.querySelectorAll(".target-user-card")).map((card) => {
|
| 283 |
+
const uniqueId = (card.dataset.uid || "").trim();
|
| 284 |
+
const targets = Array.from(card.querySelectorAll(".target-checkbox:checked"))
|
| 285 |
+
.map((el) => (el.dataset.target || "").trim())
|
| 286 |
+
.filter(Boolean);
|
| 287 |
+
return { unique_id: uniqueId, targets };
|
| 288 |
+
});
|
| 289 |
+
return { users };
|
| 290 |
+
}
|
| 291 |
+
|
| 292 |
+
async function refreshStatus() {
|
| 293 |
+
const data = await requestJSON("/api/status");
|
| 294 |
+
renderStatus(data.runtime);
|
| 295 |
+
renderHistory(data.history);
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
+
async function refreshLogs() {
|
| 299 |
+
const data = await requestJSON("/api/logs?limit=1200");
|
| 300 |
+
logBox.textContent = data.logs || "暂无日志。";
|
| 301 |
+
logBox.scrollTop = logBox.scrollHeight;
|
| 302 |
+
}
|
| 303 |
+
|
| 304 |
+
async function refreshEditorState() {
|
| 305 |
+
const data = await requestJSON("/api/editor/state");
|
| 306 |
+
messageTemplateInput.value = data.message_template || "";
|
| 307 |
+
renderTargetEditor(data.users || []);
|
| 308 |
+
}
|
| 309 |
+
|
| 310 |
+
async function refreshAll(withEditor = false) {
|
| 311 |
+
try {
|
| 312 |
+
const tasks = [refreshStatus(), refreshLogs()];
|
| 313 |
+
if (withEditor) tasks.push(refreshEditorState());
|
| 314 |
+
await Promise.all(tasks);
|
| 315 |
+
} catch (err) {
|
| 316 |
+
setMessage(err.message, true);
|
| 317 |
+
}
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
async function persistEditorsBeforeRun() {
|
| 321 |
+
const message = messageTemplateInput.value;
|
| 322 |
+
if (!message.trim()) {
|
| 323 |
+
throw new Error("消息内容不能为空,请先填写后再执行任务。");
|
| 324 |
+
}
|
| 325 |
+
await requestJSON("/api/editor/message", {
|
| 326 |
+
method: "POST",
|
| 327 |
+
body: JSON.stringify({ message }),
|
| 328 |
+
});
|
| 329 |
+
|
| 330 |
+
const payload = collectTargetsPayload();
|
| 331 |
+
if (payload.users.length) {
|
| 332 |
+
await requestJSON("/api/editor/targets", {
|
| 333 |
+
method: "POST",
|
| 334 |
+
body: JSON.stringify(payload),
|
| 335 |
+
});
|
| 336 |
+
}
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
document.getElementById("runNowBtn").addEventListener("click", async () => {
|
| 340 |
+
setMessage("正在保存编辑内容并触发任务...");
|
| 341 |
+
try {
|
| 342 |
+
await persistEditorsBeforeRun();
|
| 343 |
+
const data = await requestJSON("/api/run", { method: "POST", body: "{}" });
|
| 344 |
+
setMessage(data.message || "任务已启动。");
|
| 345 |
+
setEditorMessage("已在执行前自动保存消息内容和目标好友。");
|
| 346 |
+
await refreshAll();
|
| 347 |
+
} catch (err) {
|
| 348 |
+
setMessage(err.message, true);
|
| 349 |
+
}
|
| 350 |
+
});
|
| 351 |
+
|
| 352 |
+
document.getElementById("saveScheduleBtn").addEventListener("click", async () => {
|
| 353 |
+
const time = taskTimeInput.value;
|
| 354 |
+
if (!time) {
|
| 355 |
+
setMessage("请先选择时间。", true);
|
| 356 |
+
return;
|
| 357 |
+
}
|
| 358 |
+
isEditingTime = true;
|
| 359 |
+
setMessage("正在保存定时...");
|
| 360 |
+
try {
|
| 361 |
+
const data = await requestJSON("/api/schedule", {
|
| 362 |
+
method: "POST",
|
| 363 |
+
body: JSON.stringify({ time }),
|
| 364 |
+
});
|
| 365 |
+
setMessage(
|
| 366 |
+
(data.message || "定时已更新。") + (data.next_run ? " 下一次执行:" + data.next_run : ""),
|
| 367 |
+
);
|
| 368 |
+
await refreshStatus();
|
| 369 |
+
} catch (err) {
|
| 370 |
+
setMessage(err.message, true);
|
| 371 |
+
} finally {
|
| 372 |
+
isEditingTime = false;
|
| 373 |
+
}
|
| 374 |
+
});
|
| 375 |
+
|
| 376 |
+
document.getElementById("saveMessageBtn").addEventListener("click", async () => {
|
| 377 |
+
const message = messageTemplateInput.value;
|
| 378 |
+
if (!message.trim()) {
|
| 379 |
+
setEditorMessage("消息内容不能为空。", true);
|
| 380 |
+
return;
|
| 381 |
+
}
|
| 382 |
+
setEditorMessage("正在保存消息内容...");
|
| 383 |
+
try {
|
| 384 |
+
const data = await requestJSON("/api/editor/message", {
|
| 385 |
+
method: "POST",
|
| 386 |
+
body: JSON.stringify({ message }),
|
| 387 |
+
});
|
| 388 |
+
setEditorMessage(data.message || "消息模板已保存。");
|
| 389 |
+
} catch (err) {
|
| 390 |
+
setEditorMessage(err.message, true);
|
| 391 |
+
}
|
| 392 |
+
});
|
| 393 |
+
|
| 394 |
+
document.getElementById("saveTargetsBtn").addEventListener("click", async () => {
|
| 395 |
+
const payload = collectTargetsPayload();
|
| 396 |
+
if (!payload.users.length) {
|
| 397 |
+
setEditorMessage("当前没有可保存的账号。", true);
|
| 398 |
+
return;
|
| 399 |
+
}
|
| 400 |
+
setEditorMessage("正在保存目标好友...");
|
| 401 |
+
try {
|
| 402 |
+
const data = await requestJSON("/api/editor/targets", {
|
| 403 |
+
method: "POST",
|
| 404 |
+
body: JSON.stringify(payload),
|
| 405 |
+
});
|
| 406 |
+
setEditorMessage(data.message || "目标好友已保存。");
|
| 407 |
+
await refreshStatus();
|
| 408 |
+
} catch (err) {
|
| 409 |
+
setEditorMessage(err.message, true);
|
| 410 |
+
}
|
| 411 |
+
});
|
| 412 |
+
|
| 413 |
+
document.getElementById("logoutBtn").addEventListener("click", async () => {
|
| 414 |
+
await fetch("/api/logout", { method: "POST", credentials: "same-origin" });
|
| 415 |
+
window.location.href = "/login";
|
| 416 |
+
});
|
| 417 |
+
|
| 418 |
+
document.getElementById("refreshBtn").addEventListener("click", () => refreshAll(true));
|
| 419 |
+
taskTimeInput.addEventListener("focus", () => {
|
| 420 |
+
isEditingTime = true;
|
| 421 |
+
});
|
| 422 |
+
taskTimeInput.addEventListener("blur", () => {
|
| 423 |
+
isEditingTime = false;
|
| 424 |
+
});
|
| 425 |
+
|
| 426 |
+
refreshAll(true);
|
| 427 |
+
setInterval(refreshAll, 5000);
|
| 428 |
+
</script>
|
| 429 |
+
</body>
|
| 430 |
+
</html>
|
templates/login.html
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DouYin Spark Flow - 用户登录</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body class="login-body">
|
| 10 |
+
<main class="login-shell">
|
| 11 |
+
<section class="login-card">
|
| 12 |
+
<h1>DouYin Spark Flow</h1>
|
| 13 |
+
<p class="subtitle">普通用户登录</p>
|
| 14 |
+
<div class="field">
|
| 15 |
+
<label for="username">用户名</label>
|
| 16 |
+
<input id="username" type="text" placeholder="请输入注册时自动提取的用户名" autocomplete="username">
|
| 17 |
+
</div>
|
| 18 |
+
<div class="field">
|
| 19 |
+
<label for="password">登录密码</label>
|
| 20 |
+
<input id="password" type="password" placeholder="请输入注册时设置的密码" autocomplete="current-password">
|
| 21 |
+
</div>
|
| 22 |
+
<button id="loginBtn" class="btn primary">登录控制台</button>
|
| 23 |
+
<p id="loginMsg" class="msg"></p>
|
| 24 |
+
<div class="auth-links">
|
| 25 |
+
<a href="/register">没有账号?去注册</a>
|
| 26 |
+
<a href="/admin">管理员入口</a>
|
| 27 |
+
</div>
|
| 28 |
+
</section>
|
| 29 |
+
</main>
|
| 30 |
+
|
| 31 |
+
<script>
|
| 32 |
+
const loginBtn = document.getElementById("loginBtn");
|
| 33 |
+
const usernameInput = document.getElementById("username");
|
| 34 |
+
const passwordInput = document.getElementById("password");
|
| 35 |
+
const loginMsg = document.getElementById("loginMsg");
|
| 36 |
+
|
| 37 |
+
async function doLogin() {
|
| 38 |
+
const username = usernameInput.value.trim();
|
| 39 |
+
const password = passwordInput.value.trim();
|
| 40 |
+
if (!username || !password) {
|
| 41 |
+
loginMsg.textContent = "请输入用户名和密码。";
|
| 42 |
+
return;
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
loginBtn.disabled = true;
|
| 46 |
+
loginMsg.textContent = "正在校验...";
|
| 47 |
+
try {
|
| 48 |
+
const resp = await fetch("/api/login", {
|
| 49 |
+
method: "POST",
|
| 50 |
+
headers: { "Content-Type": "application/json" },
|
| 51 |
+
body: JSON.stringify({ username, password }),
|
| 52 |
+
credentials: "same-origin"
|
| 53 |
+
});
|
| 54 |
+
const data = await resp.json();
|
| 55 |
+
if (!resp.ok || !data.ok) {
|
| 56 |
+
throw new Error(data.message || "登录失败");
|
| 57 |
+
}
|
| 58 |
+
loginMsg.textContent = "登录成功,正在跳转...";
|
| 59 |
+
window.location.href = "/";
|
| 60 |
+
} catch (err) {
|
| 61 |
+
loginMsg.textContent = "登录失败:" + err.message;
|
| 62 |
+
} finally {
|
| 63 |
+
loginBtn.disabled = false;
|
| 64 |
+
}
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
loginBtn.addEventListener("click", doLogin);
|
| 68 |
+
passwordInput.addEventListener("keydown", (e) => {
|
| 69 |
+
if (e.key === "Enter") doLogin();
|
| 70 |
+
});
|
| 71 |
+
</script>
|
| 72 |
+
</body>
|
| 73 |
+
</html>
|
templates/register.html
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="zh-CN">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<title>DouYin Spark Flow - 用户注册</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/style.css">
|
| 8 |
+
</head>
|
| 9 |
+
<body class="login-body">
|
| 10 |
+
<main class="login-shell">
|
| 11 |
+
<section class="login-card">
|
| 12 |
+
<h1>用户注册</h1>
|
| 13 |
+
<p class="subtitle">上传 <code>usersData.json</code>,系统将自动提取用户名并创建独立任务空间</p>
|
| 14 |
+
|
| 15 |
+
<div class="field">
|
| 16 |
+
<label for="usersFile">上传 usersData.json</label>
|
| 17 |
+
<input id="usersFile" type="file" accept="application/json,.json">
|
| 18 |
+
</div>
|
| 19 |
+
<div class="field">
|
| 20 |
+
<label for="password">设置登录密码</label>
|
| 21 |
+
<input id="password" type="password" placeholder="至少 4 位" autocomplete="new-password">
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<button id="registerBtn" class="btn primary">注册账号</button>
|
| 25 |
+
<p id="registerMsg" class="msg"></p>
|
| 26 |
+
<div class="auth-links">
|
| 27 |
+
<a href="/login">已有账号?去登录</a>
|
| 28 |
+
</div>
|
| 29 |
+
</section>
|
| 30 |
+
</main>
|
| 31 |
+
|
| 32 |
+
<script>
|
| 33 |
+
const registerBtn = document.getElementById("registerBtn");
|
| 34 |
+
const usersFileInput = document.getElementById("usersFile");
|
| 35 |
+
const passwordInput = document.getElementById("password");
|
| 36 |
+
const registerMsg = document.getElementById("registerMsg");
|
| 37 |
+
|
| 38 |
+
async function doRegister() {
|
| 39 |
+
const file = usersFileInput.files[0];
|
| 40 |
+
const password = passwordInput.value.trim();
|
| 41 |
+
if (!file) {
|
| 42 |
+
registerMsg.textContent = "请先上传 usersData.json 文件。";
|
| 43 |
+
return;
|
| 44 |
+
}
|
| 45 |
+
if (!password) {
|
| 46 |
+
registerMsg.textContent = "请输入登录密码。";
|
| 47 |
+
return;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
registerBtn.disabled = true;
|
| 51 |
+
registerMsg.textContent = "正在注册...";
|
| 52 |
+
|
| 53 |
+
try {
|
| 54 |
+
const formData = new FormData();
|
| 55 |
+
formData.append("users_file", file);
|
| 56 |
+
formData.append("password", password);
|
| 57 |
+
|
| 58 |
+
const resp = await fetch("/api/register", {
|
| 59 |
+
method: "POST",
|
| 60 |
+
body: formData,
|
| 61 |
+
credentials: "same-origin",
|
| 62 |
+
});
|
| 63 |
+
const data = await resp.json();
|
| 64 |
+
if (!resp.ok || !data.ok) {
|
| 65 |
+
throw new Error(data.message || "注册失败");
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
registerMsg.textContent = `注册成功,用户名为 ${data.username},正在跳转登录页...`;
|
| 69 |
+
setTimeout(() => {
|
| 70 |
+
window.location.href = "/login";
|
| 71 |
+
}, 1200);
|
| 72 |
+
} catch (err) {
|
| 73 |
+
registerMsg.textContent = "注册失败:" + err.message;
|
| 74 |
+
} finally {
|
| 75 |
+
registerBtn.disabled = false;
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
registerBtn.addEventListener("click", doRegister);
|
| 80 |
+
</script>
|
| 81 |
+
</body>
|
| 82 |
+
</html>
|
utils/__init__.py
ADDED
|
File without changes
|
utils/__pycache__/__init__.cpython-313.pyc
ADDED
|
Binary file (167 Bytes). View file
|
|
|
utils/__pycache__/chinese_new_year_2026_mare.cpython-313.pyc
ADDED
|
Binary file (42.6 kB). View file
|
|
|
utils/__pycache__/config.cpython-313.pyc
ADDED
|
Binary file (4.32 kB). View file
|
|
|
utils/__pycache__/github_action_config.cpython-313.pyc
ADDED
|
Binary file (2.58 kB). View file
|
|
|
utils/__pycache__/hitokoto.cpython-313.pyc
ADDED
|
Binary file (1.84 kB). View file
|
|
|
utils/__pycache__/logger.cpython-313.pyc
ADDED
|
Binary file (3.04 kB). View file
|
|
|
utils/chinese_new_year_2026_mare.py
ADDED
|
@@ -0,0 +1,933 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from datetime import date
|
| 2 |
+
import random
|
| 3 |
+
|
| 4 |
+
# 2026年丙午马年 春节通用文案库 (每日30+条)
|
| 5 |
+
# 特点:全场景通用、无特定对象、强日期贴合度
|
| 6 |
+
SPRING_FESTIVAL_QUOTES = {
|
| 7 |
+
# ==============================================
|
| 8 |
+
# 2026年2月16日 除夕 (岁除/大年夜)
|
| 9 |
+
# 核心:辞旧、团圆、守岁、年夜饭、跨年
|
| 10 |
+
# ==============================================
|
| 11 |
+
date(2026, 2, 16): [
|
| 12 |
+
# 辞旧迎新篇
|
| 13 |
+
"2026丙午除夕,烟火起,照人间,举杯敬此年。",
|
| 14 |
+
"辞别乙巳,拥抱丙午。愿旧岁千般皆如意,新年万事定称心。",
|
| 15 |
+
"最后一天,把所有的遗憾打包封存,把所有的期待整装待发。",
|
| 16 |
+
"岁序更替,华章日新。站在新年的门槛,准备好迎接火热的马年。",
|
| 17 |
+
"长路浩浩荡荡,万物尽可期待。2025圆满谢幕,2026正式启航。",
|
| 18 |
+
"旧岁已展千重锦,新年再进百尺竿。除夕快乐,万事胜意。",
|
| 19 |
+
"将往事清零,与岁月言和。愿新的一年,多喜乐,长安宁。",
|
| 20 |
+
"光阴流转,又逢除夕。愿所有的努力,都能在马年开花结果。",
|
| 21 |
+
"挥手作别旧时路,策马扬鞭新征程。除夕安康,福暖四季。",
|
| 22 |
+
"跨年的钟声即将敲响,愿未来的日子,如骏马奔腾,一往无前。",
|
| 23 |
+
|
| 24 |
+
# 团圆守岁篇
|
| 25 |
+
"万家灯火时,阖家团圆日。今夜围炉话岁,共享人间清欢。",
|
| 26 |
+
"年夜饭的香气,是这一年最温暖的句号。",
|
| 27 |
+
"灯火可亲,饭香扑鼻。愿岁岁年年,共占春风。",
|
| 28 |
+
"守岁至天明,喜乐伴一生。今夜不谈烦恼,只叙团圆。",
|
| 29 |
+
"窗外烟火璀璨,屋内笑语盈盈。这便是人间最好的光景。",
|
| 30 |
+
"一杯屠苏酒,一桌团圆饭。敬过往,敬明天,敬每一个当下。",
|
| 31 |
+
"今夜无眠,唯有欢喜。愿烟火向星辰,所愿皆成真。",
|
| 32 |
+
"饺子滚一滚,福气进家门。除夕的饺子,包进了一整年的好运。",
|
| 33 |
+
"围炉守岁,静待新春。愿马年的第一缕阳光,照亮心底的梦想。",
|
| 34 |
+
"在这个辞旧迎新的夜晚,愿平安与健康,常伴左右。",
|
| 35 |
+
|
| 36 |
+
# 马年预热篇
|
| 37 |
+
"丙午火马,即将登场。愿新的一年,生命力如烈火般旺盛。",
|
| 38 |
+
"金蛇隐去,骏马奔腾。2026,准备好马力全开。",
|
| 39 |
+
"除夕之夜,许下心愿:2026,马不停蹄奔向幸福。",
|
| 40 |
+
"烟火升腾处,金马踏春来。愿新年,胜旧年。",
|
| 41 |
+
"迎接丙午马年,愿前程如骏马驰骋,一马平川。",
|
| 42 |
+
"除夕快乐!金马贺岁,福暖四季,万事胜意。",
|
| 43 |
+
"2026,愿如骏马,不负韶华,驰骋万里。",
|
| 44 |
+
"在这个火热的年份,愿日子过得红红火火,热气腾腾。",
|
| 45 |
+
"策马扬鞭迎新岁,意气风发赴前程。除夕大吉。",
|
| 46 |
+
"准备好,和金马一起,跨越山海,奔赴美好。",
|
| 47 |
+
|
| 48 |
+
# 短句补充篇
|
| 49 |
+
"除夕快乐,2026你好。",
|
| 50 |
+
"烟火年年,岁岁平安。",
|
| 51 |
+
"旧疾当愈,新年可期。",
|
| 52 |
+
"辞旧岁,迎新春,万事兴。",
|
| 53 |
+
"丙午大吉,马到成功。",
|
| 54 |
+
"万家团圆,喜乐安康。",
|
| 55 |
+
"今夜好梦,明天好运。",
|
| 56 |
+
"感恩过往,期待未来。",
|
| 57 |
+
"福满人间,春回大地。",
|
| 58 |
+
"跨年快乐,马年大吉。"
|
| 59 |
+
],
|
| 60 |
+
|
| 61 |
+
# ==============================================
|
| 62 |
+
# 2026年2月17日 正月初一 (春节/元日)
|
| 63 |
+
# 核心:开门见喜、拜年、新岁、龙马精神
|
| 64 |
+
# ==============================================
|
| 65 |
+
date(2026, 2, 17): [
|
| 66 |
+
# 开门见喜篇
|
| 67 |
+
"正月初一,开门见喜。愿2026年的第一天,满载好运与福气。",
|
| 68 |
+
"大年初一,喜气洋洋。推开窗,迎接第一缕春风与阳光。",
|
| 69 |
+
"初一启新程,万事皆顺遂。愿这一年,所求皆如愿。",
|
| 70 |
+
"门迎百福,户纳千祥。马年第一天,好运接踵而至。",
|
| 71 |
+
"初一早,福气到。愿生活明朗,万物可爱。",
|
| 72 |
+
"新岁启封,美好开场。2026,从这喜气洋洋的一天开始。",
|
| 73 |
+
"晨光熹微,年味正浓。初一早安,马年吉祥。",
|
| 74 |
+
"开启新岁的第一份好运,愿平安喜乐,一路随行。",
|
| 75 |
+
"初一开门红,全年万事通。愿日子红红火火,蒸蒸日上。",
|
| 76 |
+
"迎着朝阳,许下心愿:2026,一马当先,万事胜意。",
|
| 77 |
+
|
| 78 |
+
# 龙马精神篇
|
| 79 |
+
"丙午马年,正月初一。愿龙马精神,常驻心间。",
|
| 80 |
+
"2026,做一匹奔腾的骏马,跨越所有障碍,奔向理想。",
|
| 81 |
+
"大年初一,祝龙马精神,身体康健,活力满满。",
|
| 82 |
+
"马年第一天,愿拥有骏马的速度,更有骏马的耐力。",
|
| 83 |
+
"春风得意马蹄疾,一日看尽长安花。新年伊始,意气风发。",
|
| 84 |
+
"以梦为马,���负韶华。初一启程,奔赴山海。",
|
| 85 |
+
"金马迎春,万象更新。愿精神抖擞,迎接每一个挑战。",
|
| 86 |
+
"马到成功,从大年初一做起。每一步,都坚定有力。",
|
| 87 |
+
"愿如骏马,驰骋疆场,所向披靡,收获满满。",
|
| 88 |
+
"正月初一,愿一马平川,前程似锦,无往不利。",
|
| 89 |
+
|
| 90 |
+
# 拜年祈福篇
|
| 91 |
+
"新春大吉,拜年啦。愿这一年,多喜乐,长安宁。",
|
| 92 |
+
"大年初一,送上最真挚的祝福:四季平安,万事顺遂。",
|
| 93 |
+
"初一纳福,愿福气满满,财运亨通,好运连连。",
|
| 94 |
+
"拜年进行时,祝福送不停。愿2026,皆是欢喜。",
|
| 95 |
+
"大年初一,不只是祝福,更是对未来的美好期许。",
|
| 96 |
+
"新的一年,愿日子如熹光,温柔又安详。",
|
| 97 |
+
"初一祈福,愿阖家欢乐,岁月静好,现世安稳。",
|
| 98 |
+
"春风送暖,福气盈门。大年初一,喜乐安康。",
|
| 99 |
+
"给时光拜个年,愿它温柔以待每一个努力的人。",
|
| 100 |
+
"2026的第一声祝福:愿世间美好,与你环环相扣。",
|
| 101 |
+
|
| 102 |
+
# 短句补充篇
|
| 103 |
+
"初一吉祥,马年大吉。",
|
| 104 |
+
"开门纳福,万事亨通。",
|
| 105 |
+
"龙马精神,一马当先。",
|
| 106 |
+
"新春快乐,好事连连。",
|
| 107 |
+
"元日安康,福暖人间。",
|
| 108 |
+
"2026,向阳而生。",
|
| 109 |
+
"大年初一,喜气盈门。",
|
| 110 |
+
"马到成功,前程似锦。",
|
| 111 |
+
"新年第一喜,好运属于你。",
|
| 112 |
+
"喜乐无忧,自在如风。"
|
| 113 |
+
],
|
| 114 |
+
|
| 115 |
+
# ==============================================
|
| 116 |
+
# 2026年2月18日 正月初二 (回门/迎婿)
|
| 117 |
+
# 核心:归家、亲情、欢聚、福满门
|
| 118 |
+
# ==============================================
|
| 119 |
+
date(2026, 2, 18): [
|
| 120 |
+
# 归家欢聚篇
|
| 121 |
+
"正月初二,归宁之喜。带上祝福,奔赴另一场团圆。",
|
| 122 |
+
"初二回门,福气临门。愿每一次归家,都温暖如初。",
|
| 123 |
+
"初一团圆,初二欢聚。亲情的纽带,从未如此紧密。",
|
| 124 |
+
"带上爱与思念,回到温暖的港湾。初二快乐。",
|
| 125 |
+
"正月初二,风和日丽。适合相聚,适合表达爱意。",
|
| 126 |
+
"回门的路,是通往幸福的路。愿一路欢歌,一路笑语。",
|
| 127 |
+
"初二时光,慢煮生活。愿烟火气中,皆是幸福味。",
|
| 128 |
+
"走亲访友,传递温情。初二这一天,装满了爱。",
|
| 129 |
+
"归宁日,欢喜时。愿所有的美好,都恰逢其时。",
|
| 130 |
+
"初二启程,满载欢喜。愿相聚的时光,温柔又绵长。",
|
| 131 |
+
|
| 132 |
+
# 福满双门篇
|
| 133 |
+
"正月初二,福满双门。愿两家喜乐,万事兴隆。",
|
| 134 |
+
"初二迎福,愿福气不仅满盈小家,更福泽大家。",
|
| 135 |
+
"门门有喜,户户纳福。初二这一天,喜气洋洋。",
|
| 136 |
+
"双门纳福,马年吉祥。愿两边的长辈,福寿安康。",
|
| 137 |
+
"正月初二,好事成双。愿快乐加倍,幸福翻倍。",
|
| 138 |
+
"福临门,喜盈户。初二的日子,红红火火。",
|
| 139 |
+
"两家欢喜,一门和气。愿这份福气,延续一整年。",
|
| 140 |
+
"初二接福,愿生活有滋有味,日子顺顺当当。",
|
| 141 |
+
"福气流转,爱意相传。正月初二,吉祥如意。",
|
| 142 |
+
"双福临门,万事胜意。愿马年的每一天,都充满阳光。",
|
| 143 |
+
|
| 144 |
+
# 春日随行篇
|
| 145 |
+
"初二春早,惠风和畅。愿春风十里,不如相聚有你。",
|
| 146 |
+
"正月初二,踏春而行。愿脚步所至,皆是美好。",
|
| 147 |
+
"春日暖阳,照见归途。初二这一天,温暖随行。",
|
| 148 |
+
"春风送暖入屠苏,初二归宁乐陶陶。",
|
| 149 |
+
"马年的春天,从初二的欢聚开始。生机勃勃,充满希望。",
|
| 150 |
+
"花开正艳,春意正浓。初二出门,遇见美好。",
|
| 151 |
+
"暖阳相伴,清风相随。初二的时光,惬意又美好。",
|
| 152 |
+
"踏遍春色,归来仍是少年。正月初二,喜乐安康。",
|
| 153 |
+
"春日迟迟,卉木萋萋。初二之日,愿心情如花般绽放。",
|
| 154 |
+
"迎着春光,奔赴团圆。初二快乐,马年大吉。",
|
| 155 |
+
|
| 156 |
+
# 短句补充篇
|
| 157 |
+
"初二回门,喜气洋洋。",
|
| 158 |
+
"归宁日,幸福时。",
|
| 159 |
+
"福满双门,好事成双。",
|
| 160 |
+
"初二吉祥,马年安康。",
|
| 161 |
+
"欢聚时刻,喜乐无忧。",
|
| 162 |
+
"亲情无价,岁月留痕。",
|
| 163 |
+
"初二纳福,万事顺遂。",
|
| 164 |
+
"春风十里,不如团圆。",
|
| 165 |
+
"回门之喜,福暖人心。",
|
| 166 |
+
"马年初二,福气满满。"
|
| 167 |
+
],
|
| 168 |
+
|
| 169 |
+
# ==============================================
|
| 170 |
+
# 2026年2月19日 正月初三 (赤狗日/宅家)
|
| 171 |
+
# 核心:静养、安歇、蓄力、宅家
|
| 172 |
+
# ==============================================
|
| 173 |
+
date(2026, 2, 19): [
|
| 174 |
+
# 宅家静养篇
|
| 175 |
+
"正月初三,安歇静养。给身体放个假,给心情充个电。",
|
| 176 |
+
"初三宅家,慢��时光。在喧嚣之外,寻得一份宁静。",
|
| 177 |
+
"初一忙,初二累,初三在家睡。享受难得的清闲。",
|
| 178 |
+
"宅家的日子,也是一种幸福。初三快乐,自在随心。",
|
| 179 |
+
"闭门谢客,静心养神。初三这一天,只属于自己。",
|
| 180 |
+
"放慢脚步,享受慢生活。初三,宜休息,宜欢聚。",
|
| 181 |
+
"窗外年味浓,屋内岁月静。初三宅家,惬意安然。",
|
| 182 |
+
"暂时放下忙碌,享受片刻悠闲。正月初三,岁月静好。",
|
| 183 |
+
"初三时光,用来虚度。愿日子慢一点,幸福长一点。",
|
| 184 |
+
"在家纳福,平安喜乐。初三这一天,简单又美好。",
|
| 185 |
+
|
| 186 |
+
# 蓄力待发篇
|
| 187 |
+
"初三蓄力,静待花开。为了更好的出发,此刻需要沉淀。",
|
| 188 |
+
"养精蓄锐,马力全开。初三的休息,是为了未来的奔跑。",
|
| 189 |
+
"积蓄力量,厚积薄发。2026,准备好惊艳全场。",
|
| 190 |
+
"暂停,是为了更好的前行。初三,在宁静中积蓄能量。",
|
| 191 |
+
"休整身心,整装待发。愿未来的路,走得更稳更远。",
|
| 192 |
+
"初三时光,用来规划。愿马年的每一步,都走得坚定。",
|
| 193 |
+
"充电完毕,满格出发。初三之后,又是新的征程。",
|
| 194 |
+
"在安静中蓄力,在沉淀中成长。正月初三,未来可期。",
|
| 195 |
+
"养足精神,迎接挑战。马年的精彩,还在后面。",
|
| 196 |
+
"初三纳福,蓄力前行。愿2026,一往无前。",
|
| 197 |
+
|
| 198 |
+
# 平安纳福篇
|
| 199 |
+
"正月初三,赤狗日。宜居家,纳平安,避纷争。",
|
| 200 |
+
"在家纳福,百邪不侵。愿金马护宅,万事顺遂。",
|
| 201 |
+
"初三吉祥,平安第一。愿日子安稳,岁月静好。",
|
| 202 |
+
"纳福迎祥,阖家安康。初三这一天,福气满满。",
|
| 203 |
+
"平安是福,健康是金。正月初三,祈愿平安。",
|
| 204 |
+
"福宅安康,万事兴隆。初三纳福,马年吉祥。",
|
| 205 |
+
"闭门纳福,开门迎喜。愿初三的宁静,带来一整年的安稳。",
|
| 206 |
+
"岁月安稳,现世静好。初三之日,福暖人间。",
|
| 207 |
+
"纳千祥,迎万福。正月初三,平安喜乐。",
|
| 208 |
+
"金马护佑,平安相随。初三安康,万事大吉。",
|
| 209 |
+
|
| 210 |
+
# 短句补充篇
|
| 211 |
+
"初三宅家,自在逍遥。",
|
| 212 |
+
"静养身心,蓄力前行。",
|
| 213 |
+
"闭门纳福,平安是福。",
|
| 214 |
+
"初三吉祥,岁月静好。",
|
| 215 |
+
"慢享时光,惬意安然。",
|
| 216 |
+
"养精蓄锐,马到成功。",
|
| 217 |
+
"正月初三,宜休息。",
|
| 218 |
+
"在家享福,福气自来。",
|
| 219 |
+
"沉淀自己,未来可期。",
|
| 220 |
+
"初三安康,福满人间。"
|
| 221 |
+
],
|
| 222 |
+
|
| 223 |
+
# ==============================================
|
| 224 |
+
# 2026年2月20日 正月初四 (接灶神)
|
| 225 |
+
# 核心:烟火、食禄、家肥屋润、三餐四季
|
| 226 |
+
# ==============================================
|
| 227 |
+
date(2026, 2, 20): [
|
| 228 |
+
# 恭迎灶神篇
|
| 229 |
+
"正月初四,恭迎灶神。愿三餐四季,温暖如初。",
|
| 230 |
+
"灶神归位,烟火重燃。初四这一天,充满了生活气息。",
|
| 231 |
+
"恭迎灶王爷,福泽满人间。愿家肥屋润,衣食无忧。",
|
| 232 |
+
"初四接灶,五谷丰登。愿粮仓常满,日子富足。",
|
| 233 |
+
"灶火初红,春意渐浓。迎接灶神,迎接美好。",
|
| 234 |
+
"一炉香火,祈愿平安。初四接灶,马年吉祥。",
|
| 235 |
+
"灶神下界,保祐平安。愿每一顿饭,都吃得香甜。",
|
| 236 |
+
"正月初四,迎灶纳福。愿烟火气中,皆是幸福味。",
|
| 237 |
+
"接灶神,纳吉祥。愿2026,衣食无忧,生活美满。",
|
| 238 |
+
"灶火通明,福气盈门。初四大吉,万事顺遂。",
|
| 239 |
+
|
| 240 |
+
# 人间烟火篇
|
| 241 |
+
"人间烟火气,最抚凡人心。初四这一天,重拾生活的热爱。",
|
| 242 |
+
"三餐四季,温柔有趣。愿灶火不熄,爱与温暖常在。",
|
| 243 |
+
"厨房里的烟火,是家里最美的风景。初四快乐。",
|
| 244 |
+
"一碗热汤,温暖身心。愿马年的每一天,都热气腾腾。",
|
| 245 |
+
"烟火升腾处,幸福正当时。初四,宜下厨,宜欢聚。",
|
| 246 |
+
"柴米油盐酱醋茶,人间烟火也有趣。正月初四,岁月静好。",
|
| 247 |
+
"灶台飘香,日子红火。愿生活有滋有味,红红火火。",
|
| 248 |
+
"在烟火气中,感受生活的美好。初四这一天,惬意安然。",
|
| 249 |
+
"灶火声声,笑语盈盈。愿家宅安宁,幸福绵长。",
|
| 250 |
+
"人间有味是清欢。初四之日,愿享受每一顿家常便饭。",
|
| 251 |
+
|
| 252 |
+
# 食禄丰足篇
|
| 253 |
+
"初四接灶,食禄丰足。愿2026,不愁吃穿,富足安康。",
|
| 254 |
+
"米缸常满,日子香甜。愿马年的每一天,都衣食无忧。",
|
| 255 |
+
"食禄双全,福气满满。正月初四,祈愿丰收。",
|
| 256 |
+
"五谷丰登,食禄无忧。愿生活富足,岁月安稳。",
|
| 257 |
+
"迎灶神,纳食禄。愿这一年,物质富足,精神丰盈。",
|
| 258 |
+
"仓廪实而知礼节,衣食足而知荣辱。初四祈愿富足。",
|
| 259 |
+
"食禄绵长,福泽深厚。马年初四,吉祥如意。",
|
| 260 |
+
"愿手中有粮,心中不慌。初四接灶,岁岁安康。",
|
| 261 |
+
"丰衣足食,安居乐业。正月初四,万事亨通。",
|
| 262 |
+
"接灶神,保食禄。愿2026,日子过得殷实又幸福。",
|
| 263 |
+
|
| 264 |
+
# 短句补充篇
|
| 265 |
+
"初四接灶,福气满堂。",
|
| 266 |
+
"烟火人间,温暖相伴。",
|
| 267 |
+
"家肥屋润,衣食无忧。",
|
| 268 |
+
"灶神归位,万事顺遂。",
|
| 269 |
+
"三餐四季,温柔有趣。",
|
| 270 |
+
"食禄丰足,马年大吉。",
|
| 271 |
+
"正月初四,宜纳福。",
|
| 272 |
+
"烟火气中,幸福绵长。",
|
| 273 |
+
"迎灶纳祥,岁岁安康。",
|
| 274 |
+
"柴米油盐,皆是幸福。"
|
| 275 |
+
],
|
| 276 |
+
|
| 277 |
+
# ==============================================
|
| 278 |
+
# 2026年2月21日 正月初五 (破五/迎财神)
|
| 279 |
+
# 核心:财运、破禁、送穷、发财
|
| 280 |
+
# ==============================================
|
| 281 |
+
date(2026, 2, 21): [
|
| 282 |
+
# 迎财纳福篇
|
| 283 |
+
"正月初五,迎财神。愿2026,财运亨通,富贵吉祥。",
|
| 284 |
+
"五路财神齐到访,八方来财福满堂。初五接福啦。",
|
| 285 |
+
"财神到,福运照。愿马年的每一天,都财源滚滚。",
|
| 286 |
+
"初五迎财,开门见喜。愿事业有成,财运亨通。",
|
| 287 |
+
"东路招财,西路纳珍。初五这一天,装满了财富。",
|
| 288 |
+
"财神骑马到家门,金银财宝进家门。马年发大财。",
|
| 289 |
+
"正月初五,财门大开。愿八方财源,滚滚而来。",
|
| 290 |
+
"迎财神,纳千祥。愿2026,腰缠万贯,富贵安康。",
|
| 291 |
+
"五路财神护佑,马年财运亨通。初五快乐。",
|
| 292 |
+
"财星高照,福气临门。正月初五,恭喜发财。",
|
| 293 |
+
|
| 294 |
+
# 破五送穷篇
|
| 295 |
+
"正月初五,破五送穷。送走烦恼,送走霉运,送走贫穷。",
|
| 296 |
+
"破五之时,送穷出门。愿2026,轻装上阵,奔赴美好。",
|
| 297 |
+
"鞭炮一响,穷鬼跑光。初五这一天,除旧布新。",
|
| 298 |
+
"破除禁忌,送走穷困。愿马年的日子,蒸蒸日上。",
|
| 299 |
+
"破五开运,万象更新。愿所有的不好,都随风而去。",
|
| 300 |
+
"送穷迎富,福气满屋。正月初五,好运连连。",
|
| 301 |
+
"破五之日,百无禁忌。愿想做的事,都能如愿。",
|
| 302 |
+
"送走旧岁的穷气,迎来新年的财气。初五大吉。",
|
| 303 |
+
"破五重生,焕然一新。愿2026,元气满满。",
|
| 304 |
+
"除旧迎新,破五纳祥。愿未来的日子,一片光明。",
|
| 305 |
+
|
| 306 |
+
# 马年钱程篇
|
| 307 |
+
"策马奔腾赴钱程,马不停蹄赚金银。初五快乐。",
|
| 308 |
+
"马年行大运,财运滚滚来。愿事业如骏马,飞驰向前。",
|
| 309 |
+
"金马送财,富贵花开。愿2026,钱途无量。",
|
| 310 |
+
"马力全开搞事业,一心一意赚大钱。初五吉祥。",
|
| 311 |
+
"如骏马驰骋,在财富的草原上,收获满满。",
|
| 312 |
+
"马到成功,财到手。愿2026,盆满钵满。",
|
| 313 |
+
"金马踏春来,财运随身带。正月初五,发财发财。",
|
| 314 |
+
"驰骋商海,如骏马奔腾。愿财源广进,日进斗金。",
|
| 315 |
+
"马年第一桶金,从初五迎财神开始。",
|
| 316 |
+
"财运如骏马,日行千里,夜行八百。",
|
| 317 |
+
|
| 318 |
+
# 短句补充篇
|
| 319 |
+
"初五迎财,富贵自来。",
|
| 320 |
+
"送穷迎富,万事胜意。",
|
| 321 |
+
"财神驾到,财源滚滚。",
|
| 322 |
+
"破五开运,马到成功。",
|
| 323 |
+
"五路接财,八方纳福。",
|
| 324 |
+
"马年发财,钱途无量。",
|
| 325 |
+
"正月初五,恭喜发财。",
|
| 326 |
+
"财门大开,好运自来。",
|
| 327 |
+
"送穷出门,迎富入宅。",
|
| 328 |
+
"日进斗金,腰缠万贯。"
|
| 329 |
+
],
|
| 330 |
+
|
| 331 |
+
# ==============================================
|
| 332 |
+
# 2026年2月22日 正月初六 (送穷/开市)
|
| 333 |
+
# 核心:顺意、开工、送穷、六六大顺
|
| 334 |
+
# ==============================================
|
| 335 |
+
date(2026, 2, 22): [
|
| 336 |
+
# 六六大顺篇
|
| 337 |
+
"正月初六,六六大顺。愿2026,万事顺遂,顺心如意。",
|
| 338 |
+
"六六大顺日,马年吉祥时。愿好运连连,幸福满满。",
|
| 339 |
+
"初六大顺,一顺百顺。愿生活顺心,事业顺利。",
|
| 340 |
+
"天顺地顺人更顺,心顺意顺事事顺。正月初六快乐。",
|
| 341 |
+
"顺风顺水,顺理成章。愿马年的每一天,都顺顺利利。",
|
| 342 |
+
"六六大顺,福满人间。愿所有的美好,都如期而至。",
|
| 343 |
+
"初六送福,顺字当头。愿日子过得舒心,过得顺心。",
|
| 344 |
+
"顺气东来,福气西至。正月初六,万事亨通。",
|
| 345 |
+
"顺顺利利开工,红红火火生活。初六大吉。",
|
| 346 |
+
"顺境常伴,逆境不扰。愿2026,一路顺风。",
|
| 347 |
+
|
| 348 |
+
# 送穷启程篇
|
| 349 |
+
"正月初六,送穷启程。愿霉运清零,好运加满。",
|
| 350 |
+
"送走穷神,迎来福神。初六这一天,焕然一新。",
|
| 351 |
+
"穷气送出门,福气迎进门。愿马年的日子,富足安康。",
|
| 352 |
+
"初六送穷,一送永逸。愿2026,无病无灾,无贫无困。",
|
| 353 |
+
"鞭炮声声送穷神,欢歌笑语迎新春。正月初六快乐。",
|
| 354 |
+
"送穷归故里,迎富入新宅。愿生活蒸蒸日上。",
|
| 355 |
+
"初六启程,甩掉包袱。愿轻装上阵,奔赴前程。",
|
| 356 |
+
"穷神走,财神留。愿2026,富贵常伴。",
|
| 357 |
+
"送穷之日,开启新程。愿马年的路,越走越宽。",
|
| 358 |
+
"告别贫穷与烦恼,迎接富裕与快乐。初六吉祥。",
|
| 359 |
+
|
| 360 |
+
# 开市大吉篇
|
| 361 |
+
"正月初六,开市大吉。愿2026,事业兴旺,财源广进。",
|
| 362 |
+
"开工啦!愿马力全开,业绩长虹。",
|
| 363 |
+
"初六启市,百业兴旺。愿生意兴隆,客似云来。",
|
| 364 |
+
"开市迎财,大吉大利。愿马年的事业,如日中天。",
|
| 365 |
+
"鞭炮一响,黄金万两。初六开工,红红火火。",
|
| 366 |
+
"新征程,新起点。初六开市,未来可期。",
|
| 367 |
+
"开门做生意,笑脸迎财神。愿2026,订单不断。",
|
| 368 |
+
"初六开工,元气满满。愿工作顺利,薪水翻番。",
|
| 369 |
+
"开市纳福,生意兴隆。愿马年的事业,一马当先。",
|
| 370 |
+
"正月初六,宜开工。愿所有的努力,都有回报。",
|
| 371 |
+
|
| 372 |
+
# 短句补充篇
|
| 373 |
+
"初六大顺,万事亨通。",
|
| 374 |
+
"送穷迎富,开工大吉。",
|
| 375 |
+
"六六大顺,马到成功。",
|
| 376 |
+
"顺风顺水,前程似锦。",
|
| 377 |
+
"开市纳财,富贵吉祥。",
|
| 378 |
+
"正月初六,启程出发。",
|
| 379 |
+
"霉运清零,好运加满。",
|
| 380 |
+
"红红火火,开工大吉。",
|
| 381 |
+
"顺字当头,幸福安康。",
|
| 382 |
+
"马年开工,业绩长虹。"
|
| 383 |
+
],
|
| 384 |
+
|
| 385 |
+
# ==============================================
|
| 386 |
+
# 2026年2月23日 正月初七 (人日/庆寿)
|
| 387 |
+
# 核心:生民、健康、成长、七菜
|
| 388 |
+
# ==============================================
|
| 389 |
+
date(2026, 2, 23): [
|
| 390 |
+
# 众人生日篇
|
| 391 |
+
"正月初七,人日快乐。愿世间所有人,平安健康。",
|
| 392 |
+
"传说女娲造人,初七始成。这是属于每个人的生日。",
|
| 393 |
+
"人日吉祥,喜乐安康。愿2026,善待每一个生命。",
|
| 394 |
+
"初七庆生,福满人间。愿岁月温柔,不负韶华。",
|
| 395 |
+
"所有人的生日,所有的祝福。愿平安常伴,健康常在。",
|
| 396 |
+
"人日之时,许下心愿:愿众生皆苦,唯有你甜。",
|
| 397 |
+
"正月初七,祝自己,也祝你,生日快乐。",
|
| 398 |
+
"生而为人,何其有幸。初七这一天,感恩生命。",
|
| 399 |
+
"人日纳福,愿每一个人,都能被世界温柔以待。",
|
| 400 |
+
"初七之日,万物生辉。愿生命蓬勃,充满希望。",
|
| 401 |
+
|
| 402 |
+
# 健康成长篇
|
| 403 |
+
"人日祈健康,愿身体无恙,精神饱满。",
|
| 404 |
+
"正月初七,宜养生。愿龙马精神,常驻心间。",
|
| 405 |
+
"健康是福,平安是金。愿2026,无病无灾。",
|
| 406 |
+
"在这个属于人的日子,愿健康常伴左右。",
|
| 407 |
+
"茁壮成长,不负春光。愿马年的每一天,都充满活力。",
|
| 408 |
+
"身强体健,百病不侵。初七祈愿,健康长寿。",
|
| 409 |
+
"愿如骏马,体魄强健,驰骋万里。",
|
| 410 |
+
"人日吃顿好,身体没烦恼。愿营养均衡,健康无忧。",
|
| 411 |
+
"正月初七,动起来。愿活力满满,元气十足。",
|
| 412 |
+
"健康的体魄,是梦想的基石。初七快乐。",
|
| 413 |
+
|
| 414 |
+
# 七菜迎春篇
|
| 415 |
+
"初七吃七菜,福气自然来。愿生活丰富多彩。",
|
| 416 |
+
"七菜羹,聚福气。愿2026,集齐所有的美好。",
|
| 417 |
+
"七种蔬菜,七种祝福。愿马年的日子,五彩斑斓。",
|
| 418 |
+
"食七菜,迎新春。愿日子过得有滋有味。",
|
| 419 |
+
"正月初七,尝鲜迎春。愿生活如七菜,清爽又健康。",
|
| 420 |
+
"七菜同煮,福气满屋。愿阖家欢乐,岁月静好。",
|
| 421 |
+
"吃口七菜羹,全年万事兴。初七吉祥。",
|
| 422 |
+
"七种食材,七种好运。愿马年的每一天,都有惊喜。",
|
| 423 |
+
"人日食七菜,健康又自在。愿身体安康,万事顺遂。",
|
| 424 |
+
"七菜迎春,福满人间。正月初七,喜乐安康。",
|
| 425 |
+
|
| 426 |
+
# 短句补充篇
|
| 427 |
+
"初七人日,喜乐安康。",
|
| 428 |
+
"众人生日,平安吉祥。",
|
| 429 |
+
"健康第一,万事无忧。",
|
| 430 |
+
"人日纳福,马年大吉。",
|
| 431 |
+
"生而自由,爱而无畏。",
|
| 432 |
+
"七菜迎春,福气满满。",
|
| 433 |
+
"正月初七,岁月静好。",
|
| 434 |
+
"生命可贵,且行且惜。",
|
| 435 |
+
"人日快乐,诸事顺遂。",
|
| 436 |
+
"龙马精神,健康长寿。"
|
| 437 |
+
],
|
| 438 |
+
|
| 439 |
+
# ==============================================
|
| 440 |
+
# 2026年2月24日 正月初八 (开工/谷日)
|
| 441 |
+
# 核心:耕耘、丰收、事业、启程
|
| 442 |
+
# ==============================================
|
| 443 |
+
date(2026, 2, 24): [
|
| 444 |
+
# 开工启程篇
|
| 445 |
+
"正月初八,开工大吉。愿2026,马力全开,再创辉煌。",
|
| 446 |
+
"初八启程,奔赴前程。愿事业如骏马,一日千里。",
|
| 447 |
+
"假期归零,快乐不归零。初八开工,元气满满。",
|
| 448 |
+
"新的征程,从初八开始。愿脚踏实地,仰望星空。",
|
| 449 |
+
"初八开工,喜气洋洋。愿工作顺利,步步高升。",
|
| 450 |
+
"收心归位,全力以付。愿马年的事业,一马当先。",
|
| 451 |
+
"正月初八,宜奋斗。愿每一份努力,都不被辜负。",
|
| 452 |
+
"开工啦!愿2026,业绩长虹,薪水翻番。",
|
| 453 |
+
"带着新年的喜气,投入工作的热情。初八快乐。",
|
| 454 |
+
"启程出发,未来可期。正月初八,万事亨通。",
|
| 455 |
+
|
| 456 |
+
# 谷日祈丰篇
|
| 457 |
+
"正月初八,谷日吉祥。愿五谷丰登,国泰民安。",
|
| 458 |
+
"谷日祈丰收,愿大地回馈,仓廪丰实。",
|
| 459 |
+
"初八是谷日,预示丰收年。愿生活富足,岁月安稳。",
|
| 460 |
+
"春种一粒粟,秋收万颗子。初八祈愿,收获满满。",
|
| 461 |
+
"五谷飘香,日子绵长。愿马年的每一天,都衣食无忧。",
|
| 462 |
+
"谷日纳福,愿耕耘有收获,付出有回报。",
|
| 463 |
+
"正月初八,惜粮感恩。愿每一粒粮食,都被珍惜。",
|
| 464 |
+
"谷物丰登,福气盈门。愿2026,物质富足。",
|
| 465 |
+
"谷日之时,许下心愿:愿世间无饥饿,人间皆温饱。",
|
| 466 |
+
"初八谷日,福泽深厚。愿马年,岁岁丰收。",
|
| 467 |
+
|
| 468 |
+
# 耕耘收获篇
|
| 469 |
+
"一分耕耘,一分收获。初八开工,愿辛勤付出,换来硕果累累。",
|
| 470 |
+
"如农人耕耘,如骏马驰骋。愿在事业的田野,收获满满。",
|
| 471 |
+
"播种希望,收获未来。正月初八,宜行动。",
|
| 472 |
+
"不驰于空想,不骛于虚声。初八开始,脚踏实地。",
|
| 473 |
+
"耕耘当下,收获未来。愿2026,满载而归。",
|
| 474 |
+
"像守护庄稼一样,守护梦想。初八快乐。",
|
| 475 |
+
"辛勤耕耘,静待花开。愿马年的事业,蒸蒸日上。",
|
| 476 |
+
"只有播种,才有收获。初八启程,开始新的耕耘。",
|
| 477 |
+
"愿汗水浇灌梦想,收获金色的未来。正月初八吉祥。",
|
| 478 |
+
"耕耘岁月,收获幸福。愿2026,硕果累累。",
|
| 479 |
+
|
| 480 |
+
# 短句补充篇
|
| 481 |
+
"初八开工,大吉大利。",
|
| 482 |
+
"谷日祈丰,五谷丰登。",
|
| 483 |
+
"马力全开,奔赴前程。",
|
| 484 |
+
"耕耘收获,未来可期。",
|
| 485 |
+
"正月初八,宜奋斗。",
|
| 486 |
+
"业绩长虹,步步高升。",
|
| 487 |
+
"仓廪丰实,衣食无忧。",
|
| 488 |
+
"脚踏实地,仰望星空。",
|
| 489 |
+
"开工启程,马到成功。",
|
| 490 |
+
"播种希望,收获辉煌。"
|
| 491 |
+
],
|
| 492 |
+
|
| 493 |
+
# ==============================================
|
| 494 |
+
# 2026年2月25日 正月初九 (天公生)
|
| 495 |
+
# 核心:天长地久、祈福、高远、玉皇诞
|
| 496 |
+
# ==============================================
|
| 497 |
+
date(2026, 2, 25): [
|
| 498 |
+
# 天公诞辰篇
|
| 499 |
+
"正月初九,天公生。愿上天庇佑,阖家安康。",
|
| 500 |
+
"玉皇大帝诞辰日,一拜天公,风调雨顺。",
|
| 501 |
+
"初九拜天公,福气满乾坤。愿2026,万事顺遂。",
|
| 502 |
+
"天公作美,岁月静好。正月初九,吉祥如意。",
|
| 503 |
+
"叩拜天公,祈愿平安。愿风调雨顺,国泰民安。",
|
| 504 |
+
"初九吉日,天公赐福。愿所有的美好,都降临身边。",
|
| 505 |
+
"天公生,福满门。愿金马踏云,带来祥瑞。",
|
| 506 |
+
"正月初九,诚心祈福。愿上天眷顾,诸事皆宜。",
|
| 507 |
+
"拜天公,纳千祥。愿2026,福运亨通。",
|
| 508 |
+
"天公庇佑,金马护航。初九安康,万事大吉。",
|
| 509 |
+
|
| 510 |
+
# 长长久久篇
|
| 511 |
+
"正月初九,长长久久。愿福气长久,财运长久。",
|
| 512 |
+
"九为数之极,寓意圆满。愿2026,长长久久的幸福。",
|
| 513 |
+
"初九之日,许下心愿:愿健康长久,快乐长久。",
|
| 514 |
+
"长长久久的陪伴,长长久久的幸福。正月初九快乐。",
|
| 515 |
+
"友谊长存,爱意长久。愿所有的关系,都天长地久。",
|
| 516 |
+
"初九纳福,愿好运长久相伴,烦恼长久远离。",
|
| 517 |
+
"幸福久久,好运连连。愿马年的每一天,都充满阳光。",
|
| 518 |
+
"长长久久的岁月,长长久久的安康。",
|
| 519 |
+
"正月初九,愿这份祝福,伴你天长地久。",
|
| 520 |
+
"久久同心,万事胜意。愿2026,美好长存。",
|
| 521 |
+
|
| 522 |
+
# 志存高远篇
|
| 523 |
+
"初九天公生,愿志存高远,心向星辰。",
|
| 524 |
+
"如天马行空,自由自在。愿梦想无边界,前程无阻碍。",
|
| 525 |
+
"仰望星空,脚踏实地。正月初九,未来可期。",
|
| 526 |
+
"心有凌云志,脚下万里途。愿马年的你,驰骋万里。",
|
| 527 |
+
"志在千里,壮心不已。愿2026,实现远大理想。",
|
| 528 |
+
"天高地阔,任君驰骋。愿如骏马,飞跃高山。",
|
| 529 |
+
"���月初九,宜立志。愿立下鸿鹄志,不负少年时。",
|
| 530 |
+
"胸怀天下,志存高远。愿马年的事业,蒸蒸日上。",
|
| 531 |
+
"心向蓝天,脚踏实地。愿每一步,都走得坚定。",
|
| 532 |
+
"初九之日,愿眼界开阔,格局打开。",
|
| 533 |
+
|
| 534 |
+
# 短句补充篇
|
| 535 |
+
"初九拜天,福泽绵绵。",
|
| 536 |
+
"天长地久,幸福安康。",
|
| 537 |
+
"天公赐福,万事大吉。",
|
| 538 |
+
"正月初九,步步高升。",
|
| 539 |
+
"志存高远,马到成功。",
|
| 540 |
+
"福气久久,好运连连。",
|
| 541 |
+
"风调雨顺,国泰民安。",
|
| 542 |
+
"天马行空,自在逍遥。",
|
| 543 |
+
"初九吉祥,福满人间。",
|
| 544 |
+
"长长久久,万事胜意。"
|
| 545 |
+
],
|
| 546 |
+
|
| 547 |
+
# ==============================================
|
| 548 |
+
# 2026年2月26日 正月初十 (石不动/十全十美)
|
| 549 |
+
# 核心:圆满、稳固、十全十美、基础
|
| 550 |
+
# ==============================================
|
| 551 |
+
date(2026, 2, 26): [
|
| 552 |
+
# 十全十美篇
|
| 553 |
+
"正月初十,十全十美。愿2026,圆满无缺,万事胜意。",
|
| 554 |
+
"十全十美日,马年吉祥时。愿集齐所有的美好。",
|
| 555 |
+
"初十圆满,事事如意。愿生活有滋有味,有声有色。",
|
| 556 |
+
"十分幸福,十分美满。正月初十,福暖人间。",
|
| 557 |
+
"十全十美,百事无忧。愿马年的每一天,都顺心顺意。",
|
| 558 |
+
"初十这一天,愿所有的期待,都得到圆满答复。",
|
| 559 |
+
"十分好运,十分福气。愿2026,好运连连。",
|
| 560 |
+
"十全十美,千金不换。愿这份幸福,伴你一生。",
|
| 561 |
+
"正月初十,愿生活满分,快乐满分。",
|
| 562 |
+
"圆满之日,喜乐之时。愿马年,圆圆满满。",
|
| 563 |
+
|
| 564 |
+
# 根基稳固篇
|
| 565 |
+
"正月初十,石不动。愿根基稳固,如磐石般坚定。",
|
| 566 |
+
"石不动,心安稳。愿马年的每一步,都走得踏实。",
|
| 567 |
+
"初十之日,宜固本。愿基础扎实,前程稳固。",
|
| 568 |
+
"如磐石般坚定,如骏马般奔腾。愿动静皆宜。",
|
| 569 |
+
"根基深厚,枝繁叶茂。愿事业如大树,茁壮成长。",
|
| 570 |
+
"石不动,福常驻。愿家宅安宁,岁月静好。",
|
| 571 |
+
"正月初十,愿初心如磐,使命在肩。",
|
| 572 |
+
"稳固根基,才能行稳致远。初十吉祥。",
|
| 573 |
+
"如石般坚定,如水般灵动。愿马年的日子,刚柔并济。",
|
| 574 |
+
"初十纳福,愿基业长青,幸福长久。",
|
| 575 |
+
|
| 576 |
+
# 十福临门篇
|
| 577 |
+
"初十迎十福,福满门庭。愿福气、财气、运气,统统到来。",
|
| 578 |
+
"一福平安,二福健康。初十这一天,十福临门。",
|
| 579 |
+
"集齐十福,召唤好运。愿2026,福气满满。",
|
| 580 |
+
"十福齐至,万事亨通。正月初十,喜乐安康。",
|
| 581 |
+
"福满十方,喜盈门庭。愿马年的日子,红红火火。",
|
| 582 |
+
"初十接福,愿幸福像花儿一样,朵朵绽放。",
|
| 583 |
+
"十全十美,五福临门。愿2026,好事成双。",
|
| 584 |
+
"福运绵长,十全十美。愿每一个梦想,都开花结果。",
|
| 585 |
+
"正月初十,愿福气东来,紫气西至。",
|
| 586 |
+
"十福相伴,一生平安。愿马年,福暖四季。",
|
| 587 |
+
|
| 588 |
+
# 短句补充篇
|
| 589 |
+
"初十圆满,十全十美。",
|
| 590 |
+
"石不动,福常驻。",
|
| 591 |
+
"根基稳固,行稳致远。",
|
| 592 |
+
"十福临门,万事大吉。",
|
| 593 |
+
"正月初十,圆满收官。",
|
| 594 |
+
"十分幸福,十分美满。",
|
| 595 |
+
"马年圆满,事事如意。",
|
| 596 |
+
"初心如磐,未来可期。",
|
| 597 |
+
"初十吉祥,福满人间。",
|
| 598 |
+
"十全十美,喜乐无忧。"
|
| 599 |
+
],
|
| 600 |
+
|
| 601 |
+
# ==============================================
|
| 602 |
+
# 2026年2月27日 正月十一 (子婿日/宴请)
|
| 603 |
+
# 核心:相聚、情谊、款待、热闹
|
| 604 |
+
# ==============================================
|
| 605 |
+
date(2026, 2, 27): [
|
| 606 |
+
# 欢聚宴请篇
|
| 607 |
+
"正月十一,欢聚时刻。愿情谊长存,温暖常在。",
|
| 608 |
+
"子婿之日,宴请亲朋。愿欢声笑语,充满屋宇。",
|
| 609 |
+
"正月十一,宜相聚。愿推杯换盏,共话桑麻。",
|
| 610 |
+
"宴请八方客,喜迎四海宾。正月十一,热闹非凡。",
|
| 611 |
+
"欢聚一堂,喜气洋洋。愿这份热闹,延续一整年。",
|
| 612 |
+
"十一之日,美酒佳肴。愿吃得开心,聊得尽兴。",
|
| 613 |
+
"高朋满座,胜友如云。愿马年的日子,贵人常伴。",
|
| 614 |
+
"正月十一,把酒言欢。愿烦恼抛诸脑后,快乐常驻心间。",
|
| 615 |
+
"相聚的时光,总是短暂。愿珍惜当下,不负相遇。",
|
| 616 |
+
"宴请亲朋,共庆新春。正月十一,吉祥如意。",
|
| 617 |
+
|
| 618 |
+
# 情谊绵长篇
|
| 619 |
+
"正月十一,情谊绵长。愿亲情、友情,如陈年老酒,越久越香。",
|
| 620 |
+
"岁月流转,情谊不变。愿每一次相聚,都温暖如初。",
|
| 621 |
+
"子婿之日,亲情浓。愿家人闲坐,灯火可亲。",
|
| 622 |
+
"朋友相聚,友情深。愿高山流水,知音常在。",
|
| 623 |
+
"情谊是冬日的暖阳,是夏日的清风。正月十一,感恩相遇。",
|
| 624 |
+
"愿这份情谊,如骏马奔腾,跨越山海,永不褪色。",
|
| 625 |
+
"正月十一,愿所有的感情,都能被温柔以待。",
|
| 626 |
+
"相聚是缘,相守是福。愿情谊长存,岁月静好。",
|
| 627 |
+
"把酒言欢,共叙情谊。愿马年的每一天,都有朋友相伴。",
|
| 628 |
+
"十一之日,愿情谊之花,常开不败。",
|
| 629 |
+
|
| 630 |
+
# 余庆延续篇
|
| 631 |
+
"年味未减,余庆延续。正月十一,依然喜气洋洋。",
|
| 632 |
+
"春节的热闹,还在继续。愿快乐不减,福气依旧。",
|
| 633 |
+
"正月十一,新年的余温尚在。愿好运连连,幸福满满。",
|
| 634 |
+
"年虽过半,味仍浓。愿马年的日子,依然红红火火。",
|
| 635 |
+
"余庆绵绵,福泽深厚。正月十一,万事顺遂。",
|
| 636 |
+
"新年的脚步虽远,祝福的心意未减。",
|
| 637 |
+
"正月十一,愿这份喜气,伴你左右。",
|
| 638 |
+
"年味渐淡,情意更浓。愿每一次相聚,都值得珍藏。",
|
| 639 |
+
"十一之日,愿新年的好运,继续加持。",
|
| 640 |
+
"余庆延续,马年大吉。愿未来的日子,充满阳光。",
|
| 641 |
+
|
| 642 |
+
# 短句补充篇
|
| 643 |
+
"正月十一,欢聚一堂。",
|
| 644 |
+
"情谊绵长,温暖相伴。",
|
| 645 |
+
"子婿之日,喜气洋洋。",
|
| 646 |
+
"把酒言欢,共话美好。",
|
| 647 |
+
"余庆延续,福满人间。",
|
| 648 |
+
"高朋满座,胜友如云。",
|
| 649 |
+
"十一吉祥,马年安康。",
|
| 650 |
+
"相聚是缘,相守是福。",
|
| 651 |
+
"年味依旧,快乐不减。",
|
| 652 |
+
"宴请亲朋,共庆新春。"
|
| 653 |
+
],
|
| 654 |
+
|
| 655 |
+
# ==============================================
|
| 656 |
+
# 2026年2月28日 正月十二 (搭灯棚/备元宵)
|
| 657 |
+
# 核心:预热、光明、期待、筹备
|
| 658 |
+
# ==============================================
|
| 659 |
+
date(2026, 2, 28): [
|
| 660 |
+
# 搭棚迎灯篇
|
| 661 |
+
"正月十二,搭灯棚。为即将到来的元宵,点亮希望。",
|
| 662 |
+
"灯棚初搭,喜气初临。愿光明将至,幸福将至。",
|
| 663 |
+
"正月十二,张灯结彩。愿大街小巷,充满节日的气氛。",
|
| 664 |
+
"搭起灯棚,点亮心灯。愿2026,前途一片光明。",
|
| 665 |
+
"十二之日,筹备元宵。愿所有的期待,都如期而至。",
|
| 666 |
+
"灯棚高高挂,福气进门来。正月十二,吉祥如意。",
|
| 667 |
+
"红红火火搭灯棚,热热闹闹迎元宵。",
|
| 668 |
+
"正月十二,愿这一盏盏灯,照亮前行的路。",
|
| 669 |
+
"搭灯棚,纳吉祥。愿马年的夜晚,不再黑暗。",
|
| 670 |
+
"十二这一天,为团圆做准备。愿日子红红火火。",
|
| 671 |
+
|
| 672 |
+
# 元宵预热篇
|
| 673 |
+
"年味未消,元宵将至。正月十二,期待满满。",
|
| 674 |
+
"倒计时三天,元宵佳节即将登场。愿快乐加倍。",
|
| 675 |
+
"正月十二,心向元宵。愿那一碗汤圆,甜进心里。",
|
| 676 |
+
"春节的尾声,元宵的序曲。十二这一天,承上启下。",
|
| 677 |
+
"期待那一夜的灯火,期待那一碗的香甜。",
|
| 678 |
+
"正月十二,愿所有的美好,都在元宵之夜绽放。",
|
| 679 |
+
"预热元宵,福气先行。愿2026,圆圆满满。",
|
| 680 |
+
"十二之日,许下心愿:愿元宵之夜,月圆人圆。",
|
| 681 |
+
"春节的热闹还在,元宵的期待已来。",
|
| 682 |
+
"正月十二,愿这份期待,化作美好的现实。",
|
| 683 |
+
|
| 684 |
+
# 光明希望篇
|
| 685 |
+
"正月十二,点亮心灯。愿心中有光,脚下有路。",
|
| 686 |
+
"灯象征着希望。愿2026,如明灯指引,一路向前。",
|
| 687 |
+
"十二之日,愿光明驱散黑暗,希望战胜绝望。",
|
| 688 |
+
"心有明灯,不惧黑暗。愿马年的每一天,都充满阳光。",
|
| 689 |
+
"搭灯棚,迎光明。愿前程似锦,未来可期。",
|
| 690 |
+
"正月十二,愿这世间,灯火通明,温暖常在。",
|
| 691 |
+
"光明将至,幸福随行。愿马年的夜晚,星光璀璨。",
|
| 692 |
+
"点亮一盏灯,照亮一片天。十二这一天,充满希望。",
|
| 693 |
+
"愿心灯长明,愿福运长伴。",
|
| 694 |
+
"正月十二,愿如骏马,向着光明,飞驰而去。",
|
| 695 |
+
|
| 696 |
+
# 短句补充篇
|
| 697 |
+
"正月十二,搭灯迎福。",
|
| 698 |
+
"元宵预热,期待满满。",
|
| 699 |
+
"心有明灯,前途光明。",
|
| 700 |
+
"张灯结彩,喜气洋洋。",
|
| 701 |
+
"十二吉祥,圆圆满满。",
|
| 702 |
+
"筹备元宵,福气先行。",
|
| 703 |
+
"灯火可亲,未来可期。",
|
| 704 |
+
"马年十二,光明将至。",
|
| 705 |
+
"搭起灯棚,点亮希望。",
|
| 706 |
+
"喜迎元宵,万事顺遂。"
|
| 707 |
+
],
|
| 708 |
+
|
| 709 |
+
# ==============================================
|
| 710 |
+
# 2026年3月1日 正月十三 (试灯/赏花灯)
|
| 711 |
+
# 核心:试灯、璀璨、浪漫、初亮
|
| 712 |
+
# ==============================================
|
| 713 |
+
date(2026, 3, 1): [
|
| 714 |
+
# 试灯初亮篇
|
| 715 |
+
"正月十三,试灯初亮。愿这一抹光,温暖整个春天。",
|
| 716 |
+
"灯火试明,幸福先行。愿元宵之夜,璀璨夺目。",
|
| 717 |
+
"十三试灯,点亮街头。愿这世界,五彩斑斓。",
|
| 718 |
+
"试灯的夜晚,星光与灯光交相辉映。",
|
| 719 |
+
"正月十三,灯火初上。愿这光亮,照亮心底的梦想。",
|
| 720 |
+
"试灯迎元宵,喜气满人间。愿2026,光彩照人。",
|
| 721 |
+
"十三之日,灯火璀璨。愿每一盏灯,都藏着美好祝福。",
|
| 722 |
+
"试灯啦!愿马年的夜晚,不再孤单。",
|
| 723 |
+
"正月十三,愿灯光驱散寒意,带来温暖。",
|
| 724 |
+
"灯火初亮,希望初升。愿未来的日子,一片光明。",
|
| 725 |
+
|
| 726 |
+
# 璀璨浪漫篇
|
| 727 |
+
"正月十三,灯火璀璨。愿生活如灯,五彩斑斓。",
|
| 728 |
+
"试灯之夜,浪漫无边。愿遇见美好,遇见爱。",
|
| 729 |
+
"灯火万家城四畔,星河一道水中央。十三之夜,美不胜收。",
|
| 730 |
+
"正月十三,愿这璀璨的灯火,照亮浪漫的人生。",
|
| 731 |
+
"灯光摇曳,人影婆娑。愿这一夜,温柔又美好。",
|
| 732 |
+
"十三试灯,愿所有的浪漫,都恰逢其时。",
|
| 733 |
+
"璀璨灯火,映照笑脸。愿马年的每一天,都灿烂如花。",
|
| 734 |
+
"正月十三,愿在灯火阑珊处,遇见那个对的人。",
|
| 735 |
+
"浪漫之夜,灯火可亲。愿幸福绵长,岁月静好。",
|
| 736 |
+
"试灯初亮,浪漫开场。愿2026,不负韶华。",
|
| 737 |
+
|
| 738 |
+
# 期盼圆满篇
|
| 739 |
+
"正月十三,期盼圆满。愿元宵之夜,月圆人圆事事圆。",
|
| 740 |
+
"试灯是序曲,元宵是高潮。愿精彩值得等待。",
|
| 741 |
+
"十三这一天,为圆满做最后的准备。",
|
| 742 |
+
"期盼那一碗汤圆,期盼那一夜团圆。",
|
| 743 |
+
"正月十三,愿所有的等待,都不负期待。",
|
| 744 |
+
"试灯之时,许下心愿:愿2026,圆圆满满。",
|
| 745 |
+
"期盼元宵,期盼美好。愿马年的第一个月圆,圆满无缺。",
|
| 746 |
+
"正月十三,愿这份期盼,化作甜蜜的果实。",
|
| 747 |
+
"灯火试明,圆满将至。愿幸福如约而至。",
|
| 748 |
+
"十三之日,愿所有的梦想,都圆满落地。",
|
| 749 |
+
|
| 750 |
+
# 短句补充篇
|
| 751 |
+
"正月十三,试灯纳福。",
|
| 752 |
+
"灯火璀璨,浪漫无边。",
|
| 753 |
+
"试灯初亮,希望在前。",
|
| 754 |
+
"十三吉祥,圆满可期。",
|
| 755 |
+
"璀璨灯火,照亮前程。",
|
| 756 |
+
"喜迎元宵,万事胜意。",
|
| 757 |
+
"试灯之夜,幸福相伴。",
|
| 758 |
+
"马年十三,光彩照人。",
|
| 759 |
+
"灯火可亲,岁月温柔。",
|
| 760 |
+
"期盼圆满,福满人间。"
|
| 761 |
+
],
|
| 762 |
+
|
| 763 |
+
# ==============================================
|
| 764 |
+
# 2026年3月2日 正月十四 (元宵前夕/月色)
|
| 765 |
+
# 核心:待圆、酝酿、惜别、倒数
|
| 766 |
+
# ==============================================
|
| 767 |
+
date(2026, 3, 2): [
|
| 768 |
+
# 待圆酝酿篇
|
| 769 |
+
"正月十四,待圆之时。所有的美好,都在悄然酝酿。",
|
| 770 |
+
"月圆前夜,幸福将至。愿明天的团圆,圆满无缺。",
|
| 771 |
+
"十四这一天,静候月圆。愿所有的期待,都开花结果。",
|
| 772 |
+
"美好在酝酿,幸福在靠近。正月十四,满怀希望。",
|
| 773 |
+
"元宵前夕,蓄势待发。愿明天的烟火,惊艳时光。",
|
| 774 |
+
"十四之夜,月色渐浓。愿这温柔的夜,孕育美好的明天。",
|
| 775 |
+
"待圆之日,心怀期盼。愿2026,事事圆满。",
|
| 776 |
+
"正月十四,愿所有的遗憾,都在明天圆满。",
|
| 777 |
+
"酝酿已久的幸福,即将在明天绽放。",
|
| 778 |
+
"十四这一天,愿耐心等待,收获圆满。",
|
| 779 |
+
|
| 780 |
+
# 月色温柔篇
|
| 781 |
+
"正月十四,月色温柔。愿这一夜的月光,照亮心底的柔软。",
|
| 782 |
+
"月圆前夜,月色撩人。愿这温柔的光,伴你入梦。",
|
| 783 |
+
"十四之夜,月光如水。愿岁月静好,现世安稳。",
|
| 784 |
+
"月色朦胧,情意绵绵。愿这一夜,浪漫又安宁。",
|
| 785 |
+
"正月十四,愿月光指引,找到回家的路。",
|
| 786 |
+
"月光洒在身上,幸福藏在心里。",
|
| 787 |
+
"十四的月亮,虽未圆满,却已温柔。",
|
| 788 |
+
"愿这月色,洗去一身疲惫,带来满心欢喜。",
|
| 789 |
+
"正月十四,月色相伴,幸福相随。",
|
| 790 |
+
"月光所至,皆是美好。愿马年的夜晚,月色常明。",
|
| 791 |
+
|
| 792 |
+
# 惜别新春篇
|
| 793 |
+
"正月十四,惜别新春。愿这份年味,永驻心间。",
|
| 794 |
+
"春节的最后倒计时,珍惜最后的热闹。",
|
| 795 |
+
"十四这一天,是春节的尾声,也是元宵的序曲。",
|
| 796 |
+
"惜别新春,迎接元宵。愿美好延续,幸福长存。",
|
| 797 |
+
"正月十四,愿抓住春节的尾巴,再快乐一次。",
|
| 798 |
+
"年味渐淡,情意更浓。愿这份祝福,伴你一整年。",
|
| 799 |
+
"惜别旧岁的热闹,迎接新年的安稳。",
|
| 800 |
+
"十四之日,愿感恩相遇,珍惜拥有。",
|
| 801 |
+
"新春将过,记忆永存。愿2026,温暖常在。",
|
| 802 |
+
"正月十四,愿不负新春,不负韶华。",
|
| 803 |
+
|
| 804 |
+
# 短句补充篇
|
| 805 |
+
"正月十四,静待月圆。",
|
| 806 |
+
"月色温柔,幸福将至。",
|
| 807 |
+
"元宵前夕,蓄势待发。",
|
| 808 |
+
"惜别新春,迎接圆满。",
|
| 809 |
+
"十四吉祥,万事顺遂。",
|
| 810 |
+
"美好酝酿,幸福花开。",
|
| 811 |
+
"月色撩人,情意绵绵。",
|
| 812 |
+
"马年十四,期待满满。",
|
| 813 |
+
"静待花开,如愿以偿。",
|
| 814 |
+
"福暖元宵,圆满在即。"
|
| 815 |
+
],
|
| 816 |
+
|
| 817 |
+
# ==============================================
|
| 818 |
+
# 2026年3月3日 正月十五 (元宵节/上元节)
|
| 819 |
+
# 核心:团圆、圆满、灯火、收官
|
| 820 |
+
# ==============================================
|
| 821 |
+
date(2026, 3, 3): [
|
| 822 |
+
# 圆满团圆篇
|
| 823 |
+
"正月十五,元宵佳节。愿月圆人圆,事事圆满。",
|
| 824 |
+
"上元之夜,万家团圆。愿这一轮明月,照亮每一个归人。",
|
| 825 |
+
"灯火良宵,鱼龙百戏。愿今宵团圆,岁岁长安。",
|
| 826 |
+
"一碗汤圆,一份团圆。愿生活软糯香甜,日子圆圆满满。",
|
| 827 |
+
"正月十五,月光所至,皆是团圆。",
|
| 828 |
+
"闹元宵,庆团圆。愿所有的思念,都能奔赴相见。",
|
| 829 |
+
"马年第一个月圆夜,愿美好与圆满撞个满怀。",
|
| 830 |
+
"花好月圆人团圆,福满乾坤春满园。",
|
| 831 |
+
"元宵佳节,愿天涯共此时,千里共婵娟。",
|
| 832 |
+
"圆满收官,幸福续航。愿这份团圆,延续一整年。",
|
| 833 |
+
|
| 834 |
+
# 灯火璀璨篇
|
| 835 |
+
"东风夜放花千树,更吹落,星如雨。上元灯火,璀璨人间。",
|
| 836 |
+
"正月十五,花灯如昼。愿这漫天灯火,照亮前行的路。",
|
| 837 |
+
"灯火万家,良辰美景。愿身处璀璨,心向光明。",
|
| 838 |
+
"赏花灯,猜灯谜。愿元宵之夜,热闹非凡,喜乐无边。",
|
| 839 |
+
"灯火阑珊处,美好正发生。愿你遇见惊喜,遇见幸运。",
|
| 840 |
+
"上元灯火,映照笑脸。愿2026,光彩夺目,熠熠生辉。",
|
| 841 |
+
"今夜灯明,如昼如幻。愿马年的日子,红红火火。",
|
| 842 |
+
"一盏花灯,一份祈愿。愿心之所向,光亮通达。",
|
| 843 |
+
"烟花绽放,灯火璀璨。愿这一刻的美好,定格成永恒。",
|
| 844 |
+
"元宵夜,看花灯。愿生活如灯,五彩斑斓,充满希望。",
|
| 845 |
+
|
| 846 |
+
# 喜乐民俗篇
|
| 847 |
+
"正月十五闹元宵,锣鼓喧天春意闹。愿欢声笑语,响彻云霄。",
|
| 848 |
+
"吃汤圆,闹元宵。愿团团圆圆,甜甜蜜蜜。",
|
| 849 |
+
"猜灯谜,赢好礼。愿智慧与福气,双双入怀。",
|
| 850 |
+
"舞龙舞狮,锣鼓喧天。愿马年的运势,气势如虹。",
|
| 851 |
+
"踩高跷,划旱船。愿民间喜乐,岁岁相传。",
|
| 852 |
+
"元宵佳节,宜欢聚,宜赏灯,宜纳福。",
|
| 853 |
+
"捏个汤圆,团团圆圆;挂盏灯笼,亮亮堂堂。",
|
| 854 |
+
"上元祈福,百无禁忌。愿所求皆如愿,所行皆坦途。",
|
| 855 |
+
"闹元宵,迎福气。愿2026,人气旺,财气旺,运气旺。",
|
| 856 |
+
"传统民俗,热闹元宵。愿文化传承,岁月流芳。",
|
| 857 |
+
|
| 858 |
+
# 新春收官篇
|
| 859 |
+
"正月十五,新春收官。感谢相遇,期待同行。",
|
| 860 |
+
"年味虽淡,情意不减。元宵一过,整装出发。",
|
| 861 |
+
"春节的最后一场狂欢,愿不留遗憾,尽兴而归。",
|
| 862 |
+
"圆满收官,奔赴新程。愿马年的下半场,更加精彩。",
|
| 863 |
+
"以元宵的圆满,开启全年的顺遂。",
|
| 864 |
+
"告别新春,迎接春天。愿万物复苏,梦想发芽。",
|
| 865 |
+
"正月十五,为春节画上一个完美的句号。",
|
| 866 |
+
"收官之夜,许下宏愿。愿2026,马力全开,一往无前。",
|
| 867 |
+
"新春已过,奋斗在即。愿不负春光,不负自己。",
|
| 868 |
+
"元宵圆满,万事胜意。愿这一年,步履不停,收获满满。",
|
| 869 |
+
|
| 870 |
+
# 短句补充篇
|
| 871 |
+
"元宵快乐,圆满吉祥。",
|
| 872 |
+
"花好月圆,喜乐安康。",
|
| 873 |
+
"灯火万家,幸福中华。",
|
| 874 |
+
"上元佳节,福满人间。",
|
| 875 |
+
"汤圆甜甜,日子圆圆。",
|
| 876 |
+
"闹元宵,迎好运。",
|
| 877 |
+
"马年元宵,圆满收官。",
|
| 878 |
+
"花灯璀璨,前程似锦。",
|
| 879 |
+
"月圆人圆,事事圆满。",
|
| 880 |
+
"正月十五,万事亨通。"
|
| 881 |
+
]
|
| 882 |
+
}
|
| 883 |
+
|
| 884 |
+
lunar_calendar = {
|
| 885 |
+
date(2026, 2, 16): "除夕",
|
| 886 |
+
date(2026, 2, 17): "正月初一",
|
| 887 |
+
date(2026, 2, 18): "正月初二",
|
| 888 |
+
date(2026, 2, 19): "正月初三",
|
| 889 |
+
date(2026, 2, 20): "正月初四",
|
| 890 |
+
date(2026, 2, 21): "正月初五",
|
| 891 |
+
date(2026, 2, 22): "正月初六",
|
| 892 |
+
date(2026, 2, 23): "正月初七",
|
| 893 |
+
date(2026, 2, 24): "正月初八",
|
| 894 |
+
date(2026, 2, 25): "正月初九",
|
| 895 |
+
date(2026, 2, 26): "正月初十",
|
| 896 |
+
date(2026, 2, 27): "正月十一",
|
| 897 |
+
date(2026, 2, 28): "正月十二",
|
| 898 |
+
date(2026, 3, 1): "正月十三",
|
| 899 |
+
date(2026, 3, 2): "正月十四",
|
| 900 |
+
date(2026, 3, 3): "正月十五"
|
| 901 |
+
}
|
| 902 |
+
|
| 903 |
+
def get_lunar_date(gregorian_date):
|
| 904 |
+
"""
|
| 905 |
+
根据公历日期获取对应的农历日期
|
| 906 |
+
参数:gregorian_date - datetime.date 对象
|
| 907 |
+
返回:str - 农历日期字符串;None - 日期不在农历范围内
|
| 908 |
+
"""
|
| 909 |
+
return lunar_calendar.get(gregorian_date, None)
|
| 910 |
+
|
| 911 |
+
def get_random_festival_quote():
|
| 912 |
+
"""
|
| 913 |
+
根据当前日期从 SPRING_FESTIVAL_QUOTES 中随机获取一条祝福语
|
| 914 |
+
返回:str - 随机选中的祝福语;None - 当前日期无对应祝福语
|
| 915 |
+
"""
|
| 916 |
+
# 获取当前系统日期(年-月-日)
|
| 917 |
+
today = date.today()
|
| 918 |
+
|
| 919 |
+
# 1. 检查当前日期是否在祝福语字典中
|
| 920 |
+
if today in SPRING_FESTIVAL_QUOTES:
|
| 921 |
+
# 2. 获取当日的所有祝福语列表
|
| 922 |
+
daily_quotes = SPRING_FESTIVAL_QUOTES[today]
|
| 923 |
+
# 3. 随机选择一条祝福语
|
| 924 |
+
random_quote = random.choice(daily_quotes)
|
| 925 |
+
return random_quote
|
| 926 |
+
else:
|
| 927 |
+
# 若当前日期无对应祝福语,返回提示(也可改为返回None)
|
| 928 |
+
return f"今日({today.strftime('%Y年%m月%d日')})暂无专属春节祝福语"
|
| 929 |
+
|
| 930 |
+
# 测试调用示例
|
| 931 |
+
if __name__ == "__main__":
|
| 932 |
+
quote = get_random_festival_quote()
|
| 933 |
+
print(quote)
|
utils/config.py
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import logging
|
| 3 |
+
import os
|
| 4 |
+
import sys
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
from utils.logger import setup_logger
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
logger = setup_logger(level=logging.DEBUG)
|
| 12 |
+
|
| 13 |
+
DEBUG = False
|
| 14 |
+
CONFIGFILE = "config.json"
|
| 15 |
+
USERDATAFILE = "usersData.json"
|
| 16 |
+
BASE_DIR = Path(__file__).resolve().parent.parent
|
| 17 |
+
config = None
|
| 18 |
+
userData = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class Environment(Enum):
|
| 22 |
+
GITHUBACTION = "GITHUB_ACTION"
|
| 23 |
+
LOCAL = "LOCAL"
|
| 24 |
+
PACKED = "PACKED"
|
| 25 |
+
HUGGINGFACE = "HUGGINGFACE"
|
| 26 |
+
|
| 27 |
+
def __str__(self):
|
| 28 |
+
return self.value
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def get_environment():
|
| 32 |
+
if getattr(sys, "frozen", False) and hasattr(sys, "_MEIPASS"):
|
| 33 |
+
return Environment.PACKED
|
| 34 |
+
if os.getenv("GITHUB_ACTIONS") == "true":
|
| 35 |
+
return Environment.GITHUBACTION
|
| 36 |
+
if os.getenv("SPACE_ID") or os.getenv("HF_SPACE_ID"):
|
| 37 |
+
return Environment.HUGGINGFACE
|
| 38 |
+
return Environment.LOCAL
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _resolve_runtime_file(file_name):
|
| 42 |
+
env = get_environment()
|
| 43 |
+
if env == Environment.PACKED:
|
| 44 |
+
return os.path.join(os.path.dirname(sys.executable), file_name)
|
| 45 |
+
return str((BASE_DIR / file_name).resolve())
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_config():
|
| 49 |
+
global config
|
| 50 |
+
|
| 51 |
+
if config is not None:
|
| 52 |
+
return config
|
| 53 |
+
|
| 54 |
+
config_path = _resolve_runtime_file(CONFIGFILE)
|
| 55 |
+
with open(config_path, "r", encoding="utf-8") as f:
|
| 56 |
+
config = json.loads(f.read())
|
| 57 |
+
return config
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def reload_config():
|
| 61 |
+
global config
|
| 62 |
+
config = None
|
| 63 |
+
return get_config()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def get_userData():
|
| 67 |
+
global userData
|
| 68 |
+
|
| 69 |
+
if userData is not None:
|
| 70 |
+
return userData
|
| 71 |
+
|
| 72 |
+
env = get_environment()
|
| 73 |
+
if env == Environment.GITHUBACTION:
|
| 74 |
+
user_data_json = os.getenv("USER_DATA", None)
|
| 75 |
+
if not user_data_json:
|
| 76 |
+
logger.error("Environment variable USER_DATA is not set.")
|
| 77 |
+
raise RuntimeError("USER_DATA is required in GitHub Actions.")
|
| 78 |
+
else:
|
| 79 |
+
user_data_path = _resolve_runtime_file(USERDATAFILE)
|
| 80 |
+
if not os.path.exists(user_data_path):
|
| 81 |
+
raise FileNotFoundError(f"Missing required file: {user_data_path}")
|
| 82 |
+
with open(user_data_path, "r", encoding="utf-8") as f:
|
| 83 |
+
user_data_json = f.read()
|
| 84 |
+
|
| 85 |
+
userData = json.loads(user_data_json)
|
| 86 |
+
return userData
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def reload_userData():
|
| 90 |
+
global userData
|
| 91 |
+
userData = None
|
| 92 |
+
return get_userData()
|
utils/github_action_config.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
from rich.console import Console
|
| 3 |
+
from rich.panel import Panel
|
| 4 |
+
from utils.config import get_config
|
| 5 |
+
import pyperclip
|
| 6 |
+
|
| 7 |
+
config = get_config()
|
| 8 |
+
|
| 9 |
+
# 初始化 rich 控制台
|
| 10 |
+
console = Console()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def compress_users_data():
|
| 14 |
+
# 压缩 usersData.json 内容
|
| 15 |
+
with open("usersData.json", "r", encoding="utf-8") as f:
|
| 16 |
+
user_data = json.loads(f.read())
|
| 17 |
+
|
| 18 |
+
return json.dumps(user_data, ensure_ascii=False)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def print_github_action_config():
|
| 22 |
+
"""
|
| 23 |
+
打印 GitHub Action 配置表格
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
# 输出前置步骤说明
|
| 27 |
+
steps = (
|
| 28 |
+
"1. 确保已克隆仓库并在仓库的 [bold yellow]Action[/bold yellow] 选项卡下启用 "
|
| 29 |
+
"[bold green]DouYin Spark Flow Schedule Run[/bold green]\n"
|
| 30 |
+
"2. 在仓库的设置选项卡下的 [bold yellow]Environment[/bold yellow] 配置项中添加 "
|
| 31 |
+
"[bold green]user-data[/bold green] 环境,并将下方列出 Secrets 依次添加到该环境的 Secrets 中"
|
| 32 |
+
)
|
| 33 |
+
console.print(Panel(steps, title="前置步骤", expand=False, style="bold cyan"))
|
| 34 |
+
|
| 35 |
+
secrets = {
|
| 36 |
+
"USER_DATA": compress_users_data()
|
| 37 |
+
}
|
| 38 |
+
if "proxyAddress" in config and config["proxyAddress"]:
|
| 39 |
+
secrets["proxyAddress"] = config["proxyAddress"]
|
| 40 |
+
|
| 41 |
+
# 打印每个键名和键值
|
| 42 |
+
console.print("\n[bold yellow]Secrets 配置:选中后右击鼠标复制(没有弹出菜单点击鼠标右键就完成复制了!)[/bold yellow]")
|
| 43 |
+
|
| 44 |
+
for key, value in secrets.items():
|
| 45 |
+
console.rule(f"[bold cyan]{key}[/bold cyan]")
|
| 46 |
+
console.print(f"[green]{value}[/green]\n")
|
| 47 |
+
|
| 48 |
+
pyperclip.copy(secrets["USER_DATA"])
|
| 49 |
+
console.print("[bold yellow]提示:[/bold yellow][bold magenta] USER_DATA 的值已自动写入剪贴板(建议直接粘贴,手动复制可能多出空白符导致出错) [/bold magenta]")
|
utils/hitokoto.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
from utils.config import get_config
|
| 3 |
+
|
| 4 |
+
hitokotoApi = "https://v1.hitokoto.cn/"
|
| 5 |
+
|
| 6 |
+
allHitokotoTypes = {
|
| 7 |
+
"动画": "a",
|
| 8 |
+
"漫画": "b",
|
| 9 |
+
"游戏": "c",
|
| 10 |
+
"文学": "d",
|
| 11 |
+
"原创": "e",
|
| 12 |
+
"来自网络": "f",
|
| 13 |
+
"其他": "g",
|
| 14 |
+
"影视": "h",
|
| 15 |
+
"诗词": "i",
|
| 16 |
+
"哲学": "k",
|
| 17 |
+
"抖机灵": "l",
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def request_hitokoto():
|
| 22 |
+
"""请求一言 API 获取一句话"""
|
| 23 |
+
config = get_config()
|
| 24 |
+
|
| 25 |
+
api_url = hitokotoApi
|
| 26 |
+
|
| 27 |
+
for t in allHitokotoTypes.keys():
|
| 28 |
+
if t in config["hitokotoTypes"]:
|
| 29 |
+
if "?" not in api_url:
|
| 30 |
+
api_url += "?"
|
| 31 |
+
if "c=" in api_url:
|
| 32 |
+
api_url += f"&c={allHitokotoTypes[t]}"
|
| 33 |
+
else:
|
| 34 |
+
api_url += f"c={allHitokotoTypes[t]}"
|
| 35 |
+
|
| 36 |
+
try:
|
| 37 |
+
response = requests.get(api_url, timeout=10)
|
| 38 |
+
response.raise_for_status()
|
| 39 |
+
data = response.json()
|
| 40 |
+
theFrom = data.get("from")
|
| 41 |
+
if theFrom is None or theFrom.strip() == "":
|
| 42 |
+
theFrom = "未知来源"
|
| 43 |
+
theFromWho = data.get("from_who")
|
| 44 |
+
if theFromWho is None or theFromWho.strip() == "":
|
| 45 |
+
theFromWho = "未知作者"
|
| 46 |
+
return f"{data['hitokoto']} —— {theFrom} ({theFromWho})"
|
| 47 |
+
except Exception as e:
|
| 48 |
+
return "[error] 无法获取一言内容"
|
utils/logger.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
import os
|
| 3 |
+
from collections import deque
|
| 4 |
+
from logging.handlers import RotatingFileHandler
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
if not os.path.exists("logs"):
|
| 8 |
+
os.makedirs("logs")
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
LOG_FORMAT = "%(asctime)s - %(name)s - %(levelname)s - %(filename)s:%(lineno)d - %(message)s"
|
| 12 |
+
LOG_FILE = "logs/app.log"
|
| 13 |
+
MAX_IN_MEMORY_LOGS = 2000
|
| 14 |
+
_recent_logs = deque(maxlen=MAX_IN_MEMORY_LOGS)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class InMemoryLogHandler(logging.Handler):
|
| 18 |
+
"""Keep recent log lines in memory for dashboard display."""
|
| 19 |
+
|
| 20 |
+
def emit(self, record):
|
| 21 |
+
try:
|
| 22 |
+
_recent_logs.append(self.format(record))
|
| 23 |
+
except Exception:
|
| 24 |
+
# Never let logging break business logic.
|
| 25 |
+
pass
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def get_recent_logs(limit=400):
|
| 29 |
+
if limit <= 0:
|
| 30 |
+
return ""
|
| 31 |
+
return "\n".join(list(_recent_logs)[-limit:])
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def setup_logger(name="app", level=logging.INFO):
|
| 35 |
+
logger = logging.getLogger(name)
|
| 36 |
+
logger.setLevel(level)
|
| 37 |
+
|
| 38 |
+
if logger.handlers:
|
| 39 |
+
return logger
|
| 40 |
+
|
| 41 |
+
formatter = logging.Formatter(LOG_FORMAT)
|
| 42 |
+
|
| 43 |
+
console_handler = logging.StreamHandler()
|
| 44 |
+
console_handler.setLevel(level)
|
| 45 |
+
console_handler.setFormatter(formatter)
|
| 46 |
+
logger.addHandler(console_handler)
|
| 47 |
+
|
| 48 |
+
memory_handler = InMemoryLogHandler()
|
| 49 |
+
memory_handler.setLevel(level)
|
| 50 |
+
memory_handler.setFormatter(formatter)
|
| 51 |
+
logger.addHandler(memory_handler)
|
| 52 |
+
|
| 53 |
+
try:
|
| 54 |
+
file_handler = RotatingFileHandler(
|
| 55 |
+
LOG_FILE,
|
| 56 |
+
maxBytes=5 * 1024 * 1024,
|
| 57 |
+
backupCount=3,
|
| 58 |
+
encoding="utf-8",
|
| 59 |
+
)
|
| 60 |
+
file_handler.setLevel(level)
|
| 61 |
+
file_handler.setFormatter(formatter)
|
| 62 |
+
logger.addHandler(file_handler)
|
| 63 |
+
except Exception as exc:
|
| 64 |
+
logger.warning("Failed to initialize file logger: %s", exc)
|
| 65 |
+
|
| 66 |
+
return logger
|
| 67 |
+
|