#!/usr/bin/env python3.14# -*- coding: utf-8 -*-"""Capture the site's main pages for the README, as one animated GIF that cycles through them.Captures, signed out, the five main pages (`/`, `/solutions`, `/docs`, `/blogs`, `/shell`)and one random problem page below 100, with headless Chrome, and joins them into`docs/screenshots.gif`: a frame per page, looping. A GIF plays wherever an image shows (GitHubruns no script in a README) and the site's flat colours and text lose nothing to its 256colours: each frame gets its own palette, undithered, so text stays crisp. The README shows itbetween its `SCREENSHOTS` markers, which this script rewrites. python scripts/docs/screenshots.py # a random problem below 100 python scripts/docs/screenshots.py --problem 2 # that one python scripts/docs/screenshots.py --base-url https://euler.example.comRun it from the repository root, then commit `docs/screenshots.gif` and `README.md`."""from__future__importannotationsimportargparseimportrandomimportreimportshutilimportsubprocessimportsysimporttempfilefromdataclassesimportdataclassfrompathlibimportPathfromPILimportImage,ImageStatROOT:Path=Path(__file__).resolve().parents[2]GIF:Path=ROOT/'docs'/'screenshots.gif'README:Path=ROOT/'README.md'BASE_URL:str='https://euler.vikasmunshi.com'#: The browser window, in CSS pixels, captured at 1×: wide enough for the two-pane layout,#: and text that stays sharp at the size a README shows it.WINDOW:tuple[int,int]=(1600,980)#: How long each page stays up, in milliseconds.FRAME_MS:int=3000#: Virtual time Chrome gives a page to settle (fonts, htmx swaps, MathJax) before the shot.SETTLE_MS:int=8000#: The site is dark: a capture brighter than this (mean luminance, 0–255) is not the page.#: Headless Chrome now and then paints a stray resource instead, the logo on white.MAX_BRIGHTNESS:float=90ATTEMPTS:int=3_START='<!-- SCREENSHOTS:START'_END='<!-- SCREENSHOTS:END -->'_BLOCK_RE=re.compile(rf'{re.escape(_START)}.*?{re.escape(_END)}',re.DOTALL)@dataclass(frozen=True)classShot:"""One page to capture: its path on the site, and what to call it."""path:strtitle:strdefshots(problem:int)->list[Shot]:"""The pages, in the order the animation shows them."""return[Shot('/','Home'),Shot('/solutions/','Solutions'),Shot('/docs/','Docs'),Shot('/blogs/','Blogs'),Shot('/shell','Terminal'),Shot(f'/solutions/{problem}/',f'Problem {problem}'),]defchrome()->str:"""The headless browser to drive: Chrome, or Chromium."""fornamein('google-chrome','chromium','chromium-browser'):ifpath:=shutil.which(name):returnpathraiseSystemExit('screenshots: no google-chrome or chromium on PATH (make install-chrome)')defcapture(browser:str,url:str,target:Path)->None:"""Screenshot *url* to *target*, signed out (a fresh profile has no session cookie). Retried when the result is missing or too bright to be the site (:data:`MAX_BRIGHTNESS`). """for_inrange(ATTEMPTS):target.unlink(missing_ok=True)withtempfile.TemporaryDirectory()asprofile:subprocess.run([browser,'--headless=new','--disable-gpu','--hide-scrollbars',f'--user-data-dir={profile}',f'--window-size={WINDOW[0]},{WINDOW[1]}','--force-device-scale-factor=1',f'--virtual-time-budget={SETTLE_MS}',f'--screenshot={target}',url],check=True,capture_output=True,timeout=120)iftarget.is_file():withImage.open(target)asimage:ifImageStat.Stat(image.convert('L')).mean[0]<=MAX_BRIGHTNESS:returnprint(f' retrying {url}: the capture is not the page')raiseSystemExit(f'screenshots: no good capture of {url} in {ATTEMPTS} attempts')defanimate(frames:list[Path])->None:"""Join *frames* into :data:`GIF`, looping, each with its own undithered palette."""images=[Image.open(f).convert('RGB').quantize(colors=256,method=Image.Quantize.MEDIANCUT,dither=Image.Dither.NONE)forfinframes]images[0].save(GIF,save_all=True,append_images=images[1:],duration=FRAME_MS,loop=0,optimize=True,disposal=1)defwrite_readme(all_shots:list[Shot])->None:"""Show the animation in the README, between its SCREENSHOTS markers."""text=README.read_text(encoding='utf-8')ifnot_BLOCK_RE.search(text):raiseSystemExit(f'screenshots: README.md has no {_START} … {_END} block')order=' → '.join(f'<code>{s.path}</code>'forsinall_shots)block=(f'{_START}: written by scripts/docs/screenshots.py; edit the script, not this. -->\n'f'<p align="center"><img src="docs/screenshots.gif" width="100%" 'f'alt="The web front end, signed out: {", ".join(s.titleforsinall_shots)}"></p>\n'f'<p align="center">The web front end, signed out: {order}, 'f'{FRAME_MS//1000} seconds each.</p>\n'f'{_END}')README.write_text(_BLOCK_RE.sub(lambda_:block,text,count=1),encoding='utf-8')defmain(argv:list[str]|None=None)->int:"""Capture every page, animate them, and write the README block."""parser=argparse.ArgumentParser(description=__doc__.splitlines()[0]if__doc__elseNone)parser.add_argument('--problem',type=int,default=None,help='the problem page to capture (default: a random one below 100)')parser.add_argument('--base-url',default=BASE_URL,help=f'the site to capture (default: {BASE_URL})')args=parser.parse_args(argv)problem=args.problemifargs.problemisnotNoneelserandom.randint(1,99)ifnot1<=problem<=99:parser.error('--problem must be below 100: only those pages are public')base_url=args.base_url.rstrip('/')browser=chrome()all_shots=shots(problem)withtempfile.TemporaryDirectory()asscratch:frames=[]fori,shotinenumerate(all_shots):frame=Path(scratch)/f'{i}.png'print(f'capturing {base_url}{shot.path}')capture(browser,f'{base_url}{shot.path}',frame)frames.append(frame)animate(frames)write_readme(all_shots)size=GIF.stat().st_size/1e6print(f'wrote {GIF.relative_to(ROOT)} ({len(all_shots)} frames, {size:.1f} MB) and the README block')return0if__name__=='__main__':sys.exit(main())