What it is
My school's five-day beginner FPV course ships its entire curriculum as a Google Site on the provider's own domain — Learn, Tinywhoop Go, Flight and Schedule, 20 pages, a full Monday-to-Friday timetable. The catch was the URL: a Workspace-published site's last segment is a name the author typed and nothing indexes it, so /fpv, /drone and 90 other guesses hit a sign-in wall. The real name was fpvdrones. This is that site mirrored as its own HTML — Google's markup and stylesheet, every image inlined, the nav rewired to local files. 41 MB, 20 pages, zero requests when it loads.
Version history
Straight from the project’s own card data — what each version added.
20 pages · 140 images · 41 MB · official markup, scripts stripped, nav localised
Files on the site
What broke, and what I’d change
Not written yet. This is the part only I can write — what actually went wrong, and what I’d do differently. It lives in builds/reflections/fpv-drones.md (see _TEMPLATE.md); as soon as that file has words in it, this section fills in on the next build.
Everything else on this page — the spec, the version history, the file counts — is generated from the project’s own build notes and files, so nothing here is guesswork.
Build spec
The project’s own build notes, rendered. Open the raw .md ↗
Show the spec
Build spec — FPV Drones for Beginners (Rotor Racing HK course site), official-HTML mirror
Goal
The five-day beginner FPV course material lives only as a Google Site published on a Workspace domain (sites.google.com/rotorracinghk.com/fpvdrones). Mirror the site's own HTML — not a remake — as a static folder that renders with no login, no Google session and no network.
Source
- Root:
https://sites.google.com/rotorracinghk.com/fpvdrones(public, no auth). - 20 pages; the navy bar exposes them all (
/learn/<page>,/tinywhoop-go/<page>,/flight/<page>,/schedule).
Pipeline
- Render, don't crawl.
curlreturns a JS shell. Load each URL in headless Chrome (CDP :9444, - Capture the hydrated DOM.
document.documentElement.outerHTML— this is the site as it looks, - Inline images from inside the page.
sitesv-images-rtURLs are session-signed:urllib→ 403, - Strip Google's scripts. Remove every
<script>and<link as="script">. Keeping Google's bundle makes the - Localise the nav. Google writes tabs as root-relative
/rotorracinghk.com/fpvdrones/…. Rewrite to - Shrink —
sips -Z 1400 -s format jpeg -s formatOptions 85for any data URL over 380 KB: - Layout —
demos/fpv-drones/index.html+ one folder per page (learn/antennas/index.html, …) so
1440×1000), scroll to the bottom in ~0.85-viewport steps to trigger lazy images, scroll back, wait.
including Google's inline stylesheet and computed layout.
in-page fetch(src,{credentials:'include'}) → 200. Convert each <img> to a data URL by rewriting src in the DOM before capture. Do the same for CSS background-image (the hero banner is a background, not an <img>): read getComputedStyle(el).backgroundImage and set an inline background-image:url(data:…) on the element.
snapshot try to re-hydrate from the network — it blanks the local copy when offline.
per-depth relative paths (../learn/index.html) using a {page → folder} map; unknown targets point back at the live site.
78.8 MB → 41.1 MB across 20 files (max 5.2 MB), all under the 25 MiB Pages cap.
the site's own tabs resolve.
Constraints
/demos/fpv-dronesstays inSKIP_PREFIX(~/scripts/theme-inject.py) so the site skin's +390 B and- Zero external requests at load: no script bundle, no remote
<img>, no externalhrefto the origin.
theme button never touch the mirror.
https:// links to third parties inside copy are left alone.
Verification (all automated)
- 20 files written; per file:
href="/rotorracinghk.com== 0,<img src="http== 0, Google<script src=== 0.
Step 6 — restore the menus (CSS-first, 20-line JS for the drawer)
Google's JS also drove the hover dropdowns and the mobile hamburger, so a stripped snapshot loses both. The site's own markup already contains the submenus (li[data-nav-level="1"] > .oGuwee); they are only display:none until Google's jsaction toggles them, and the items carry opacity:0 + margin-top:20px for the staggered reveal. scripts/fpv_add_dropdowns.py appends one <style id="fv-dropdowns"> and one <script id="fv-nav"> to every page (idempotent — it strips its own previous block first):
nav.plFg0c li[data-nav-level="1"]:hover > .oGuwee{display:block!important}— hover reveal, pure CSS,@media (max-width:920px)turnsnav#yuynLe(the sidebar drawer) into a fixed panel, forces- The 20-line script only wires the hamburger →
nav#yuynLedisplay toggle, close on link click, Escape.
right:0 so the Flight menu can't overflow the viewport, 300 px wide, padding:8px 18px per item, .PsKE7e{margin:0} to kill Google's staggered-reveal spacing (570 px → 350 px tall).
opacity:1; transform:none on its items and expands the nested uls.
It must run on DOMContentLoaded — the block sits before </head>, where getElementById('s9iPrd') is still null.
Verify with CDP, not a screenshot alone: hover dispatch needs several Input.dispatchMouseEvent mouseMoved steps before CSS :hover applies, and .oGuwee must report display:block, width 300 and a rect whose right edge is inside the viewport.
- Rendered home / Antennas / Schedule from the local copies against the live site: same header, banners,
- Card modal (751 px iframe) opens
./demos/fpv-drones/and the first page renders.
nav highlight, timetable table intact.
Known caveats (disclose, don't hide)
- Dropdown menus and the site search are JS-driven, so they are inert in the snapshot.
- YouTube embeds become poster frames / links.
- Web fonts come from gstatic; offline they fall back to system faces.
- Some of the origin's own image URLs already 403 — dead on the live page too.