From 5f7788d16e7e2cf0bc6dc324f4dd5204470f02cf Mon Sep 17 00:00:00 2001 From: Grok Bot Date: Sat, 31 Oct 2026 11:12:27 +0100 Subject: [PATCH] TikTok: revert US-night overnight slots (02:07/03:37/06:37) - back to pre-Oct-29 cadence: 5/day Madrid 13:07 es, 16:07 en, 20:07 es, 00:07 en, 05:07 en; cap 5/24h, 2h spacing, EN catch-up ends 06:00 (founder 2026-10-31: overnight posts went to 0 views) --- src/antisearch_mill/publisher/guard.py | 21 ++++------------- src/antisearch_mill/publisher/jitter.py | 4 ---- tests/test_publisher_run.py | 16 ++++++------- tests/test_tiktok_es_slots.py | 31 ++++++++----------------- tests/test_tiktok_no_outro.py | 30 ++++++++++-------------- 5 files changed, 34 insertions(+), 68 deletions(-) diff --git a/src/antisearch_mill/publisher/guard.py b/src/antisearch_mill/publisher/guard.py index a20da19..1d043f4 100644 --- a/src/antisearch_mill/publisher/guard.py +++ b/src/antisearch_mill/publisher/guard.py @@ -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 @@ -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" @@ -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) @@ -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 diff --git a/src/antisearch_mill/publisher/jitter.py b/src/antisearch_mill/publisher/jitter.py index 7f5a446..9fbd074 100644 --- a/src/antisearch_mill/publisher/jitter.py +++ b/src/antisearch_mill/publisher/jitter.py @@ -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], diff --git a/tests/test_publisher_run.py b/tests/test_publisher_run.py index bee57cf..d957f96 100644 --- a/tests/test_publisher_run.py +++ b/tests/test_publisher_run.py @@ -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(): @@ -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(): @@ -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 diff --git a/tests/test_tiktok_es_slots.py b/tests/test_tiktok_es_slots.py index 899830c..eafeae1 100644 --- a/tests/test_tiktok_es_slots.py +++ b/tests/test_tiktok_es_slots.py @@ -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 @@ -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): @@ -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) @@ -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) diff --git a/tests/test_tiktok_no_outro.py b/tests/test_tiktok_no_outro.py index db186f2..9f4fa22 100644 --- a/tests/test_tiktok_no_outro.py +++ b/tests/test_tiktok_no_outro.py @@ -68,13 +68,13 @@ 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 @@ -82,13 +82,7 @@ def test_tiktok_schedule_is_eight_a_day_90_min_apart(): 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): @@ -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