cacode commited on
Commit
d10e42a
·
verified ·
1 Parent(s): d12275f

Upload 33 files

Browse files
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, "&amp;")
82
+ .replace(/</g, "&lt;")
83
+ .replace(/>/g, "&gt;")
84
+ .replace(/\"/g, "&quot;")
85
+ .replace(/'/g, "&#39;");
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, "&amp;")
134
+ .replace(/</g, "&lt;")
135
+ .replace(/>/g, "&gt;")
136
+ .replace(/\"/g, "&quot;")
137
+ .replace(/'/g, "&#39;");
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
+