#!/usr/bin/env python3 """Weekly writing quota and missed-window recovery; never publishes by itself.""" import argparse from datetime import datetime, timedelta import fcntl import json from pathlib import Path import secrets import sys from zoneinfo import ZoneInfo from release import atomic_json ZONE = ZoneInfo('Asia/Shanghai') STATE = Path(__file__).resolve().parents[2] / 'storage/app/private/weekly-tutorials/state.json' def week_start(now): return (now - timedelta(days=now.weekday())).date().isoformat() def new_week(key): monday = datetime.fromisoformat(key).replace(tzinfo=ZONE) while True: times = [monday + timedelta(days=secrets.choice(days), hours=15) for days in ([0, 1], [2, 3], [4, 5, 6])] if all(b - a >= timedelta(hours=24) for a, b in zip(times, times[1:])): return {'slots': [{'slot_id': f'{key}-{i + 1}', 'planned_at': t.isoformat(), 'status': 'planned', 'release_id': None, 'published_at': None, 'urls': []} for i, t in enumerate(times)]} def published_slots(state): return [s for w in state['weeks'].values() for s in w['slots'] if s.get('published_at')] def due(state, now): if now.date().isoformat() < state['starts_on']: return {'action': 'wait', 'reason': 'not_started'} key = week_start(now) state['weeks'].setdefault(key, new_week(key)) published = published_slots(state) for slot in published: if slot['status'] == 'published_pending': return {'action': 'synchronize', 'slot': slot} if any(datetime.fromisoformat(s['published_at']).astimezone(ZONE).date() == now.date() for s in published): return {'action': 'wait', 'reason': 'already_published_today'} # Uncertain old work must be reconciled before creating a new release or applying the quota. for week in sorted(state['weeks']): for slot in state['weeks'][week]['slots']: if slot.get('release_id') and not slot.get('published_at') and slot['status'] != 'abandoned': return {'action': 'resume', 'slot': slot} count = sum(week_start(datetime.fromisoformat(s['published_at']).astimezone(ZONE)) == key for s in published) if count >= 3: return {'action': 'wait', 'reason': 'weekly_quota_met'} for slot in state['weeks'][key]['slots']: if slot['status'] == 'planned' and datetime.fromisoformat(slot['planned_at']) <= now: return {'action': 'write', 'slot': slot} return {'action': 'wait', 'reason': 'not_due'} def record(state, slot_id, response, now): if response.get('ok') is not True: raise ValueError('No successful server response') data = response['data'] receipt = data.get('receipt', {}) if receipt.get('status') != 'published' or data.get('phase') not in ('complete', 'published_pending'): raise ValueError('No committed publication receipt') slot = next(s for w in state['weeks'].values() for s in w['slots'] if s['slot_id'] == slot_id) if slot.get('release_id') != data['release_id']: raise ValueError('Release does not match claimed slot') # Record actual server transaction timestamp, not the time a delayed response was received. published_at = data.get('published_at') if not published_at: raise ValueError('Missing server publication timestamp') instant = datetime.fromisoformat(published_at).astimezone(ZONE) if instant > now + timedelta(minutes=5): raise ValueError('Publication timestamp is in the future') slot.update(status='published' if data['phase'] == 'complete' else 'published_pending', published_at=instant.isoformat(), publication_id=receipt['publication_id'], urls=[a['url'] for a in receipt['articles']], receipt_phase=data['phase']) def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('operation', choices=['due', 'claim', 'record']) parser.add_argument('--slot') parser.add_argument('--release-id') parser.add_argument('--receipt', type=Path) args = parser.parse_args() now = datetime.now(ZONE) STATE.parent.mkdir(parents=True, exist_ok=True) with open(STATE.with_suffix('.lock'), 'a') as lock: fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) # Missing/corrupt state must be reconciled with server receipts, not reset to an empty quota. state = json.loads(STATE.read_text()) result = due(state, now) if args.operation == 'claim': import uuid release_id = str(uuid.UUID(args.release_id)) if result['action'] != 'write' or result['slot']['slot_id'] != args.slot: raise ValueError('Requested slot is not due') result['slot'].update(status='drafting', release_id=release_id) elif args.operation == 'record': record(state, args.slot, json.loads(args.receipt.read_text()), now) result = {'action': 'recorded', 'slot_id': args.slot} atomic_json(STATE, state) print(json.dumps(result, ensure_ascii=False)) if __name__ == '__main__': try: main() except Exception as error: print(json.dumps({'action': 'blocked', 'reason': str(error)})) sys.exit(1)