Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 5 additions & 16 deletions src/antisearch_mill/publisher/guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,9 @@
# 2026-10-19 (founder): 5 Madrid slots (2 es, 3 en); 05:07 en is the US trend slot. At most 5 in
# any 24 hours; no catch-up after a slot's own window (next slot / grace), and English slots
# never catch up after 06:00 Madrid.
# 2026-10-29 (founder, final): +3 English slots in the Madrid night (02:07, 03:37, 06:37) aimed at
# the US Central evening = 8 slots a day (6 en + 2 es). Cap 9 per day / 24h; spacing lowered to the
# 90 min the new slots need (02:07 -> 03:37 -> 05:07 -> 06:37 are exactly 90 min apart).
TT_MAX_PER_DAY = 9
TIKTOK_MAX_PER_24H = 9
TIKTOK_MIN_SPACING = timedelta(minutes=90)
TT_MAX_PER_DAY = 5
TIKTOK_MAX_PER_24H = 5
TIKTOK_MIN_SPACING = timedelta(hours=2)
DAILY_CAP = {"x": X_MAX_PER_DAY, "instagram": IG_MAX_PER_DAY, "tiktok": TT_MAX_PER_DAY}
SLOT_GRACE = timedelta(hours=3) # minimum a missed slot stays open; longer if the next slot is further away
# GitHub's schedule drops most wakes (28 Sep: 1 scheduled run in 8 h). Once a slot's window has passed with
Expand Down Expand Up @@ -101,8 +98,7 @@
DEFAULT_CADENCE = "carlos"

# TikTok on the reach cadence (founder, 2026-10-09): European hours, pinned to Madrid wall time
# (DST-safe: the ET time is recomputed per date), 90+ min apart, nothing 07:00-12:59 Madrid
# (06:37 en is the last US-night slot since 2026-10-29).
# (DST-safe: the ET time is recomputed per date), 2+ hours apart, nothing 06:00-12:59 Madrid.
# Two Spanish (20:07 is the ES trend slot), 16:07 / 00:07 / 05:07 English for the US (05:07 is the US
# trend slot). Spanish never goes out outside TIKTOK_ES_MADRID_HOURS, whatever the slot list says.
MADRID = "Europe/Madrid"
Expand All @@ -111,14 +107,9 @@
(time(16, 7), "en"),
(time(20, 7), "es"),
(time(0, 7), "en"),
(time(2, 7), "en"), # US Central evening (2026-10-29): 20:07 CDT / 19:07 CST
(time(3, 37), "en"), # 21:37 CDT / 20:37 CST
(time(5, 7), "en"), # 23:07 New York (2026-10-12); US trend slot since 2026-10-19
(time(6, 37), "en"), # 00:37 CDT / 23:37 CST
)
# Never catch up an English TikTok slot after this Madrid time (06:00 until 2026-10-29; 08:30 so the
# jittered 06:37 slot can still post, and nothing goes out in the US small hours).
TIKTOK_EN_CATCHUP_END = time(8, 30)
TIKTOK_EN_CATCHUP_END = time(6, 0) # never catch up an English TikTok slot after this Madrid time
TIKTOK_ES_MADRID_HOURS = range(13, 24) # 13:00-23:59 Madrid

RANKED = re.compile(r"^(the\s+)?(best|top|greatest|#1)\b", re.I)
Expand Down Expand Up @@ -272,8 +263,6 @@ def madrid_slots_et(day: date) -> list[time]:
for slot, _lang in TIKTOK_MADRID_SLOTS:
for d in (day, day + timedelta(days=1)):
local = datetime.combine(d, slot, tzinfo=ZoneInfo(MADRID)).astimezone(tz())
if local.astimezone(ZoneInfo(MADRID)).time().replace(tzinfo=None) != slot:
continue # 02:07 does not exist on the EU spring-forward night: that slot is skipped once a year
if posting_day(local) == day:
out.append(local.time().replace(tzinfo=None))
break
Expand Down
4 changes: 0 additions & 4 deletions src/antisearch_mill/publisher/jitter.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,6 @@ def _draws(day: date, network: str, slot: time) -> Iterator[int]:
for _ in range(400):
yield rng.randint(0, MAX_OFFSET)
yield from range(MAX_OFFSET + 1)
# TikTok's US-night slots sit exactly TIKTOK_MIN_SPACING apart (2026-10-29): when the previous
# slot drew a late minute and the only fitting one is taken, go a little past MAX_OFFSET rather
# than fall back to 0 (which the spacing guard would then skip).
yield from range(MAX_OFFSET + 1, MAX_OFFSET + 21)


def _pick(day: date, network: str, slot: time, nominal: datetime, taken: set[datetime],
Expand Down
16 changes: 7 additions & 9 deletions tests/test_publisher_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,12 @@ def test_reach_cadence_is_seven_ig_six_tiktok_and_leaves_the_others():
# The Spanish 14:07 Madrid slot (08:07 ET on this date) opens the posting day, then three ET
# slots (2026-10-07: 6+ TikTok posts in 24h got 0 views, so 4 a day, 3+ hours apart).
assert [f"{t:%H:%M}" for t in slots_for("tiktok", env, date(2026, 10, 5))] == [
"07:07", "10:07", "14:07", "18:07", "20:07", "21:37", "23:07", "00:37"]
# 13:07, 16:07, 20:07, 00:07, 05:07 Madrid (2026-10-19) + 02:07, 03:37, 06:37 for the US night (2026-10-29)
"07:07", "10:07", "14:07", "18:07", "23:07"] # 13:07, 16:07, 20:07, 00:07, 05:07 Madrid (2026-10-19)
for net in ("x", "linkedin", "bluesky", "threads", "mastodon", "reddit"):
assert slots_for(net, env) == slots_for(net, soul)
# Instagram 7 under its cap of 8, TikTok 8 under its cap of 9 (2026-10-29).
# Instagram 7 under its cap of 8, TikTok 5 at its cap of 5 (2026-10-19).
assert len(slots_for("instagram", env)) == 7
assert len(slots_for("tiktok", env, date(2026, 10, 5))) == 8
assert len(slots_for("tiktok", env, date(2026, 10, 5))) == 5


def test_instagram_takes_up_to_eight_slots_and_every_other_network_stays_at_five():
Expand All @@ -194,8 +193,8 @@ def test_instagram_takes_up_to_eight_slots_and_every_other_network_stays_at_five
with pytest.raises(ValueError, match="cap is 8"):
slots_for("instagram", {"PUBLISHER_SLOTS_INSTAGRAM": eight + ",02:07"})
assert len(slots_for("tiktok", {"PUBLISHER_SLOTS_TIKTOK": "10:07,13:07,16:07,19:07,21:07"})) == 5
with pytest.raises(ValueError, match="cap is 9"):
slots_for("tiktok", {"PUBLISHER_SLOTS_TIKTOK": "06:07,08:07,10:07,12:07,14:07,16:07,18:07,20:07,22:07,00:07"})
with pytest.raises(ValueError, match="cap is 5"):
slots_for("tiktok", {"PUBLISHER_SLOTS_TIKTOK": "08:07,10:07,13:07,16:07,19:07,21:07"})


def test_reach_slot_stays_open_until_the_next_one_or_three_hours():
Expand All @@ -209,9 +208,8 @@ def test_reach_slot_stays_open_until_the_next_one_or_three_hours():
assert open_now(datetime(2026, 9, 26, 19, 10, tzinfo=ET), ig, 0) == 1
tt = slots_for("tiktok", {"PUBLISHER_CADENCE": "reach"}, date(2026, 9, 26))
# Last slot of the day gets 3h, not the whole night until the next posting day.
# 20:30 ET is 02:30 Madrid: 18:07 ET (00:07 Madrid) is still inside 3h and 20:07 (02:07 Madrid) is open.
assert open_now(datetime(2026, 9, 26, 20, 30, tzinfo=ET), tt, 3) == 2
assert open_now(datetime(2026, 9, 27, 5, 30, tzinfo=ET), tt, 0, network="tiktok") == 0 # no TikTok catch-up
assert open_now(datetime(2026, 9, 26, 20, 30, tzinfo=ET), tt, 3) == 1 # 18:07 still inside 3h
assert open_now(datetime(2026, 9, 27, 5, 30, tzinfo=ET), tt, 0) == 0
nxt = slots_for("tiktok", {"PUBLISHER_CADENCE": "reach"}, date(2026, 9, 27))
assert open_now(datetime(2026, 9, 27, 7, 30, tzinfo=ET), nxt, 0) == 1 # the 13:07 Madrid slot

Expand Down
31 changes: 10 additions & 21 deletions tests/test_tiktok_es_slots.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Spanish TikTok by slot. Since 2026-10-09 (founder) TikTok reach slots are pinned to Madrid wall
time. Since 2026-10-19: 13:07 and 20:07 Spanish, 16:07 / 00:07 / 05:07 English (05:07 is the US
trend slot; 03:07 removed): 5 a day. Since 2026-10-29 also 02:07, 03:37, 06:37 English (US night): 8 a day, 90+ min apart. Spanish never goes out outside 13:00-23:59 Madrid."""
trend slot; 03:07 removed): 5 a day, 2+ hours apart. Spanish never goes out outside 13:00-23:59 Madrid."""

from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
Expand All @@ -15,18 +15,7 @@
MADRID = ZoneInfo("Europe/Madrid")
REACH = {"PUBLISHER_CADENCE": "reach"}
ENV = {**LIVE_ENV, **REACH, "PUBLISHER_LIVE_NETWORKS": "tiktok"}
SCHEDULE = [("13:07", "es"), ("16:07", "en"), ("20:07", "es"), ("00:07", "en"), ("02:07", "en"), ("03:37", "en"), ("05:07", "en"), ("06:37", "en")] # +02:07/03:37/06:37 en (US night, 2026-10-29)


def _spring_forward(day: date) -> bool:
"""The posting day whose night is the EU spring-forward: 02:07 Madrid does not exist then."""
from datetime import time as _t
nxt = day + timedelta(days=1)
return datetime.combine(nxt, _t(2, 7), tzinfo=MADRID).astimezone(ZoneInfo("UTC")).astimezone(MADRID).hour != 2


def _expected(day: date):
return [s for s in SCHEDULE if not (_spring_forward(day) and s[0] == "02:07")]
SCHEDULE = [("13:07", "es"), ("16:07", "en"), ("20:07", "es"), ("00:07", "en"), ("05:07", "en")]


def _plan(day: date, env=REACH):
Expand All @@ -39,21 +28,21 @@ def test_daily_schedule_in_madrid():
assert _plan(date(2027, 1, 12)) == SCHEDULE # winter
assert _plan(date(2026, 10, 27)) == SCHEDULE # EU on winter time, US not yet
assert _plan(date(2027, 3, 20)) == SCHEDULE # US on summer time, EU not yet
assert [f"{t:%H:%M}" for t in madrid_slots_et(date(2026, 10, 9))] == ["07:07", "10:07", "14:07", "18:07", "20:07", "21:37", "23:07", "00:37"]
assert [f"{t:%H:%M}" for t in madrid_slots_et(date(2026, 10, 27))] == ["08:07", "11:07", "15:07", "19:07", "21:07", "22:37", "00:07", "01:37"]
assert [f"{t:%H:%M}" for t in madrid_slots_et(date(2026, 10, 9))] == ["07:07", "10:07", "14:07", "18:07", "23:07"]
assert [f"{t:%H:%M}" for t in madrid_slots_et(date(2026, 10, 27))] == ["08:07", "11:07", "15:07", "19:07", "00:07"]


def test_every_day_of_two_years_keeps_the_madrid_schedule():
d = date(2026, 1, 1)
while d < date(2028, 1, 1):
times = slot_times(d, slots_for("tiktok", REACH, d))
assert _plan(d) == _expected(d), d
assert all(b - a >= timedelta(minutes=90) for a, b in zip(times, times[1:])), d
assert _plan(d) == SCHEDULE, d
assert all(b - a >= timedelta(hours=2) for a, b in zip(times, times[1:])), d
assert all(posting_day(t) == d for t in times), d
for t in times:
if slot_language("tiktok", t, REACH) == "es":
assert 13 <= t.astimezone(MADRID).hour <= 23, (d, t)
assert not 7 <= t.astimezone(MADRID).hour < 13, (d, t) # nothing in the Madrid morning (06:37 is the last US-night slot)
assert not 6 <= t.astimezone(MADRID).hour < 13, (d, t) # nothing in the Madrid morning
d += timedelta(days=1)


Expand Down Expand Up @@ -107,15 +96,15 @@ def test_kill_switch_posts_english_on_spanish_slots(ledger, log): # noqa: F811
assert [o.job_id for o in out if o.status == "published"] == ["p-en-thing"]


def test_jitter_keeps_tiktok_slots_90_min_apart_so_none_is_skipped():
def test_jitter_keeps_tiktok_slots_two_hours_apart_so_none_is_skipped():
from antisearch_mill.publisher.jitter import slot_offsets
d = date(2026, 1, 1)
while d < date(2027, 1, 1):
slots = slots_for("tiktok", REACH, d)
offs = slot_offsets("tiktok", d, slots, REACH)
at = [n + o for n, o in zip(slot_times(d, slots), offs)]
assert all(b - a >= timedelta(minutes=90) for a, b in zip(at, at[1:])), d
assert all(timedelta(0) <= o <= timedelta(minutes=60) for o in offs), d # 40, rarely up to 60 (jitter.py)
assert all(b - a >= timedelta(hours=2) for a, b in zip(at, at[1:])), d
assert all(timedelta(0) <= o <= timedelta(minutes=40) for o in offs), d
d += timedelta(days=1)


Expand Down
30 changes: 12 additions & 18 deletions tests/test_tiktok_no_outro.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,27 +68,21 @@ def test_only_instagram_calc_clips_have_the_end_screen(tmp_path, monkeypatch, ne
assert row.duration_s < 7.0 # back to the blink-length clip (calc + outro was ~7.7 s)


def test_tiktok_schedule_is_eight_a_day_90_min_apart():
assert TT_MAX_PER_DAY == 9 and TIKTOK_MAX_PER_24H == 9 # 2026-10-29
def test_tiktok_schedule_is_five_a_day_two_hours_apart():
assert TT_MAX_PER_DAY == 5 and TIKTOK_MAX_PER_24H == 5
d = date(2026, 1, 1)
while d < date(2028, 1, 1):
times = slot_times(d, slots_for("tiktok", REACH, d))
assert len(times) == (7 if _spring_forward(d) else 8), d
assert all(b - a >= timedelta(minutes=90) for a, b in zip(times, times[1:])), d
assert len(times) == 5, d
assert all(b - a >= timedelta(hours=2) for a, b in zip(times, times[1:])), d
for t in times:
if slot_language("tiktok", t, REACH) == "es":
assert 13 <= t.astimezone(MADRID).hour <= 23
d += timedelta(days=1)
day = date(2026, 10, 9)
got = [(t.astimezone(MADRID).strftime("%H:%M"), slot_language("tiktok", t, REACH))
for t in slot_times(day, slots_for("tiktok", REACH, day))]
assert got == [("13:07", "es"), ("16:07", "en"), ("20:07", "es"), ("00:07", "en"), ("02:07", "en"), ("03:37", "en"), ("05:07", "en"), ("06:37", "en")] # 2026-10-29


def _spring_forward(day):
from datetime import time as _t
nxt = day + timedelta(days=1)
return datetime.combine(nxt, _t(2, 7), tzinfo=MADRID).astimezone(ZoneInfo("UTC")).astimezone(MADRID).hour != 2
assert got == [("13:07", "es"), ("16:07", "en"), ("20:07", "es"), ("00:07", "en"), ("05:07", "en")] # 2026-10-19


def _log(tmp_path, times):
Expand All @@ -99,14 +93,14 @@ def _log(tmp_path, times):
return log


def test_never_more_than_nine_in_24h_nor_closer_than_90_min(tmp_path):
def test_never_more_than_five_in_24h_nor_closer_than_120_min(tmp_path):
now = datetime(2026, 10, 8, 19, 10, tzinfo=ET)
past = [now - timedelta(hours=h) for h in (2, 4, 6, 8, 10, 12, 14, 16, 18, 20)]
assert "24 hours" in tiktok_crowded(_log(tmp_path / "a", past), now)
assert tiktok_crowded(_log(tmp_path / "b", past[:8]), now) is None
assert "24 hours" in tiktok_crowded(_log(tmp_path / "f", past[:9]), now)
assert "min between" in tiktok_crowded(_log(tmp_path / "c", [now - timedelta(minutes=89)]), now)
assert tiktok_crowded(_log(tmp_path / "d", [now - timedelta(minutes=90)]), now) is None
four = [now - timedelta(hours=h) for h in (2.5, 5, 7, 11, 15, 20)]
assert "24 hours" in tiktok_crowded(_log(tmp_path / "a", four), now)
assert tiktok_crowded(_log(tmp_path / "b", four[:4]), now) is None
assert "24 hours" in tiktok_crowded(_log(tmp_path / "f", four[:5]), now)
assert "min between" in tiktok_crowded(_log(tmp_path / "c", [now - timedelta(hours=1, minutes=59)]), now)
assert tiktok_crowded(_log(tmp_path / "d", [now - timedelta(hours=2)]), now) is None
assert tiktok_crowded(_log(tmp_path / "e", [now - timedelta(hours=25)] * 1), now) is None


Expand Down
Loading