Skip to content
VC
Case Study #25 · Web · Tooling

Desktop promo-landing generator: brief or URL → finished page

My own tool, written for my own workflow. Promo pages used to be copy-pasted from the previous one — with manual edits to meta tags, copy, image slots and palette. I built a desktop app that assembles the page from a template and a brief (or lifts the content off an existing page by URL) and ships the finished archive to the host over its API.

Project type
Own tooling · internal build
Stack
Python · tkinter · Pillow · stdlib
Timeline
26 commits · ~1 month of iteration
Outcome
Brief or URL → page live on the host
01 · Pain Point

Every new page was a copy-paste of the last one

A page for a new product or a new language was always built the same way: duplicate the previous folder, walk the markup by hand and rewrite everything that belonged to the old project. Title, meta description, the whole og:* block, favicon, the config holding store name and prices, intro copy, survey questions, reviews, the offer block — and only then the images.

Images were their own failure mode. The template has fixed slots: intro logo, header logo, favicon, page background, product shot, social image. Files from the source arrive mixed in with decorative icons, avatars and badges — and the logo slot kept getting the wrong file. Nobody watches file weight either: a multi-megabyte original ships to production untouched.

The last step was manual too: zip the folder, open the host panel, create a group, upload the archive, then deal with the name clash if that page already existed. One cycle is tolerable. Several pages a week, each with language and palette variants, and it stops being work and becomes maintenance of your own copy-paste.

02 · Solution

A desktop app that reads the source and assembles the page

The tool is a plain tkinter window with tabs: project, content, palette, images, survey, reviews, publishing, log. No server, no build step — the Python file launches on a double click, with macOS and Windows wrappers in the repo. The flow is one-directional: parse the source, fill the template, push the result to the host.

01
Source

A folder with assets and a brief — or a URL: the built-in fetcher pulls HTML and assets

02
Parse

Titles, meta, names, prices, copy, questions, reviews, palette from CSS

03
Slots

Logo, favicon, background and product auto-detected from markup; editable in the GUI

04
Build

Template + content → output folder, images compressed, markup self-checked

05
Publish

In-memory ZIP → host Admin API: group, templated name, clash → overwrite

Two template families, one interface

The repo carries two templates: template_1 on Bootstrap, and template_2 — a static export of an Astro build styled with Tailwind-style atomic utilities. Their markup, file names and slot sets differ, so switching the preset rebuilds the tabs: its own image slots, its own colour keys, its own substitution rules. From the outside it stays one workflow.

A URL instead of a folder: the built-in fetcher

The content source can be an existing page's URL rather than a local folder. The fetcher is written on the standard library and behaves like a well-mannered browser:

  • honours <base href> when resolving relative paths
  • collects assets from img/link/script/source/srcset and from url(...) in inline styles
  • walks CSS recursively — url() and @import, so fonts and backgrounds aren't lost
  • stays on the same origin — external CDNs remain links instead of dragging half the internet along
  • strips <script> bodies before parsing, otherwise strings inside inline JS get matched as src=

Logo, favicon and background come from the markup, not the filename

The first version sorted images into slots with "name + file size" heuristics — and that is exactly what dropped random junk into the header. Markup now takes priority: the favicon comes from <link rel="icon">, the logo from the header and from alt/class values containing logo/brand, the background from background-image: url(...) on body/html, review images from their own blocks. Name and weight heuristics survive only as a fallback, and decorative icons, avatars and badges sit on a deny-list so they can never claim the logo slot again. Anything the tool can't decide stays visible in the interface and gets fixed by hand.

The palette is lifted from the source, not eyeballed

The app reads the HTML together with its linked CSS and pulls colours out of theme variables. The last declaration wins — a theme is supposed to override base styles, not the other way round. Tokens are normalised from hex, rgb() and oklch(), greys and near-whites are dropped as uninformative, and what remains is mapped onto semantic keys: button, button text, header, page background, card, accent. From there the palette is just editable fields in the UI.

A project is a JSON file, not a snowballing folder

Build state is saved to a project file: chosen template, paths, all content, palette, slot mapping, questions, reviews. A repeat or a variant means opening the project and editing two fields instead of cloning a directory. Side effect: builds are reproducible and can be versioned alongside the code.

Images get compressed on the way out

Every file landing in a slot goes through Pillow: resize on the longer edge, transparency preserved for PNG/WebP, sane output quality. If Pillow isn't available it falls back quietly to a plain copy — generation never fails on this. It is exactly the kind of small discipline nobody keeps by hand on every page.

The build self-checks its markup before publishing

A finished page has mandatory pieces that are easy to lose while editing a template: hidden placeholders the host substitutes at serve time, and a shared event identifier for the browser-side and server-side analytics paths. The generator verifies they exist in the output, re-injects whatever is missing and writes a warning into the log. The reasoning is simple: a template edit must not silently break event tracking — silent zeros surface far too late.

Publishing is one action, name clash included

The finished folder is zipped in memory (system junk such as .DS_Store is filtered out), base64-encoded and pushed to the host Admin API. Existing groups are pulled into a dropdown; the page name is composed from a pattern like {store} - {product} - {template} - {lang}. A 422 "name already used" response doesn't kill the run: the app locates the existing record and asks whether to overwrite it, then sends a PUT to that id. The API key lives in a local config with 600 permissions, outside the repository.

A design system that lives next to the code

The repo also holds three briefs: shared foundations (typography, grid, states, mobile adaptation, performance budget) plus one document per template family — a selector-by-selector breakdown, a delete list, an add list and a verification checklist. The point is division of labour: the generator guarantees structure, the briefs guarantee the visual bar. Otherwise automation just ships mediocre layout faster.

03 · Stack

Desktop, no build step — it opens on a double click

Python 3 + tkinter

Local app with no server and no build pipeline

html.parser + regex

Source parsing: meta, copy, prices, questions, reviews

urllib (stdlib)

Page fetcher by URL and client for the host Admin API

Pillow

In-app previews and image compression on output

zipfile + base64

In-memory packaging and archive upload over the API

JSON projects

Full build state in a file — reproducibility and variants

template_1 · Bootstrap

First template family with its own slot set

template_2 · Astro export

Second family: static build on atomic utilities

design-briefs/

Design system: shared foundations + a brief per family

.app / run.sh / run.bat

Launch wrappers — the end user never touches Python

PythontkinterPillowhtml.parserurllibzipfileAdmin APIBootstrapAstroTailwind
04 · Results

Pages get built from a project, not from memory

Building a page
10 steps 1 run

manual markup editing replaced by a single generation run

Publishing
1 action

archive, group, name and name clash all handled inside the tool

How it evolved
26

commits — each fix came out of a real build, not a roadmap

A whole class of mistakes disappeared, not just the hours

A forgotten og:image, last project's favicon, a logo in the wrong slot, a multi-megabyte photo, a background lost in the move — all of it used to be caught by eye on a live page. Those things are now either surfaced as explicit fields in the interface or verified automatically before upload.

The commit history is a list of rakes stepped on

The tool was hardened on real builds, and nearly every commit is a specific defect: an intro logo growing sub-pixel by sub-pixel because of an animation; relative paths breaking on transfer; a name clash on re-upload that used to abort the run outright; small icons the heuristic mistook for a logo. You don't design these in advance — you find them in production use, which is precisely why a tool for your own process is worth writing yourself.

Honest limits

This is internal tooling, not a product: the interface has no UI test coverage, and parsing quality depends directly on how disciplined the source markup is. On an unusual source the automation fills some fields wrong — which is why everything it detects stays editable and the generation log shows every step. That contract is more honest than magic: you always see what was substituted and where it came from.

05 · Where it fits

Where the same methodology applies

This case isn't about landing pages. It's the standard problem of "template + structured content → finished static artefact → automatic publishing". The pattern transfers almost unchanged anywhere repetitive pages are built by hand:

  • Multilingual and multi-brand pages — one build, different projects: language, palette, logo, copy
  • A landing page per SKU from a product feed — batch generation instead of hand-built cards
  • Rolling out chain or franchise sites — shared template, per-location contacts, colours and logo
  • Migrating an existing page to a new template — the fetcher lifts the source, the parser extracts content and palette
  • Batch-prepared variants for comparison tests — same copy, different palette and slots, published in one queue
  • The same code without a GUI — as a CLI step in CI: a JSON project in, a published page out
What carries over to the next projects
  • A static-page fetcher: recursive CSS walk, same-origin limit, preserved path structure
  • An image-role detection layer: markup first, heuristics second, manual override always available
  • Palette extraction from CSS variables: hex / rgb / oklch, theme over base, greys filtered out
  • A JSON project format — builds are reproducible, versionable and reusable as the basis for a variant
  • A pre-publish self-check of mandatory markup markers — re-injection plus a warning in the log
  • Publishing to a host/CMS over its API: in-memory ZIP, base64, name-clash handled as an overwrite
Similar challenge?

If your pages are copy-pasted from the last one — that converts into a generator

Tooling built around your own process pays off wherever a repetitive page is produced weekly rather than quarterly. Start with one template, one source format and one publishing target — the rest grows out of real builds.

Ready to start?

The 5,000 ₽ audit — with a concrete report and quote

I'll tell you what to deploy in your business first, what the payback looks like, and whether you need AI for the task at all (sometimes you don't).

Or just send your question — I reply within 2 hours