Turn a local business's own customer reviews into a clean, honest one-page website it owns.
ReviewForge takes a business's details, its customer reviews and (ideally) its
own photos, and generates a fast, accessible, static website: a hero, the
services the reviews describe, verbatim attributed testimonials, a short
"about" blurb, and a clear call to phone the business. Every build also writes
a machine-readable provenance.json saying where each word and each image came
from.
It is built around one rule: nothing reaches the page that the input does not support. It cannot guarantee that on its own, so it is also built to make a human review easy (see Before you publish).
Supplied photos (samples/business-with-photos.json) |
Reviews only, illustrative placeholders (samples/business.json) |
|---|---|
![]() |
![]() |
Both sample businesses are fictional. See Samples.
- It is a tool a business (or someone helping one) can use to stand up a simple website from reviews and photos the business already has the right to use.
- It is not a scraper. It has no code that fetches reviews or photos from Google, Yelp or any other platform.
- It is not a review generator. Testimonials are verbatim excerpts of the reviews you supply, with the author and rating you supply. ReviewForge cannot check that a supplied review is genuine; that is on whoever supplies it.
- It does not pass generated pictures off as the business's work. See Imagery.
- The marketing copy is machine-drafted. It is audited for unsupported claims, but it still has to be read by a human before the site goes live.
Use only reviews the business has the right to republish: reviews customers sent the business directly, testimonials it collected with permission, or an export from a platform whose terms allow republication.
ReviewForge deliberately has no Google Places / Google Maps integration. A static site is an indefinite copy of whatever it shows, and Google's terms for Places content do not allow storing reviews or photos like that, and serving Places photos from a static page would put an API key in public HTML. The only compliant way to show live Google reviews is a client-side widget that fetches them at view time under Google's terms, which is out of scope for a static generator.
Do not cherry-pick. If you supply only the five best reviews out of forty,
the page will show an average of those five. ReviewForge labels that number as
"average of the 5 reviews shown" so it cannot read as the business's overall
rating, but the selection itself is still yours to answer for. If the business
has an overall rating on a platform, supply it as rating_summary and the hero
shows that instead (e.g. "4.6 on Google · 40 reviews").
Models invent credentials even when told not to; a 7B model wrote "With over 20 years of experience" for a business whose input says nothing about its age. So every generated field is audited before it reaches the page. The audit looks for:
- years in business, decades, founding years;
- licensing, insurance, bonding, certification, OSHA, BBB / A+ ratings;
- awards and rankings: best, leading, premier, top-rated, #1;
- expertise and reputation puffery: expert, experienced, professional, trusted, reliable, high-quality;
- availability: 24/7, emergency, same-day;
- ownership: family-owned, locally owned, veteran-/woman-owned, third-generation;
- eco-friendly, lowest price, price match, affordable;
- guarantees, warranties, lifetime;
- percentages, star ratings, hundreds / thousands / dozens, and any number of three or more digits;
- the word free — see below.
A flagged phrase is allowed only if the same words appear in the business's own
facts or reviews: if a customer wrote "30 years", the copy may say "30 years".
If anything is unsupported, the draft is discarded and the model gets one
retry, told exactly which phrases were rejected. If the retry also fails, a
plain deterministic template is used instead (the template itself is tested
against the same audit for many categories). provenance.json records every
attempt and every rejected phrase.
Be clear about what this check is. It is a conservative pattern match plus a case-insensitive whole-phrase lookup in the input (allowing a simple singular/plural, so a review saying "emergency" supports "emergencies"). It stops the copy introducing claims the input never mentions; it does not prove anything true. A review saying "not the best" would "support" the word best. Mild, unlisted adjectives get through. A human still has to read the page.
ReviewForge never assumes a business gives anything away. The words "free
estimate" / "free quote" (or "free" anywhere in the copy) appear only when
business.json sets "offers_free_estimates": true. Otherwise the calls to
action are neutral ("Call for a quote"). A review that mentions something free
does not count: it describes one customer's experience, not an offer.
A trade website lives or dies on its pictures, and the pictures that sell
work are photographs of the work. Put the business's own photos in
business.json: the hero photo (the first, or the one with "role": "hero")
fills the top of the page and the rest become an "Our work" gallery, with the
business's own captions kept word for word.
| Source | origin |
Where it is used |
|---|---|---|
| Photos the business supplied | supplied |
hero + "Our work" gallery, captions verbatim |
| Generated generic trade imagery | generated |
hero backdrop + service cards only |
Generated imagery is a placeholder, not a finished product, and the rules for it are enforced in code:
- Disclosed on the page. Whenever generated imagery is used, the footer
states that the images are software-generated placeholders, not photographs
of the business's work, and each such
<img>has adata-illustrative="true"attribute. - Never in "Our work". The gallery renders only business-supplied photos; this is filtered in both the generator and the renderer.
- No ownership captions.
is_business_ownis derived fromoriginand cannot be overridden. A caption or alt text on a generated image that implies the business did the work ("our crew", "recent job", "this home") is stripped and recorded inprovenance.json. - Generic by construction. Prompts describe materials, tools and distant, unidentifiable work; the negative prompt rejects text, signage, house numbers, plates, logos and faces.
- No embedded metadata. Generators such as ComfyUI write their whole
workflow into PNG text chunks. Every image written to the output has its
PNG
tEXt/iTXt/zTXt/eXIf/tIMEchunks or JPEG EXIF/XMP/IPTC/comment segments removed (standard library only); the generation details live inprovenance.jsoninstead. This also strips GPS data from phone photos — note that it drops the EXIF orientation flag too, so supply photos upright. - No logo is ever generated. A supplied logo is used as-is; otherwise the page shows a typographic wordmark.
- A missing photo is an error, never silently replaced with a generated one.
Why generated imagery is only a stopgap: it is not the business's work and
never will be; visitors may assume otherwise even with the labels; some
jurisdictions treat implying work you have not done as misleading advertising;
and generative models produce artefacts. Look at every image, and ask the owner
for five phone photos. --images own-only forbids generated imagery outright;
--images off disables imagery entirely.
ReviewForge does not ship a diffusion pipeline; it drives one you already run:
ComfyUIBackend(default) — a running ComfyUI over its HTTP API (POST /prompt→ poll/history/<id>→/view), standard library only and core nodes only. Point it anywhere with--comfyui/REVIEWFORGE_COMFYUI_URL; pick the checkpoint with--image-checkpoint/REVIEWFORGE_IMAGE_CHECKPOINT(defaultsd_xl_base_1.0.safetensors; if the server lacks it, an SDXL checkpoint it has is used and the substitution recorded).DiffusersScriptBackend(optional) — your own localdiffusersworker script, viaREVIEWFORGE_DIFFUSERS_SCRIPTand--image-backend diffusers.
If neither is reachable the page is built without imagery and
provenance.json says why. ReviewForge is written to be a polite guest on a
shared ComfyUI: it queues the whole image set at once (so it renders as one
block instead of waiting behind every job that arrives in between), treats a
missed status poll from a busy server as a hiccup rather than a failure,
withdraws its own queued prompt when it gives up on it (never touching anyone
else's job), and notices within seconds if a restart dropped the queue, then
retries that image once. Allow for queueing with --image-timeout.
Privacy. Anything provenance.json records about the machines involved is
minimal: an LLM or ComfyUI endpoint is recorded only if it is a loopback
address (otherwise just "non-local endpoint"), the server's checkpoint list is
not recorded, and only the number of compute devices is noted.
Many jurisdictions require a business website to carry a legal notice and a privacy policy. Supply them as text or as URLs:
"country": "DE",
"legal": {
"impressum": "Muster Sanitär GmbH\nMusterstraße 1\n10115 Berlin\n...",
"privacy": "https://example.com/datenschutz"
}Text is rendered in a section at the foot of the page; URLs are linked. The footer links read Impressum / Datenschutz for Germany and Legal notice / Privacy elsewhere.
country: "DE"without both an Impressum and a privacy policy is a hard error — the build stops and writes nothing.- Other EU/EEA countries missing either get a warning (printed, and recorded in
provenance.json). - ReviewForge writes no legal text for you and gives no legal advice. Also note that a static page with no forms, cookies, fonts or third-party scripts — which is what ReviewForge emits — keeps the privacy policy simple, but your hosting provider's access logs still count. If you enable the optional lead widget, the page does collect personal data; see Lead capture and booking.
With a leads block in business.json, the page gets a small self-contained chat widget (chat.js, vanilla JS, no dependencies, no CDN, no cookies, no trackers, under 15 KB) behind a floating "Request a callback" button, plus a plain no-JavaScript form in the contact section that posts to the same place. Without leads, nothing changes: no script, no form.
The JSON for the leads block is:
{
"endpoint": "https://leads.example.com/lead",
"email": "office@example.com",
"callback_windows": ["Morning", "Afternoon", "Evening"]
}And the rules:
endpointmust be https (http only for 127.0.0.1/localhost testing) and receives a JSON POST;emailis the mailto fallback;- at least one of the two is required;
callback_windowsdefault to Morning/Afternoon/Evening (max 6).
The booking block is optional, needs leads, and has:
provider:calcom,googleorcalendly;url: must be https and on the provider's own domain (cal.com, calendly.com, calendar.app.google / calendar.google.com) -- a self-hosted cal.com needs"self_hosted": true.
The conversation proceeds as follows:
- Service (the page's service titles + "Something else")
- Name
- Phone (required)
- Email (optional)
- "Call me back" with a time window or "Book a time"
- Consent checkbox
- Summary
- Send
Honesty: the widget's header says "Automated assistant" (EU AI Act Art. 50 transparency) and it never claims to be a person. Its text is fixed; it never states prices, availability, response times or anything not in business.json. The confirmation is neutral: "Thanks, has your request." Booking slots come only from the provider's page; ReviewForge never shows or invents availability.
"Book a time" opens the provider page in a new tab, prefilled where the provider supports it (cal.com: name, email, notes; Calendly: name, email; Google appointment schedules: no prefill). The request is also sent as a lead.
Delivery:
- A fetch() POST of JSON with the fields
name,phone,email,service,callback_window,booking_provider,message,consent,page_url,ts(plus a honeypot field and the time taken, used for spam filtering); - If the POST fails and an email is configured, a prefilled mailto: link is offered;
- The phone (tel:) link is always shown.
Accessibility:
- Keyboard reachable launcher
- role="dialog" with focus trap
- Esc closes
- aria-live transcript
- respects prefers-reduced-motion
- full-height sheet on phones
- themed only through the page's CSS custom properties (--brand, --brand-dark, --accent, --bg, --surface, --ink, --muted, --border, --radius)
reviewforge serve-leads --origin https://www.example.com --db leads.sqlite3 --require-consent
reviewforge serve-leads --db leads.sqlite3 --export-csv leads.csvThe serve-leads receiver:
- Uses only the standard library;
- Binds 127.0.0.1:8787 by default (put it behind your TLS reverse proxy at the
endpointURL); - Accepts JSON and the no-JS form;
- CORS allowlist of the site origin(s) via --origin;
- Per-IP rate limit (default 5 per 10 minutes);
- Honeypot and too-fast submissions are silently dropped;
- Requests over 8 KB are refused;
- Leads go into a SQLite file;
--export-csvwrites them out;- Optional email notification through SMTP configured only by environment variables (REVIEWFORGE_SMTP_HOST, _PORT, _USER, _PASSWORD, _FROM, _TO, _STARTTLS) -- credentials are never written to disk or logged.
Any other endpoint that accepts the same JSON works too (a form backend, your own server).
What is collected:
- Only what the visitor types: name, phone, optional email, the service picked, callback window or booking choice, an optional message, the consent answer, the page URL and a timestamp.
Where it goes:
- Only to the endpoint and/or email address in business.json -- ReviewForge itself receives nothing;
- The booking provider receives only what is prefilled into its URL once the visitor opens it, under the provider's own privacy terms.
The serve-leads receiver stores no IP addresses:
- IPs are held in memory only for rate limiting;
- The BUSINESS is the data controller and must mention the form, the receiver/host and the booking provider in its privacy policy, decide how long to keep leads, and delete them on request;
Consent rule:
- The checkbox is always shown;
- It links the privacy policy when there is one;
- It is REQUIRED when country is "DE" or a privacy policy is supplied (use --require-consent on the receiver for such sites).
No legal advice.
Copy is machine-drafted and must be reviewed by a person before the site goes live. A short checklist:
- Read every sentence of the hero, services and about text against what the
business actually does. Remove any service it does not offer and any
adjective you cannot stand behind. Check
provenance.json→rejected_claimsto see what the model tried to say. - Reviews: confirm the business has the right to republish each one, that none was edited, and that the selection is fair (not just the top few).
- Rating: if the hero shows a platform rating, check it matches the platform today.
- Offers: confirm
offers_free_estimatesis right. Phone number, hours and service area are correct. - Images: every photo really is the business's own work (or is clearly labelled illustrative); no faces, addresses or plates of customers without consent. Replace generated placeholders with real photos as soon as possible.
- Legal: Impressum / privacy policy present where required.
- Ownership: the business should own its domain and its hosting account (or at least have the login), so the site is not held hostage by whoever built it. The output is plain static files; any static host works.
The default design (--theme astra) is an editorial one-page layout: a large
brand-colour hero panel (paired with the business's photo when it has one, a
spacious typographic hero when it does not), service cards, a gallery of the
business's own photos only, full verbatim reviews with a numeric average and
its stated basis, an about block, a contact panel and, on phones, a fixed call
bar. System fonts only; no JavaScript unless the lead widget is enabled.
Colours are CSS custom properties. The palette is picked from the business category:
| Palette | Picked for categories mentioning |
|---|---|
roofing (terracotta and limestone) |
roof, gutter, shingle, Dachdecker |
plumbing (deep teal and mineral) |
plumb, drain, sewer, water heater, heating, HVAC, Sanitär |
electrical (bronze and soft voltage yellow) |
electric, solar, lighting, Elektriker |
landscaping (forest and pale leaf) |
landscaping, lawn, garden, tree, yard |
cleaning (slate blue and porcelain) |
cleaning, maid, janitorial, pressure washing, carpet, Reinigung |
auto (oxblood plum and warm alloy) |
auto, car, mechanic, tire, collision, Kfz |
Anything else gets the neutral cleaning palette. Set "palette" in
business.json to choose one explicitly. Every listed text/background pair
meets WCAG AA (4.5:1). The original design is still available with
--theme classic.
Pure standard library at runtime — no third-party dependencies. Python 3.10+.
pip install -e . # installs the `reviewforge` command
pip install -e ".[test]" # + pytest for the test suite# Preferred: the business's own photos, listed in business.json
reviewforge build --input samples/business-with-photos.json --out out-photos/
# No photos? Generic illustrative placeholders from a local ComfyUI
reviewforge build -i samples/business.json -o out/ --images illustrative
# Never show imagery the business did not supply
reviewforge build -i samples/business.json -o out/ --images own-only
# Check the LLM endpoint and the image backend
reviewforge check| Flag | Default | Meaning |
|---|---|---|
--input / -i |
— | path to the business JSON |
--out / -o |
— | output directory |
--model |
qwen2.5-coder:32b |
Ollama model for copy generation |
--endpoint |
http://localhost:11434 |
Ollama base URL |
--timeout |
120 |
LLM request timeout (seconds) |
--theme |
astra |
page design: astra | classic (see Design) |
--images |
auto |
auto | own-only | illustrative | off |
--image-backend |
auto |
auto | comfyui | diffusers |
--comfyui |
http://127.0.0.1:8188 |
ComfyUI base URL |
--image-checkpoint |
sd_xl_base_1.0.safetensors |
checkpoint to generate with |
--service-images |
3 |
how many illustrative service images to make |
--image-seed |
fixed | seed, for reproducible imagery |
--image-timeout |
1800 |
seconds to wait for one generated image |
--images auto uses the business's photos if it has any and generates
placeholders only if it supplied none; it does not contact an image backend
when photos are present. If the LLM is unreachable, the deterministic template
is used and the build still succeeds. For any qwen3 model, think:false is
sent automatically (otherwise it returns empty output).
* required (both keys) when country is "DE".
Required business fields: name, category, city, phone. Required per
review: author, rating (0–5), text. Required per photo: src (a local
path, absolute or relative to the JSON file, or an http(s) URL that is
referenced, never downloaded). Missing optional fields are omitted, never
invented; a malformed review, rating summary or photo entry is rejected loudly.
index.html— semantic, responsive, no JavaScript (unless the optional lead widget is enabled), no trackers, no external fonts or CDNs; skip link, landmarks,tel:links, focus styles (the classic theme also has a dark mode). Theprovenance.jsonrecord is embedded too.style.css— the Astra stylesheet followed by the business's trade palette as CSS custom properties (or the classic stylesheet with--theme classic).chat.js— only whenleadsis configured: the self-contained lead widget (its CSS is inlined inindex.html).images/— staged copies of supplied photos and/or generated placeholders, metadata stripped.provenance.json— copy source and every attempt, rejected claims, which reviews became testimonials (withsource_url), the basis of the displayed rating, legal-page status, and every image with its origin, illustrative flag, alt text, and (if generated) prompt and seed.
Both sample businesses are fictional — invented names, made-up reviews,
555 phone numbers and .example domains.
-
samples/business.json→out/: Summit Ridge Roofing, reviews only, built with--images illustrativeto show the placeholder path (the footer discloses the generated imagery). Norating_summary, so the hero shows the average of the reviews shown. -
samples/business-with-photos.json→out-photos/: Blue Line Plumbing, the supplied-photos path (hero, gallery, logo, captions, arating_summary), plus aleadsblock and a placeholder cal.combookinglink to show the chat widget. Its photos are openly licensed stand-ins from Wikimedia Commons, not photographs of any plumber's work, and each is credited on the page:File Author Licence Source bathroom-rough-in.jpgKVDP Public domain File:ShowerInstallation.JPG pex-supply-line.jpgTomwsulcer CC0 1.0 File:PEX pipes and valves in basement ceiling for exterior water spigot.jpg water-heater.jpgMattes Public domain File:Water heater bathroom.JPG isolating-valve.jpgMike1024 Public domain File:Compression fitting isolating valve 15mm screwdriver turn.jpg The logo is a simple mark drawn for the fictional business.
Both sample builds were produced with qwen3:30b-a3b-instruct-2507-q4_K_M through
Ollama. The
preview.png in each output folder is a full-page screenshot.
pytest -qThe suite runs offline. It covers: input parsing and its refusals; the claim
audit (the full vocabulary, support from reviews, "free" gating, the
retry-then-fallback path, and the fallback passing its own audit across
categories); footer and testimonial wording; the rating basis; legal-page
handling including the German hard stop; the footer disclosure for generated
imagery; metadata stripping for PNG and JPEG; the image honesty rules (gallery
exclusion, caption stripping, unforgeable is_business_own); the ComfyUI
backend's failure modes against a local fake server; and a check that no
Places / key-in-URL code remains. tests/conftest.py points the image backend
at an unroutable address so no test can start a real GPU render.
The lead widget is covered too: config validation, booking-URL prefill parity between the Python and JavaScript implementations (run with node when it is installed), the widget appearing only when leads is set, the "Automated assistant" label, the consent rule, and the serve-leads receiver (valid input, honeypot, rate limit, size cap and CORS) on an ephemeral local port.
The page designs are covered as well: Astra is the default and classic stays selectable, the category-to-palette mapping and the palette override, the palette landing last in style.css, the layout's landmarks and their order, and the chat widget sitting inside the new layout.
MIT — see LICENSE.


{ "name": "Summit Ridge Roofing", "category": "Roofing Contractor", "city": "Asheville, NC", "phone": "(828) 555-0147", "website": "https://example.com", // optional "hours": "Mon-Fri 7:30am-6pm", // optional "offers_free_estimates": false, // optional, default false "rating_summary": { // optional "average": 4.6, "count": 40, "platform": "Google" }, "country": "US", // optional, ISO 3166-1 alpha-2 "legal": { "impressum": "...", "privacy": "https://..." }, // optional* "logo": { "src": "photos/logo.png", "alt": "Summit Ridge Roofing logo" }, "photos": [ // optional, strongly preferred { "src": "photos/roof-1.jpg", "role": "hero", // optional "alt": "New architectural shingles on a two-storey home", "caption": "Full tear-off and re-shingle in West Asheville", "attribution": "Photo: J. Smith" } // optional credit line ], "reviews": [ { "author": "Marcus D.", "rating": 5, "text": "...", "date": "March 2026", // optional "source_url": "https://..." } // optional, kept in provenance ], "leads": { "endpoint": "https://...", "email": "office@..." }, // optional "booking": { "provider": "calcom", "url": "https://cal.com/..." }, // optional "palette": "plumbing", // optional design palette override }