Since 1 October 2026, California's Combating Auto Retail Scams (CARS) Act has required a dealer to disclose a vehicle's total price "in any advertisement that references a specific vehicle for sale" (Civil Code section 1784.41). Section 1784.44 adds a duty to keep records: a dealer must "create and retain, for a period of two years from the date the record is created," the records needed to show it complied, including records showing that its communications and advertisements of a vehicle's total price met section 1784.41(a). For that purpose, "advertisement" includes "internet-based listings that display a vehicle's total price, features, or financing terms disseminated by the dealer."
The records may be kept "in any legible form," and failing to keep them "is a violation of this title" (section 1784.44(b)). California's new-car dealers' association told its members a year ahead that the bill "contains significant new compliance requirements that demand your attention," among them advertising requirements on a vehicle's total price and new records retention requirements (CNCDA bulletin, October 2025).
Listings are the awkward part of that record. A listing's price moves, and the page goes when the car sells: all three California listings we found through search on 12 November 2026 were gone by the time we captured them. What a listing showed has to be captured while it is live.
A Python script captures each listing once a day from a US address as a new visitor, saves the full-page screenshot, reads the price the page prints after its label, such as "Total Price", and adds a row to a file it only ever appends to: the time in UTC, the page's status, the address the page landed on and the screenshot's SHA-256. One screenshot shows what one page displayed at one moment. Whether a dealer's records show that it complied, and what else they must hold, is for its counsel.
Check one listing in the browser, free
- Open site-shot.com and paste the link of the vehicle listing. Capturing is free and needs no signup.
- In the Country dropdown, select United States, a free choice, so the dealer's site serves the page it shows shoppers in the US.
- Tick full page, leave No ads / No cookie ticked (it is on by default), then press Shot.
- Save the image with the date and the VIN in its name. By hand this works for a few listings; for an inventory, use the API.
Put the listings in a file
You need Python 3.9 or newer, the Site-Shot SDK and an API key. Install the SDK:
pip install site-shot
For the key, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. The API quickstart gets you one. Free-plan captures come from the default location or the US only, and this script captures from the US.
Every listing costs one capture a run, and every capture the API admits counts against the month, failed ones included. A month can hold 31 daily runs. The free plan's allowance keeps a daily record of 3 listings; the $5 plan's keeps 64. An inventory of 300 vehicles needs 9,300 captures in a 31-day month, and pricing lists the larger plans.
Put the listings in listings.csv:
link,vin,label
https://dealer.example/vehicle/1A2B3C4D5E6F7G8H9/used-2024-hatchback,,
https://dealer.example/inventory/new-2026-suv/,9Z8Y7X6W5V4U3T2S1,
https://marketplace.example/listing/48213,5N1A2B3C4D5E6F7G8,Sale Price
linkis the listing's address, as a shopper opens it.vinis optional. Left empty, the script takes a VIN written in the link, as many listing addresses carry one. With a VIN, the script reads a price only from a page that shows that VIN and no other vehicle's VIN, so a sold car's link that now shows other vehicles is not recorded with one of their prices. A cell that is not a VIN is noted, and the row is captured without the check. A row with no VIN at all gets a price only from a page that prints no VIN, so give the VIN wherever the link lacks one.labelis optional: the words the page prints right before the price you want recorded, capital letters included, such asSale PriceorInternet Price. Left empty, it isTotal Price.
Inventory changes every day, so rebuild listings.csv from your inventory export before each run: new listings are then captured on their first day, and sold ones drop out. If your site lists its vehicle pages in a sitemap, the sitemap guide shows how to read links from it.
Capture every listing once a day
Save this as listing_records.py next to listings.csv:
"""Keep a dated record of each vehicle listing on listings.csv for California's CARS Act.
python listing_records.py (once a day)
Captures every link from a US address as a new visitor, saves the full-page screenshot and adds a
row to listing-records/records.csv: the time, the page status, the address the page landed on and
the price the page shows after its label. Earlier rows and screenshots are never changed.
"""
import base64
import csv
import hashlib
import html
import re
import time
from decimal import Decimal, InvalidOperation
from html.parser import HTMLParser
from pathlib import Path
from urllib.parse import unquote, urlsplit
from site_shot import AuthError, QuotaError, SiteShot, SiteShotError
client = SiteShot() # reads SITESHOT_API_KEY from the environment
OUT = Path("listing-records")
RECORDS = OUT / "records.csv"
FIELDS = ["captured_utc", "link", "vin", "landed", "status", "label", "price", "image", "sha256", "notes"]
SETTINGS = dict(
width=1280, height=1000,
full_size=True, max_height=8000,
format="jpeg", delay_time=3000,
no_cookie_popup=True, fresh_session=True,
source_code=True, country="US",
)
# Writes the address the browser ended up on, after any redirect, into the page source.
FINAL_URL = "document.documentElement.setAttribute('data-final-url', location.href);"
LABEL = "Total Price" # the words before the price to record; a label column overrides them
BOT_CHECK = re.compile(r"_cf_chl_opt|__cf_chl_") # left in the page source by a Cloudflare bot check
VIN = re.compile(r"(?<![A-Za-z0-9])[A-HJ-NPR-Z0-9]{17}(?![A-Za-z0-9])", re.I)
PRICE = re.compile(r"[^A-Za-z$]{0,20}\$\s?(\d{1,3}(?:,\d{3})+|\d+)(\.\d\d)?")
RUN = time.strftime("%Y-%m-%dT%H%M%SZ", time.gmtime()) # this run's folder
def vin_in(link):
"""A VIN written in the link, as many listing addresses carry one."""
return next((v.upper() for v in VIN.findall(link) if any(c.isdigit() for c in v)), "")
def same(a, b):
"""Whether two addresses name the same page, ignoring the case of scheme and host and a final slash."""
def parts(url):
p = urlsplit(url if "//" in url else "https://" + url)
return p.scheme.lower(), p.netloc.lower(), unquote(p.path).rstrip("/"), p.query
try:
return parts(a) == parts(b)
except ValueError: # a malformed address, such as "https://[bad"
return a == b
def money(amount):
return "${:,.2f}".format(amount)
class Page(HTMLParser):
"""The text a listing page shows and the address it landed on, from its rendered HTML."""
def __init__(self):
super().__init__(convert_charrefs=True)
self.landed, self.parts, self.hidden = "", [], 0
def handle_starttag(self, tag, attrs):
if tag == "html":
self.landed = dict(attrs).get("data-final-url") or ""
elif tag in ("script", "style", "noscript", "template"):
self.hidden += 1
def handle_endtag(self, tag):
if tag in ("script", "style", "noscript", "template") and self.hidden:
self.hidden -= 1
def handle_data(self, data):
if not self.hidden:
self.parts.append(data)
def text(self):
return " ".join(" ".join(self.parts).split())
def price_after(text, label):
"""The dollar amount right after the first place the page writes the label, as in "Total Price $23,010"."""
at = text.find(label) # the first place the label appears, and only that one
found = PRICE.match(text, at + len(label)) if at >= 0 else None
if found:
return Decimal(found.group(1).replace(",", "") + (found.group(2) or "")).quantize(Decimal("0.01"))
return None
def capture(row, image):
"""Capture one listing, save the screenshot as OUT/image and return its record row."""
reply = client.capture_json(row["link"], javascript_code=FINAL_URL, **SETTINGS)
data = reply.get("image") or ""
if "," not in data:
raise SiteShotError("the reply carried no image")
shot = base64.b64decode(data.split(",", 1)[1])
(OUT / image).write_bytes(shot)
status = (reply.get("response") or {}).get("status_code")
record = dict(row, captured_utc=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), landed="",
status=status, price=None, image=image, sha256=hashlib.sha256(shot).hexdigest(), notes=list(row["notes"]))
notes, source = record["notes"], reply.get("source_code") or ""
if str(status) != "200": # 404 for a removed listing, 504 for a capture that ran out of time
notes.append("status %s" % status)
if reply.get("source_code_status") != "ok" or not source:
notes.append("no page source (%s)" % reply.get("source_code_status"))
elif BOT_CHECK.search(source):
notes.append("the page source is a bot check; read the screenshot")
else:
page = Page()
page.feed(source)
record["landed"] = page.landed
text = page.text()
others = {v.upper() for v in VIN.findall(text) if any(c.isdigit() for c in v)} - {row["vin"]}
# A price is read only from a 200 page that stayed on the link and shows no other vehicle's VIN.
if page.landed and not same(page.landed, row["link"]):
notes.append("lands on %s" % page.landed)
elif str(status) != "200":
pass # noted above: a removed listing or an error card is not the listing
elif row["vin"] and row["vin"] not in text.upper():
notes.append("VIN %s not on the page" % row["vin"])
elif others:
notes.append("the page shows a VIN the row does not name; read the screenshot")
elif not row["vin"] and not page.landed:
notes.append("no VIN and no landing address; read the screenshot")
else:
record["price"] = price_after(text, row["label"])
if record["price"] is None:
notes.append('no price after "%s"' % row["label"])
return record
def listings():
"""One dict per row of listings.csv: the link, its VIN and the label before the price."""
with open("listings.csv", newline="", encoding="utf-8-sig") as f:
first = f.readline()
f.seek(0)
reader = csv.DictReader(f, delimiter=";" if ";" in first and "," not in first else ",") # Excel
reader.fieldnames = [(name or "").strip().lower() for name in reader.fieldnames or []]
if "link" not in reader.fieldnames:
raise SystemExit("listings.csv needs a link column (vin and label are optional)")
found = []
for n, row in enumerate(reader, 1):
link = (row.get("link") or "").strip()
if not link:
continue # a blank line
vin, notes = (row.get("vin") or "").strip().upper() or vin_in(link), []
if vin and not VIN.fullmatch(vin): # one bad cell must not cost the day's other records
vin, notes = "", ["the vin column is not a VIN (17 letters and digits, without I, O or Q)"]
found.append({"n": n, "link": link, "vin": vin, "notes": notes,
"label": (row.get("label") or "").strip() or LABEL})
return found
def earlier():
"""The latest recorded price of each link and when it was captured."""
last = {}
if RECORDS.exists():
with open(RECORDS, newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
try:
if row["price"] and row["captured_utc"] >= last.get(row["link"], ("", ""))[1]:
last[row["link"]] = (Decimal(row["price"]), row["captured_utc"])
except (InvalidOperation, KeyError, TypeError):
continue # a row another program rewrote: skipped, never a reason to stop
return last
def main():
(OUT / RUN).mkdir(parents=True, exist_ok=True)
rows, last, stop, cells = listings(), earlier(), None, []
new = not RECORDS.exists() or not RECORDS.stat().st_size
with open(RECORDS, "a", newline="", encoding="utf-8") as f:
writer = csv.DictWriter(f, fieldnames=FIELDS, extrasaction="ignore")
if new:
writer.writeheader()
for row in rows:
name = row["vin"] or hashlib.sha256(row["link"].encode("utf-8")).hexdigest()[:12]
try:
record = capture(row, "%s/%02d-%s.jpg" % (RUN, row["n"], name))
except (AuthError, QuotaError) as error: # a refused key or a spent month: later rows would fail too
stop = "stopped at row %d: %s" % (row["n"], error)
break
except SiteShotError as error:
record = dict(row, captured_utc=time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), landed="",
status="", price=None, image="", sha256="",
notes=row["notes"] + ["capture failed: %s" % error])
before = last.get(row["link"])
if record["price"] is not None and before and before[0] != record["price"]:
record["notes"].append("was %s on %s" % (money(before[0]), before[1][:10]))
said = "; ".join(([money(record["price"])] if record["price"] is not None else []) + record["notes"])
print(row["n"], said)
writer.writerow(dict(record, price="" if record["price"] is None else record["price"],
notes="; ".join(record["notes"])))
f.flush() # each row is on disk before the next capture starts
shot = record["image"].split("/", 1)[-1]
cells.append("<h2>%d. <a href='%s'>%s</a></h2><p><b>%s</b><br>captured %s, page status %s</p>%s" % (
row["n"], html.escape(row["link"]), html.escape(row["link"]), html.escape(said),
record["captured_utc"], html.escape(str(record["status"])),
"<p><a href='%s'><img src='%s' width='400'></a></p>" % (shot, shot) if shot else ""))
(OUT / RUN / "report.html").write_text("<meta charset='utf-8'><p>Run %s</p>%s" % (RUN, "".join(cells)),
encoding="utf-8")
if stop:
raise SystemExit(stop)
main()
On a Linux server, keep the key in a file only you can read and let cron run the script once a day, after your inventory export has rebuilt listings.csv:
# /srv/listing-records/key.env (chmod 600)
export SITESHOT_API_KEY=your-key-here
# crontab -e: every day at 07:17 server time
17 7 * * * cd /srv/listing-records && . ./key.env && python3 listing_records.py >> run.log 2>&1
Each run makes a folder named for its start time in UTC, such as listing-records/2026-11-12T071703Z/, holding the run's report.html and each screenshot under the row number and the VIN (01-1A2B3C4D5E6F7G8H9.jpg, or a hash of the link), and appends one row per listing to listing-records/records.csv. Nothing is overwritten: a second run on the same day makes a second folder and second rows. A failed capture is still a row, with the error in notes; a refused key or a spent month stops the run, since every later capture would be refused too. Cron won't tell you, so read run.log now and then. To look through records.csv in a spreadsheet, open a copy: the script skips rows another program has rewritten.
The full-page captures of dealers' pages in our test were 1.0 to 1.8 MB each. At that size a daily record of 300 listings adds 110 to 200 GB a year, so plan the disk or the storage bucket for two years of it.
No server? On a paid plan, a schedule captures a fixed page, such as your inventory page, daily into your library, within the plan's weekly schedule limit. The library keeps them for as long as your subscription is active, plus a 30-day grace period after it ends, so download what belongs in a two-year record. Listings that come and go, and the price column, stay with this script.
What each setting does
country="US"routes the capture through an IP address in the US and sets the browser's language, time zone and geolocation to match, so the dealer's site serves the page it serves shoppers in the US. The free plan covers it.full_size=True, max_height=8000captures the whole page, from the price at the top to the disclaimers at the bottom, and cuts a longer page at 8,000 pixels;width=1280, height=1000sets a desktop window.format="jpeg"saves a JPEG instead of the default lossless PNG. The prices and the fine print stayed legible in our JPEG captures.delay_time=3000waits 3 seconds after the page has loaded, and after the script below has run, so the page's own scripts can fill in prices and photos.no_cookie_popup=Trueremoves cookie-consent banners. In our test, one covered part of an inventory page until this was on.fresh_session=Truestarts each capture without the cookies earlier captures saved, so every capture is a new visitor.source_code=Truereturns the rendered HTML in the same reply as the screenshot. The script reads the VIN, the price and the landing address from it.javascript_codecopies the final address into the page before the HTML is read, because the API's JSON reply does not include it.
How the script reads a listing
Each run prints one line per listing, and the same notes go into the notes column:
- a price, such as
$23,010.00: the dollar amount right after the first place the page's text shows the label. It is read only when the page answered 200, did not land on another address, and shows the row's VIN and no other VIN. - was a price on a date: the price differs from the last one recorded for the link.
- VIN … not on the page: the page no longer shows that vehicle, as with a sold car's link that now shows other vehicles. No price is recorded.
- lands on an address: the browser ended up on another page, as when a sold car's link redirects to the inventory. No price is recorded.
- status and a number: the page did not answer 200, and no price is recorded. A 404 usually means the listing was removed; a 504 usually means the capture ran out of time, and the image is then an error card or a blank frame, not the page.
- the page source is a bot check; read the screenshot: the HTML in the reply is a Cloudflare bot check's, so the script records no price; in our test the screenshot still showed the page.
- the page shows a VIN the row does not name and no VIN and no landing address: the script cannot tell whose price it would read, so it records none. A row without a VIN gets the first note on any page that prints a VIN, its own included, and so does a listing that shows similar vehicles with theirs; the screenshot is then the record.
- no price after the label: the first place the page's text shows the label has no dollar amount right after it. The label may differ, the listing may say "call for price", or the page failed to load; the screenshot tells which.
- no page source and capture failed: the reply carried no HTML, or the API returned an error, whose message follows; a failed capture saves no image.
What the script read from four test captures on 12 November 2026
We captured California listings through our own key while testing this guide, then ran four of those replies through the published script, with the API stubbed and no new captures. The captures used the script's settings except fresh_session and the landing-address script, and they cut full pages at 7,000 pixels; rows 2 and 4 were also taken without no_cookie_popup. Dealers are named only by kind:
| Row | Listing | Printed |
|---|---|---|
| 1 | A franchise dealer's used hatchback, still for sale | $23,010.00 |
| 2 | The same dealer's new sedan, found through search | VIN <its VIN> not on the page |
| 3 | Another franchise dealer's new SUV, found through search | the page source is a bot check; read the screenshot |
| 4 | A listing aggregator's new SUV, found through search | status 404 |
Row 1's page shows the dealer's price stack: Original Price $23,111, Market Price $22,888, Document Fee +$85, Electronic Filing Fee +$37 and Total Price $23,010, with the pricing disclaimers legible at the bottom of the screenshot.
Row 2's link now shows "We can't find the vehicle you are looking for" above other vehicles, each with its own Total Price. The VIN check is why none of them was recorded.
Row 3's screenshot shows the dealer's inventory page, 77 vehicles for sale: the car had sold and the link led there, which only the screenshot shows, since the reply's HTML was the bot check's.
Where the honest limits are
- A record of what a page showed, not a compliance check. The script reads one number after one label. Section 1784.31(j) defines total price as the total sale price of the vehicle, including any dealer price adjustment and the cost of any item installed on it at the time of the advertisement, with no deduction for a rebate, and excluding the taxes, fees and charges described in Vehicle Code section 11713.1(e). Whether a page's "Total Price" is that price, and whether the page discloses it "clearly and conspicuously" as section 1784.41 requires, the script does not decide.
- The record starts with the first run, once a day. A price that changed and changed back between two runs is not recorded, and a listing that ended before the first run is not seen.
- A new visitor from a US address. Each capture has no cookies and no account, so a price shown only after a login, a chat or a form is not captured.
- The HTML is read, not the pixels. The price comes from the page's text: text the page hides can still be read, and a price drawn into an image cannot. The VIN check only asks that the VIN appears somewhere on the page, so a sold car's page that still names its VIN above another vehicle without one can record that vehicle's price. Compare the first runs with their screenshots.
- Bot checks. On a site that answers with a Cloudflare check, the reply's HTML can be the check page while the screenshot shows the page (2 of 2 captures of one site in our test); the script then reads no price. Other bot checks may go unrecognized.
- Consent banners.
no_cookie_popupremoves the banners it recognizes; one it misses stays in the screenshot over part of the page. - Listings only. The CARS Act's records also include a copy of the first written communication with a consumer that refers to a specific vehicle for sale, or to any monetary amount or financing term for any vehicle, kept at least two years under section 1784.41(a)(3)(B), and the signed contracts, add-on, cancellation and complaint records that section 1784.44(a)(2) to (5) lists. The script captures the web pages you list and nothing else.
- The files, the clock and the hashes are yours. The time comes from the machine that runs the script, so keep its clock synchronized. A SHA-256 tells you later whether a file is still the one the run saved; it proves nothing about when the file was made. The script never deletes anything; the statute's period is two years from the date each record is created.
- Not legal advice. Which pages need a record, how often, and what else the record must hold is for a dealer's counsel to say.
- The free plan covers a daily record of 3 listings. A dealer's inventory needs a paid plan.
FAQ
What records does the California CARS Act require for vehicle ads? Section 1784.44 of the California Civil Code, operative since 1 October 2026, requires a dealer to create and keep, for two years from the date each record is created, the records needed to show compliance with the CARS Act, including records showing that its advertisements of a vehicle's total price met section 1784.41(a). For that purpose, advertisements include internet-based listings disseminated by the dealer that display a vehicle's total price, features or financing terms. The records may be kept in any legible form.
Is a screenshot enough to meet the CARS Act record requirement? The CARS Act lets a dealer keep its records in any legible form and does not name a format. A dated screenshot shows what one listing displayed at one moment; whether a dealer's records together show compliance is a question for its counsel. A Site-Shot script can capture each listing once a day, with the time, the page status and the price the page shows after its label.
How long must a California dealer keep CARS Act records? Two years from the date each record is created, under California Civil Code section 1784.44(a). A copy of the dealer's first written communication with a consumer that refers to a specific vehicle for sale, or to any monetary amount or financing term for any vehicle, must be kept for at least two years under section 1784.41(a)(3)(B). Section 1784.44(c) adds that it does not limit the requirements of Civil Code section 2984.5.
Can I capture vehicle listings for free? The free browser tool at site-shot.com captures a page from the US with no signup, so a daily capture by hand works for a few listings. Through the API, a confirmed account's API key comes with the free plan: 100 API captures a month, no card needed; paid plans start at $5/mo for 2,000. A daily record of 3 listings fits the free plan; a dealer's inventory needs a paid plan.
Related reading
- How to Save a Web Page as Evidence Before It Changes or Disappears
- How to Capture MAP Violation Evidence with Timestamped Screenshots
- How to Check a Discount Against the EU 30-Day Lowest Price
One listing? The browser tool is free, no signup. A daily record of an inventory runs on the API; see pricing.