diff --git a/bin/freeze-score b/bin/freeze-score
new file mode 100755
index 0000000000000000000000000000000000000000..94c0a5c28e3a1c30a5118ff9d9042347ae526f5a
--- /dev/null
+++ b/bin/freeze-score
@@ -0,0 +1,106 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import locale
+from pathlib import Path
+from shutil import copyfile
+from sqlalchemy.orm import joinedload
+from typing import List
+import PyPDF2
+
+import mo.db as db
+import mo.jobs.score
+import mo.util
+from mo.util import die, init_standalone
+
+
+def freeze(contests: List[db.Contest]) -> None:
+    for ct in contests:
+        mo.jobs.score.schedule_snapshot_score(ct, for_user=mo.db.get_system_user(), snapshot_note='freeze-score')
+
+
+def download(contests: List[db.Contest], out_name: str) -> None:
+    out_dir = Path(out_name)
+    out_dir.mkdir(exist_ok=True)
+    pdfs = []
+    jsons = []
+
+    for ct in contests:
+        out = out_dir / ct.place.get_code()
+        print(f'{out} ({ct.place.name}): ', end="")
+        st = sess.query(db.ScoreTable).filter_by(contest_id=ct.contest_id).order_by(db.ScoreTable.created_at.desc()).limit(1).one_or_none()
+        if st is None:
+            print('NONE')
+        else:
+            if ct.scoretable_id == st.scoretable_id:
+                print('official')
+            else:
+                print('unofficial')
+
+            pdf = out.with_suffix('.pdf')
+            copyfile(Path(mo.util.data_dir('score')) / st.pdf_file, pdf)
+            pdfs.append(pdf)
+
+            js = mo.jobs.score.get_web_json(st, ct)
+            jsons.append(js)
+            with open(out.with_suffix('.json'), 'w') as f:
+                json.dump(js, f, ensure_ascii=False, indent=4, sort_keys=True)
+
+    all_json = out_dir / 'all.json'
+    print(f'Merging JSONs to {all_json}')
+    with open(all_json, 'w') as f:
+        json.dump(jsons, f, ensure_ascii=False, indent=4, sort_keys=True)
+
+    all_pdf = out_dir / 'all.pdf'
+    print(f'Merging PDFs to {all_pdf}')
+    writer = PyPDF2.PdfWriter()
+    for pdf in pdfs:
+        reader = PyPDF2.PdfReader(pdf)
+        for pg in reader.pages:
+            writer.add_page(pg)
+    with open(all_pdf, 'wb') as f:
+        writer.write(f)
+
+
+def make_official(contests: List[db.Contest]) -> None:
+    for ct in contests:
+        print(f'{ct.place.code} ({ct.place.name}): ', end="")
+        st = sess.query(db.ScoreTable).filter_by(contest_id=ct.contest_id).order_by(db.ScoreTable.created_at.desc()).limit(1).one_or_none()
+        if st is None:
+            print('NONE')
+        else:
+            print('OK')
+            ct.scoretable_id = st.scoretable_id
+    sess.commit()
+
+
+parser = argparse.ArgumentParser(description='Dávkové zpracování zmražených výsledkových listin')
+
+cmds = parser.add_mutually_exclusive_group(required=True)
+cmds.add_argument('--freeze', default=False, action='store_true', help='zmrazí všechny výsledkové listiny')
+cmds.add_argument('--download', metavar='DIR', help='stáhne poslední zmraženou verzi (PDF i JSON)')
+cmds.add_argument('--make-official', default=False, action='store_true', help='prohlásí poslední zmraženou verzi za oficiální')
+
+parser.add_argument('--round', type=str, required=True, metavar='YY-C-S[p]', help='kód kola')
+
+args = parser.parse_args()
+init_standalone()
+sess = db.get_session()
+
+round_code = mo.util.RoundCode.parse(args.round)
+if round_code is None:
+    die("Chybná syntaxe kódu kola")
+round = mo.util.get_round_by_code(round_code)
+if round is None:
+    die("Kolo s tímto kódem neexistuje!")
+
+contests = sess.query(db.Contest).filter_by(round=round).options(joinedload(db.Contest.place)).all()
+contests.sort(key=lambda ct: locale.strxfrm(ct.place.name))
+
+if args.freeze:
+    freeze(contests)
+if args.download:
+    download(contests, args.download)
+if args.make_official:
+    make_official(contests)