diff --git a/api/monitor/models.py b/api/monitor/models.py
index f3e439d..b4a4ef5 100644
--- a/api/monitor/models.py
+++ b/api/monitor/models.py
@@ -75,7 +75,7 @@ MODE_NOTE = "note"
class MonitorTask(MonitorBase):
- """One monitored schedule: a set of targets plus an interval."""
+ """One monitored schedule: a set of targets, plus when to run them."""
__tablename__ = "monitor_task"
@@ -86,6 +86,21 @@ class MonitorTask(MonitorBase):
enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
interval_minutes: Mapped[int] = mapped_column(Integer, nullable=False, default=360)
+ # How the task is scheduled. `interval` is the original "every N minutes" and
+ # stays the default; `daily` and `weekly` fire at chosen clock times instead
+ # (the arithmetic lives in schedule.py).
+ #
+ # The clock fields are comma-separated text rather than a child table: they
+ # are a handful of small integers, always read as a whole, and a table would
+ # buy nothing but joins.
+ schedule_mode: Mapped[str] = mapped_column(String(16), nullable=False, default="interval")
+ # 0-23, e.g. "9,12,18". Empty in interval mode.
+ schedule_hours: Mapped[str] = mapped_column(String(96), nullable=False, default="")
+ # 0-6 with Monday = 0, matching Python's date.weekday(). Weekly mode only.
+ schedule_days: Mapped[str] = mapped_column(String(32), nullable=False, default="")
+ # Minute past the hour, shared by every time in the schedule.
+ schedule_minute: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+
# Crawl window knobs, mirrored onto each run's CLI flags.
max_notes_count: Mapped[int] = mapped_column(Integer, nullable=False, default=20)
enable_comments: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
diff --git a/api/monitor/schedule.py b/api/monitor/schedule.py
new file mode 100644
index 0000000..b9ba7ae
--- /dev/null
+++ b/api/monitor/schedule.py
@@ -0,0 +1,167 @@
+# -*- coding: utf-8 -*-
+# Copyright (c) 2025 relakkes@gmail.com
+#
+# This file is part of MediaCrawler project.
+# Repository: https://github.com/NanmiCoder/MediaCrawler/blob/main/api/monitor/schedule.py
+# GitHub: https://github.com/NanmiCoder
+# Licensed under NON-COMMERCIAL LEARNING LICENSE 1.1
+#
+# 声明:本代码仅供学习和研究目的使用。使用者应遵守以下原则:
+# 1. 不得用于任何商业用途。
+# 2. 使用时应遵守目标平台的使用条款和robots.txt规则。
+# 3. 不得进行大规模爬取或对平台造成运营干扰。
+# 4. 应合理控制请求频率,避免给目标平台带来不必要的负担。
+# 5. 不得用于任何非法或不当的用途。
+#
+# 详细许可条款请参阅项目根目录下的LICENSE文件。
+# 使用本代码即表示您同意遵守上述原则和LICENSE中的所有条款。
+
+"""Monitor task schedule arithmetic.
+
+Three modes, all expressible by a picker. A raw cron string was ruled out on
+purpose -- it is a small language, and the operator should not have to write one
+to say "every day at nine":
+
+* ``interval`` -- every N minutes.
+* ``daily`` -- at chosen clock times, e.g. 09:00 and 18:30.
+* ``weekly`` -- at chosen clock times on chosen weekdays, e.g. Mon-Fri 10:00.
+
+The two clock modes are **fixed-time**, unlike ``interval``, which is fixed-delay.
+The distinction matters: for an interval, measuring the next slot from when the
+run starts is what stops a slow run from firing back-to-back. For a clock
+schedule it would be wrong, because a run that starts at 09:07 would drag every
+later run seven minutes late, compounding all day.
+
+Fixed-time also means **no jitter is applied** to clock schedules. The operator
+picked a time; quietly running at 09:04 instead of 09:00 is not a feature, it just
+looks like a bug. ``interval`` keeps its jitter, where there is no stated time to
+contradict.
+
+All arithmetic is in the server's local timezone -- naive datetimes on purpose,
+because the container is pinned to the operator's zone via TZ and pretending
+otherwise would add a timezone concept nobody asked for.
+"""
+
+from datetime import datetime, time, timedelta
+from typing import Optional, Sequence
+
+MODE_INTERVAL = "interval"
+MODE_DAILY = "daily"
+MODE_WEEKLY = "weekly"
+
+SCHEDULE_MODES = (MODE_INTERVAL, MODE_DAILY, MODE_WEEKLY)
+CLOCK_MODES = (MODE_DAILY, MODE_WEEKLY)
+
+_MS_PER_MINUTE = 60_000
+_WEEKDAY_NAMES = "一二三四五六日"
+
+
+def parse_hours(raw: Optional[str]) -> list[int]:
+ """``"9,18"`` -> ``[9, 18]``. Sorted, de-duplicated, junk dropped."""
+ return _parse_int_list(raw, 0, 23)
+
+
+def parse_days(raw: Optional[str]) -> list[int]:
+ """``"0,2,4"`` -> ``[0, 2, 4]``. **0 is Monday**, matching ``date.weekday()``."""
+ return _parse_int_list(raw, 0, 6)
+
+
+def _parse_int_list(raw: Optional[str], low: int, high: int) -> list[int]:
+ values: set[int] = set()
+ for chunk in (raw or "").split(","):
+ chunk = chunk.strip()
+ if not chunk:
+ continue
+ try:
+ number = int(chunk)
+ except ValueError:
+ # Stored values come from our own UI, but a hand-edited row must not
+ # be able to crash the scheduler loop.
+ continue
+ if low <= number <= high:
+ values.add(number)
+ return sorted(values)
+
+
+def format_hours(hours: Sequence[int]) -> str:
+ return ",".join(str(hour) for hour in sorted(set(hours)))
+
+
+def format_days(days: Sequence[int]) -> str:
+ return ",".join(str(day) for day in sorted(set(days)))
+
+
+def describe(
+ *,
+ mode: str,
+ interval_minutes: int,
+ hours: Sequence[int],
+ days: Sequence[int],
+ minute: int,
+) -> str:
+ """One human sentence for the task card.
+
+ Lives here rather than in the frontend so the list view and the editor cannot
+ drift apart on what a schedule means.
+ """
+ if mode == MODE_INTERVAL:
+ if interval_minutes % 1440 == 0:
+ return f"每 {interval_minutes // 1440} 天"
+ if interval_minutes % 60 == 0:
+ return f"每 {interval_minutes // 60} 小时"
+ return f"每 {interval_minutes} 分钟"
+
+ if not hours:
+ return "未设置时间"
+
+ clock = "、".join(f"{hour:02d}:{minute:02d}" for hour in sorted(set(hours)))
+
+ if mode == MODE_DAILY:
+ return f"每天 {clock}"
+
+ if not days:
+ return f"每天 {clock}"
+ labels = "、".join(f"周{_WEEKDAY_NAMES[day]}" for day in sorted(set(days)))
+ return f"{labels} {clock}"
+
+
+def next_occurrence(
+ *,
+ mode: str,
+ interval_minutes: int,
+ hours: Sequence[int],
+ days: Sequence[int],
+ minute: int,
+ after_ms: int,
+) -> Optional[int]:
+ """The next fire time strictly after ``after_ms``, as epoch milliseconds.
+
+ ``None`` means the schedule can never fire -- a clock mode with no hours
+ chosen. Callers store that as "no next run" rather than something in the past,
+ which would otherwise leave the task permanently due and re-running on every
+ tick.
+ """
+ if mode == MODE_INTERVAL:
+ return after_ms + max(1, interval_minutes) * _MS_PER_MINUTE
+
+ if not hours:
+ return None
+
+ now = datetime.fromtimestamp(after_ms / 1000)
+ # No weekdays chosen means every day, matching describe(). Without the `days`
+ # guard an empty selection would produce an empty allowed set, no matching day,
+ # and a task that silently never runs.
+ allowed_days = set(days) if (mode == MODE_WEEKLY and days) else set(range(7))
+
+ # Eight days of lookahead covers today's remaining slots plus a full week,
+ # which is more than any weekday selection can need.
+ for offset in range(8):
+ day = (now + timedelta(days=offset)).date()
+ if day.weekday() not in allowed_days:
+ continue
+ for hour in sorted(set(hours)):
+ candidate = datetime.combine(day, time(hour=hour, minute=minute))
+ if candidate > now:
+ return int(candidate.timestamp() * 1000)
+
+ return None
diff --git a/api/monitor/scheduler.py b/api/monitor/scheduler.py
index 0792f1a..cf6c63b 100644
--- a/api/monitor/scheduler.py
+++ b/api/monitor/scheduler.py
@@ -23,8 +23,15 @@ is enough here: there is exactly one process, one global crawler subprocess, and
therefore no concurrency to coordinate -- a cron-style library would add a
dependency without adding a capability.
-Scheduling is **fixed-delay**, not fixed-rate: ``next_run_at`` is set from the
-moment a run starts, so a slow run cannot make its task fire back-to-back.
+Two families of schedule, and the difference matters:
+
+* ``interval`` is **fixed-delay**, not fixed-rate -- ``next_run_at`` is measured
+ from the moment a run starts, so a slow run cannot make its task fire
+ back-to-back.
+* the clock modes (``daily``/``weekly``) are **fixed-time** -- recomputed from the
+ calendar, so a run that starts late does not drag every later run with it.
+
+The arithmetic for both lives in schedule.py.
"""
import asyncio
@@ -37,7 +44,7 @@ from sqlalchemy import select
from tools.time_util import get_current_timestamp
from ..services import crawler_manager
-from . import app_settings
+from . import app_settings, schedule
from .db import get_session
from .models import MonitorRun, MonitorTask, RUN_INTERRUPTED, RUN_RUNNING
from .runner import execute_task
@@ -45,10 +52,9 @@ from .settings import get_cookie
POLL_INTERVAL_SECONDS = 20
# Spread tasks sharing an interval so they do not all come due on the same tick.
+# Applied to interval mode only -- see the advance step below.
JITTER_SECONDS = 60
-_MS_PER_MINUTE = 60_000
-
class MonitorScheduler:
"""Polls the task table and runs whatever is due."""
@@ -168,11 +174,34 @@ class MonitorScheduler:
# Advance before running so a crash mid-run cannot cause an immediate
# re-fire, and so a long outage coalesces into a single run instead
# of one run per missed interval.
- task.next_run_at = (
- get_current_timestamp()
- + task.interval_minutes * _MS_PER_MINUTE
- + random.randint(0, JITTER_SECONDS) * 1000
+ now = get_current_timestamp()
+ following = schedule.next_occurrence(
+ mode=task.schedule_mode,
+ interval_minutes=task.interval_minutes,
+ hours=schedule.parse_hours(task.schedule_hours),
+ days=schedule.parse_days(task.schedule_days),
+ minute=task.schedule_minute,
+ after_ms=now,
)
+
+ if following is None:
+ # A clock schedule with no times can never fire. The API rejects
+ # that shape, so this guards against a hand-edited row: park the
+ # task with no next run rather than leaving it permanently due and
+ # re-running it on every tick.
+ task.next_run_at = None
+ print(
+ f"[monitor.scheduler] task {task.id} has no usable schedule "
+ f"and will not run until one is set"
+ )
+ elif task.schedule_mode == schedule.MODE_INTERVAL:
+ # Jitter belongs to the interval mode only. Spreading identical
+ # intervals apart is the point; nudging a time the operator
+ # explicitly picked is not -- it just looks like a broken clock.
+ task.next_run_at = following + random.randint(0, JITTER_SECONDS) * 1000
+ else:
+ task.next_run_at = following
+
task_id = task.id
try:
diff --git a/api/monitor/service.py b/api/monitor/service.py
index 20c03bd..117e81d 100644
--- a/api/monitor/service.py
+++ b/api/monitor/service.py
@@ -28,7 +28,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from tools.time_util import get_current_timestamp
-from . import app_settings, platforms
+from . import app_settings, platforms, schedule
from .db import get_session
from .platforms import PLATFORM_XHS
from .models import (
@@ -53,6 +53,28 @@ _background_runs: set[asyncio.Task] = set()
MIN_INTERVAL_MINUTES = 30
MAX_INTERVAL_MINUTES = 7 * 24 * 60
+# Changing any of these invalidates the pending run slot.
+SCHEDULE_FIELDS = {
+ "interval_minutes",
+ "schedule_mode",
+ "schedule_hours",
+ "schedule_days",
+ "schedule_minute",
+}
+
+
+def _require_clock_fields(mode: str, hours: list, days: list) -> None:
+ """A clock schedule with no clock time can never fire.
+
+ The create schema already rejects that shape, but the update path merges
+ partial fields and therefore has no schema-level view of the result -- so the
+ check lives here, where both paths meet.
+ """
+ if mode in schedule.CLOCK_MODES and not hours:
+ raise ValueError("按钟点调度至少要选一个时间")
+ if mode == schedule.MODE_WEEKLY and not days:
+ raise ValueError("按周调度至少要选一个星期")
+
_CREATOR_URL_RE = re.compile(r"xiaohongshu\.com/user/profile/([A-Za-z0-9_-]+)")
_NOTE_URL_RE = re.compile(r"xiaohongshu\.com/(?:explore|discovery/item)/([A-Za-z0-9_-]+)")
# XHS user ids and note ids are 24-char hex; allow a slightly wider range so a
@@ -139,7 +161,12 @@ async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> Monitor
# the Settings page actually governs new tasks.
defaults = await app_settings.defaults(session, platform)
interval_minutes = payload.get("interval_minutes") or defaults["interval_minutes"]
- interval_ms = int(interval_minutes) * 60_000
+
+ schedule_mode = payload.get("schedule_mode") or schedule.MODE_INTERVAL
+ schedule_hours = list(payload.get("schedule_hours") or [])
+ schedule_days = list(payload.get("schedule_days") or [])
+ schedule_minute = int(payload.get("schedule_minute") or 0)
+ _require_clock_fields(schedule_mode, schedule_hours, schedule_days)
task = MonitorTask(
name=payload["name"],
@@ -147,12 +174,23 @@ async def create_task(session: AsyncSession, payload: Dict[str, Any]) -> Monitor
mode=mode,
enabled=payload.get("enabled", True),
interval_minutes=interval_minutes,
+ schedule_mode=schedule_mode,
+ schedule_hours=schedule.format_hours(schedule_hours),
+ schedule_days=schedule.format_days(schedule_days),
+ schedule_minute=schedule_minute,
max_notes_count=payload.get("max_notes_count") or defaults["max_notes_count"],
enable_comments=payload.get("enable_comments", True),
max_comments_count=payload.get("max_comments_count") or defaults["max_comments_count"],
run_timeout_seconds=payload.get("run_timeout_seconds", 3600),
notify_enabled=payload.get("notify_enabled", False),
- next_run_at=now + interval_ms,
+ next_run_at=schedule.next_occurrence(
+ mode=schedule_mode,
+ interval_minutes=interval_minutes,
+ hours=schedule_hours,
+ days=schedule_days,
+ minute=schedule_minute,
+ after_ms=now,
+ ),
last_status="idle",
created_at=now,
updated_at=now,
@@ -189,10 +227,14 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An
if task is None:
raise ValueError(f"Task {task_id} not found")
+ was_enabled = task.enabled
+
for field in (
"name",
"enabled",
"interval_minutes",
+ "schedule_mode",
+ "schedule_minute",
"max_notes_count",
"enable_comments",
"max_comments_count",
@@ -202,6 +244,13 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An
if field in payload and payload[field] is not None:
setattr(task, field, payload[field])
+ # The clock lists are stored as comma-separated text, so they cannot go
+ # through the generic loop above.
+ if payload.get("schedule_hours") is not None:
+ task.schedule_hours = schedule.format_hours(payload["schedule_hours"])
+ if payload.get("schedule_days") is not None:
+ task.schedule_days = schedule.format_days(payload["schedule_days"])
+
# Replacing targets resets the baseline implicitly: a note set that now
# includes new ids will simply report them as new on the next run.
if payload.get("targets") is not None:
@@ -227,8 +276,23 @@ async def update_task(session: AsyncSession, task_id: int, payload: Dict[str, An
)
)
- if "interval_minutes" in payload and payload["interval_minutes"]:
- task.next_run_at = get_current_timestamp() + payload["interval_minutes"] * 60_000
+ # Any change to when the task runs invalidates the pending slot, so recompute
+ # it from the merged state rather than working out which field moved.
+ # Re-enabling counts as a change too: otherwise a task switched off for a
+ # month comes back holding a next_run_at a month in the past and fires the
+ # instant it is saved.
+ if (SCHEDULE_FIELDS & set(payload)) or (task.enabled and not was_enabled):
+ hours = schedule.parse_hours(task.schedule_hours)
+ days = schedule.parse_days(task.schedule_days)
+ _require_clock_fields(task.schedule_mode, hours, days)
+ task.next_run_at = schedule.next_occurrence(
+ mode=task.schedule_mode,
+ interval_minutes=task.interval_minutes,
+ hours=hours,
+ days=days,
+ minute=task.schedule_minute,
+ after_ms=get_current_timestamp(),
+ )
task.updated_at = get_current_timestamp()
await session.flush()
@@ -623,6 +687,7 @@ async def list_tasks(
"mode": task.mode,
"enabled": task.enabled,
"interval_minutes": task.interval_minutes,
+ **_schedule_fields(task),
"max_notes_count": task.max_notes_count,
"enable_comments": task.enable_comments,
"max_comments_count": task.max_comments_count,
@@ -644,6 +709,29 @@ async def list_tasks(
]
+def _schedule_fields(task: MonitorTask) -> Dict[str, Any]:
+ """The schedule columns, plus the sentence the task list renders.
+
+ The label is composed here rather than in the frontend so the list and the
+ editor cannot drift on what a given schedule means.
+ """
+ hours = schedule.parse_hours(task.schedule_hours)
+ days = schedule.parse_days(task.schedule_days)
+ return {
+ "schedule_mode": task.schedule_mode,
+ "schedule_hours": hours,
+ "schedule_days": days,
+ "schedule_minute": task.schedule_minute,
+ "schedule_label": schedule.describe(
+ mode=task.schedule_mode,
+ interval_minutes=task.interval_minutes,
+ hours=hours,
+ days=days,
+ minute=task.schedule_minute,
+ ),
+ }
+
+
async def overview(session: AsyncSession, platform: Optional[str] = None) -> Dict[str, Any]:
"""Headline numbers for the dashboard tiles, scoped to one platform."""
now = get_current_timestamp()
diff --git a/api/schemas/monitor.py b/api/schemas/monitor.py
index f396503..f74c7a1 100644
--- a/api/schemas/monitor.py
+++ b/api/schemas/monitor.py
@@ -20,7 +20,7 @@
from typing import List, Literal, Optional
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, model_validator
# A floor on the interval is a correctness guard, not a nicety: every run
# launches a browser and hits XHS with several requests, so a short interval
@@ -40,6 +40,19 @@ class MonitorTaskCreate(BaseModel):
interval_minutes: Optional[int] = Field(
default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES
)
+ # --- Scheduling ---------------------------------------------------------
+ # All three modes are expressible with pickers; a raw cron string is
+ # deliberately not supported, since it is a small language to learn just to
+ # say "every day at nine".
+ schedule_mode: Literal["interval", "daily", "weekly"] = "interval"
+ # 0-23, e.g. [9, 12, 18]. Required for the two clock modes.
+ schedule_hours: List[int] = Field(default_factory=list)
+ # 0-6 with Monday = 0, matching Python's date.weekday(). Required for weekly.
+ schedule_days: List[int] = Field(default_factory=list)
+ # One minute for the whole schedule, so a task with three times is
+ # "09:30, 12:30, 18:30" rather than three separate minute choices.
+ schedule_minute: int = Field(default=0, ge=0, le=59)
+
max_notes_count: Optional[int] = Field(default=None, ge=1, le=500)
enable_comments: bool = True
# Raising this widens the comment window, which is the only lever available
@@ -53,6 +66,20 @@ class MonitorTaskCreate(BaseModel):
# Raw pasted values: full URLs or bare ids, in either form.
targets: List[str] = Field(min_length=1)
+ @model_validator(mode="after")
+ def _validate_schedule(self) -> "MonitorTaskCreate":
+ if any(hour < 0 or hour > 23 for hour in self.schedule_hours):
+ raise ValueError("小时必须在 0-23 之间")
+ if any(day < 0 or day > 6 for day in self.schedule_days):
+ raise ValueError("星期必须在 0-6 之间(周一为 0)")
+ # A clock mode with no chosen time can never fire. Rejecting it here is
+ # what keeps next_occurrence()'s None branch unreachable in practice.
+ if self.schedule_mode in ("daily", "weekly") and not self.schedule_hours:
+ raise ValueError("按钟点调度至少要选一个时间")
+ if self.schedule_mode == "weekly" and not self.schedule_days:
+ raise ValueError("按周调度至少要选一个星期")
+ return self
+
class MonitorTaskUpdate(BaseModel):
name: Optional[str] = Field(default=None, min_length=1, max_length=200)
@@ -60,6 +87,12 @@ class MonitorTaskUpdate(BaseModel):
interval_minutes: Optional[int] = Field(
default=None, ge=MIN_INTERVAL_MINUTES, le=MAX_INTERVAL_MINUTES
)
+ # None means "leave alone". Cross-field validity depends on the merged state,
+ # so it is checked in the service rather than here.
+ schedule_mode: Optional[Literal["interval", "daily", "weekly"]] = None
+ schedule_hours: Optional[List[int]] = None
+ schedule_days: Optional[List[int]] = None
+ schedule_minute: Optional[int] = Field(default=None, ge=0, le=59)
max_notes_count: Optional[int] = Field(default=None, ge=1, le=500)
enable_comments: Optional[bool] = None
max_comments_count: Optional[int] = Field(default=None, ge=1, le=500)
diff --git a/tests/test_schedule.py b/tests/test_schedule.py
new file mode 100644
index 0000000..d862659
--- /dev/null
+++ b/tests/test_schedule.py
@@ -0,0 +1,194 @@
+# -*- coding: utf-8 -*-
+"""Tests for monitor task schedule arithmetic.
+
+Everything here is timezone-local, matching the implementation: the container is
+pinned to the operator's zone via TZ, so the tests build their expectations from
+naive local datetimes too and stay correct wherever they run.
+"""
+
+from datetime import datetime, time, timedelta
+
+import pytest
+
+from api.monitor import schedule
+
+
+def _ms(moment: datetime) -> int:
+ return int(moment.timestamp() * 1000)
+
+
+def test_interval_is_now_plus_the_interval():
+ after = _ms(datetime(2026, 10, 7, 9, 0))
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_INTERVAL,
+ interval_minutes=120,
+ hours=[],
+ days=[],
+ minute=0,
+ after_ms=after,
+ )
+
+ assert nxt == after + 120 * 60_000
+
+
+def test_daily_takes_the_soonest_remaining_time_today():
+ after = _ms(datetime(2026, 10, 7, 8, 0))
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_DAILY,
+ interval_minutes=60,
+ hours=[18, 9], # deliberately unsorted
+ days=[],
+ minute=30,
+ after_ms=after,
+ )
+
+ assert nxt == _ms(datetime(2026, 10, 7, 9, 30))
+
+
+def test_daily_rolls_over_to_tomorrow_once_every_time_has_passed():
+ after = _ms(datetime(2026, 10, 7, 20, 0))
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_DAILY,
+ interval_minutes=60,
+ hours=[9, 18],
+ days=[],
+ minute=30,
+ after_ms=after,
+ )
+
+ assert nxt == _ms(datetime(2026, 10, 8, 9, 30))
+
+
+def test_a_slot_exactly_now_belongs_to_the_next_day():
+ """Strictly-after, so the run that just fired does not fire again.
+
+ The scheduler advances with ``after_ms`` set to the moment the run started,
+ which is at or just past the slot -- if the comparison were inclusive it would
+ pick the same slot back up and loop.
+ """
+ after = _ms(datetime(2026, 10, 7, 9, 30))
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_DAILY,
+ interval_minutes=60,
+ hours=[9],
+ days=[],
+ minute=30,
+ after_ms=after,
+ )
+
+ assert nxt == _ms(datetime(2026, 10, 8, 9, 30))
+
+
+def test_weekly_jumps_to_the_next_selected_weekday():
+ after_dt = datetime(2026, 10, 7, 8, 0)
+ target = (after_dt.weekday() + 2) % 7
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_WEEKLY,
+ interval_minutes=60,
+ hours=[10],
+ days=[target],
+ minute=0,
+ after_ms=_ms(after_dt),
+ )
+
+ expected = datetime.combine((after_dt + timedelta(days=2)).date(), time(10, 0))
+ assert nxt == _ms(expected)
+
+
+def test_weekly_can_fire_later_the_same_day():
+ after_dt = datetime(2026, 10, 7, 8, 0)
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_WEEKLY,
+ interval_minutes=60,
+ hours=[21],
+ days=[after_dt.weekday()],
+ minute=15,
+ after_ms=_ms(after_dt),
+ )
+
+ assert nxt == _ms(datetime.combine(after_dt.date(), time(21, 15)))
+
+
+def test_weekly_without_weekdays_means_every_day():
+ """Otherwise an empty day selection would match nothing and never fire."""
+ after = _ms(datetime(2026, 10, 7, 8, 0))
+
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_WEEKLY,
+ interval_minutes=60,
+ hours=[9],
+ days=[],
+ minute=0,
+ after_ms=after,
+ )
+
+ assert nxt == _ms(datetime(2026, 10, 7, 9, 0))
+
+
+def test_a_clock_schedule_with_no_times_can_never_fire():
+ """Returned as None so the caller can park the task instead of leaving it due."""
+ nxt = schedule.next_occurrence(
+ mode=schedule.MODE_DAILY,
+ interval_minutes=60,
+ hours=[],
+ days=[],
+ minute=0,
+ after_ms=_ms(datetime(2026, 10, 7, 8, 0)),
+ )
+
+ assert nxt is None
+
+
+@pytest.mark.parametrize(
+ "raw, expected",
+ [
+ ("9,18", [9, 18]),
+ ("18,9", [9, 18]), # stored order is not guaranteed
+ ("9,9,9", [9]),
+ ("", []),
+ (None, []),
+ ("9, 18 ", [9, 18]),
+ ("9,99,-1,abc,", [9]), # junk is dropped, never raised
+ ],
+)
+def test_parse_hours_is_forgiving(raw, expected):
+ assert schedule.parse_hours(raw) == expected
+
+
+def test_parse_days_accepts_the_whole_week():
+ assert schedule.parse_days("0,1,2,3,4,5,6") == [0, 1, 2, 3, 4, 5, 6]
+ assert schedule.parse_days("7,-1") == []
+
+
+@pytest.mark.parametrize(
+ "mode, interval, hours, days, minute, expected",
+ [
+ (schedule.MODE_INTERVAL, 360, [], [], 0, "每 6 小时"),
+ (schedule.MODE_INTERVAL, 1440, [], [], 0, "每 1 天"),
+ (schedule.MODE_INTERVAL, 45, [], [], 0, "每 45 分钟"),
+ (schedule.MODE_DAILY, 60, [9, 18], [], 30, "每天 09:30、18:30"),
+ (schedule.MODE_WEEKLY, 60, [10], [0, 1, 2, 3, 4], 0, "周一、周二、周三、周四、周五 10:00"),
+ (schedule.MODE_WEEKLY, 60, [10], [], 0, "每天 10:00"),
+ (schedule.MODE_DAILY, 60, [], [], 0, "未设置时间"),
+ ],
+)
+def test_describe(mode, interval, hours, days, minute, expected):
+ assert (
+ schedule.describe(
+ mode=mode, interval_minutes=interval, hours=hours, days=days, minute=minute
+ )
+ == expected
+ )
+
+
+def test_format_round_trips_through_parse():
+ hours = [9, 12, 18]
+ days = [0, 4]
+ assert schedule.parse_hours(schedule.format_hours(hours)) == hours
+ assert schedule.parse_days(schedule.format_days(days)) == days
diff --git a/webui/src/components/monitor/TaskCard.tsx b/webui/src/components/monitor/TaskCard.tsx
index f694f14..e370813 100644
--- a/webui/src/components/monitor/TaskCard.tsx
+++ b/webui/src/components/monitor/TaskCard.tsx
@@ -3,7 +3,7 @@ import { Bell, CalendarClock, Pencil, Play, Trash2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { useDeleteTask, useRunTaskNow, useUpdateTask } from '@/hooks/useMonitor'
-import { formatInterval, formatRelative } from '@/lib/monitorFormat'
+import { formatRelative } from '@/lib/monitorFormat'
import type { MonitorTask } from '@/types/monitor'
interface TaskCardProps {
@@ -82,7 +82,8 @@ export function TaskCard({ task, selected, onSelect, onEdit }: TaskCardProps) {
目标 {task.target_count} 个
- 间隔 {formatInterval(task.interval_minutes)}
+ {/* 由后端拼好,列表和编辑弹窗因此不会对同一个计划给出两种说法 */}
+ {task.schedule_label}
{task.enabled ? formatRelative(task.next_run_at) : '已暂停'}
diff --git a/webui/src/components/monitor/TaskEditorDialog.tsx b/webui/src/components/monitor/TaskEditorDialog.tsx
index e28a92c..388360f 100644
--- a/webui/src/components/monitor/TaskEditorDialog.tsx
+++ b/webui/src/components/monitor/TaskEditorDialog.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react'
+import { useEffect, useState, type ReactNode } from 'react'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
@@ -20,7 +20,13 @@ import {
SelectValue,
} from '@/components/ui/select'
import { useCreateTask, useSettings, useUpdateTask } from '@/hooks/useMonitor'
-import type { MonitorMode, MonitorTask, TaskCreatePayload } from '@/types/monitor'
+import { describeSchedule } from '@/lib/monitorFormat'
+import type {
+ MonitorMode,
+ MonitorTask,
+ ScheduleMode,
+ TaskCreatePayload,
+} from '@/types/monitor'
interface TaskEditorDialogProps {
open: boolean
@@ -44,6 +50,48 @@ const INTERVAL_OPTIONS = [
{ value: '10080', label: '7 天' },
]
+const SCHEDULE_MODE_OPTIONS: Array<{ value: ScheduleMode; label: string }> = [
+ { value: 'interval', label: '固定间隔' },
+ { value: 'daily', label: '每天定时' },
+ { value: 'weekly', label: '每周定时' },
+]
+
+const HOURS = Array.from({ length: 24 }, (_, hour) => hour)
+const MINUTES = Array.from({ length: 60 }, (_, minute) => minute)
+const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日']
+const pad = (value: number) => String(value).padStart(2, '0')
+
+function toggleNumber(values: number[], value: number): number[] {
+ return values.includes(value)
+ ? values.filter((item) => item !== value)
+ : [...values, value].sort((a, b) => a - b)
+}
+
+/** A selectable pill. Used for the hours of the day and the weekdays. */
+function Chip({
+ active,
+ onClick,
+ children,
+}: {
+ active: boolean
+ onClick: () => void
+ children: ReactNode
+}) {
+ return (
+
+ )
+}
+
export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogProps) {
const isEdit = Boolean(task)
const createTask = useCreateTask()
@@ -53,6 +101,10 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
const [name, setName] = useState('')
const [mode, setMode] = useState('creator')
const [intervalMinutes, setIntervalMinutes] = useState('360')
+ const [scheduleMode, setScheduleMode] = useState('interval')
+ const [scheduleHours, setScheduleHours] = useState([])
+ const [scheduleDays, setScheduleDays] = useState([])
+ const [scheduleMinute, setScheduleMinute] = useState('0')
const [maxNotes, setMaxNotes] = useState('20')
const [enableComments, setEnableComments] = useState(true)
const [maxComments, setMaxComments] = useState('50')
@@ -69,6 +121,10 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
setIntervalMinutes(
String(task?.interval_minutes ?? settings?.values['collect.default_interval_minutes'] ?? 360),
)
+ setScheduleMode(task?.schedule_mode ?? 'interval')
+ setScheduleHours(task?.schedule_hours ?? [])
+ setScheduleDays(task?.schedule_days ?? [])
+ setScheduleMinute(String(task?.schedule_minute ?? 0))
setMaxNotes(
String(task?.max_notes_count ?? settings?.values['collect.default_max_notes'] ?? 20),
)
@@ -85,17 +141,35 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
.map((value) => value.trim())
.filter(Boolean)
+ // Switching into a clock mode with nothing chosen would be invalid, so seed a
+ // sensible starting point -- the operator then edits rather than fills a blank.
+ const selectScheduleMode = (next: ScheduleMode) => {
+ setScheduleMode(next)
+ if (next !== 'interval' && scheduleHours.length === 0) setScheduleHours([9])
+ if (next === 'weekly' && scheduleDays.length === 0) setScheduleDays([0, 1, 2, 3, 4])
+ }
+
const pending = createTask.isPending || updateTask.isPending
// Note-mode and creator-mode targets are different shapes, so switching mode
// would silently mis-parse the list. The backend decides parsing from the
// task's stored mode, hence mode is fixed once created.
- const canSubmit = name.trim().length > 0 && targetList.length > 0 && !pending
+ const clockIncomplete =
+ scheduleMode !== 'interval' &&
+ (scheduleHours.length === 0 || (scheduleMode === 'weekly' && scheduleDays.length === 0))
+ const canSubmit =
+ name.trim().length > 0 && targetList.length > 0 && !pending && !clockIncomplete
const handleSubmit = () => {
const payload: TaskCreatePayload = {
name: name.trim(),
mode,
interval_minutes: Number(intervalMinutes),
+ schedule_mode: scheduleMode,
+ // Interval mode has no clock times, so send none rather than leave stale
+ // ones behind from a mode the operator tried and abandoned.
+ schedule_hours: scheduleMode === 'interval' ? [] : scheduleHours,
+ schedule_days: scheduleMode === 'weekly' ? scheduleDays : [],
+ schedule_minute: Number(scheduleMinute),
max_notes_count: Number(maxNotes),
enable_comments: enableComments,
max_comments_count: Number(maxComments),
@@ -159,6 +233,28 @@ export function TaskEditorDialog({ open, onOpenChange, task }: TaskEditorDialogP
)}
+