Turn a Property Shortlist into a Shareable Research Packet
Generate a printable property research report from a saved snapshot: source links, collection times, missing fields and analyst notes kept apart from the data, with no copied listing photos.
A packet someone else can trust
At some point a shortlist leaves your screen. A client wants to read it on a train, a partner wants to mark it up, or an investment committee wants a document in the meeting folder. A dashboard does not travel well and a raw spreadsheet hides its caveats. A research packet does both jobs: it is a printable page per property that shows what was observed, where it came from, when it was collected, what was missing and what your team thinks.
This guide builds that packet as a single HTML file generated from a saved snapshot. You can print it, save it as PDF from the browser or send the file itself. It runs locally, uses only the Python standard library and never needs the API key, because it reads data you already collected.
The rules the template follows
A packet is persuasive by nature, so the template is built to be honest by default. These rules come straight from the code below.
- Every property shows what was requested next to what was returned.
- Every observation shows its source link and collection time.
- Missing fields are listed by name instead of disappearing from the page.
- Failed lookups stay in the packet with their error code.
- Estimates are labeled as Zillow's estimates, and price is labeled by listing status.
- Analyst notes are visually separate and attributed to a named author.
- Listing photos are not embedded; readers follow the source link to see them.
Prepare the two inputs
The first input is a snapshot of full property responses. If you followed the map or CRM guides, you already have one in the expected shape: an exported_at time and a list of rows, each with your id, your input and the API's ok, data, meta and error. The property dashboard guide shows another way to build a snapshot from an address list.
The second input is notes.csv, where analysts write their observations keyed by the same id. Keeping notes in a separate file is deliberate. It stops opinions from being mixed into source fields, and it lets you regenerate the packet with fresh data without losing anyone's notes.
id,author,note
home-a,J. Rivera,"Walked the block: busy road on the north side. Ask about the roof age."
home-b,J. Rivera,"Price cut noted in the source history; confirm with the listing agent."Generate the packet
make_packet.py writes one section per row. The price line is labeled asking price, monthly asking rent or sale price depending on homeStatus, so a reader never has to guess. Every value passes through an escaping function before it reaches the HTML, which matters because addresses, descriptions and notes are text from people and pages you do not control.
The facts table is short on purpose. It covers the fields readers ask about first, and it ends with a sentence that names every field that came back empty. Add fields if your audience needs them, but keep the missing-field line, because silence about a gap reads as a claim that there was nothing to report.
Source links are only rendered for zillow.com pages, and the header paragraph states the data's limits in plain language, including that the packet is not an appraisal and that PropertiesAPI is not affiliated with Zillow.
"""make_packet.py: snapshot.json + notes.csv -> packet.html (print or save as PDF from the browser).
notes.csv columns: id, author, note (analyst notes, kept apart from source data)
"""
import csv, json, sys
from collections import defaultdict
from datetime import datetime, timezone
from html import escape
FIELDS = [ # (label, response field) shown for every property
("Status", "statusText"), ("Home type", "homeType"), ("Beds", "beds"), ("Baths", "baths"),
("Living area (sqft)", "livingArea"), ("Lot (sqft)", "lotSize"), ("Year built", "yearBuilt"),
("Monthly HOA", "monthlyHoaFee"), ("Annual tax", "taxAnnualAmount"),
("Zestimate (Zillow's estimate)", "zestimate"), ("Rent Zestimate (Zillow's estimate)", "rentZestimate"),
]
LISTED = {"FOR_SALE", "PENDING", "COMING_SOON", "FORECLOSURE"}
def price_label(status):
return ("Asking price" if status in LISTED else "Monthly asking rent" if status == "FOR_RENT"
else "Sale price" if status == "RECENTLY_SOLD" else "Price")
def cell(v):
if v is None:
return '<span class="na">Not returned</span>'
return escape(f"{v:,}" if isinstance(v, (int, float)) and not isinstance(v, bool) else str(v))
def section(row, notes):
d, meta = row.get("data") or {}, row.get("meta") or {}
h = [f'<section class="property"><h2>{escape(row["id"])}</h2>',
f'<p class="input">Requested: {escape(row["input"])}</p>']
if not row["ok"]:
code = (row.get("error") or {}).get("code", "unknown")
h.append(f'<p class="na">No data: lookup failed ({escape(code)}). Kept in the packet on purpose.</p>')
else:
h.append(f'<p><strong>Returned:</strong> {cell(d.get("address"))}</p>')
if meta.get("warning"):
h.append(f'<p class="warn">Check identity: {escape(meta["warning"])}</p>')
year = d.get("yearBuilt")
rows = [(price_label(d.get("homeStatus")), d.get("price"))] + [
(l, (str(year) if year is not None else None) if f == "yearBuilt" else d.get(f)) for l, f in FIELDS]
h.append("<table>" + "".join(f"<tr><th>{escape(l)}</th><td>{cell(v)}</td></tr>" for l, v in rows) + "</table>")
missing = [l for l, v in rows if v is None]
h.append(f'<p class="na">Not returned for this property: {escape(", ".join(missing)) or "none of the fields above"}.</p>')
url = d.get("detailUrl") or ""
link = f'<a href="{escape(url)}">{escape(url)}</a>' if url.startswith("https://www.zillow.com/") else "none"
h.append(f'<p class="src">Source page: {link}<br>Collected: {cell(meta.get("fetched_at"))}'
f' {"(from cache)" if meta.get("cached") else ""}</p>')
h.append("<h3>Analyst notes</h3>")
h.append("".join(f'<blockquote>{escape(n["note"])}<cite>{escape(n["author"])}</cite></blockquote>'
for n in notes) or '<p class="na">No notes yet.</p>')
return "".join(h) + "</section>"
CSS = """body{font:15px/1.45 Georgia,serif;max-width:46rem;margin:2rem auto;color:#222}
th{text-align:left;font-weight:normal;color:#555;padding-right:1rem} .na{color:#8a5a00}
.warn{color:#a40000;font-weight:bold} blockquote{border-left:3px solid #2F5D8C;margin:.5rem 0;padding-left:.75rem}
cite{display:block;font-size:.85em;color:#555} .src{font-size:.85em;word-break:break-all}
@media print{body{margin:0;max-width:none} .property{break-inside:avoid;break-before:page} a{color:inherit}}"""
if __name__ == "__main__":
snap = json.load(open(sys.argv[1], encoding="utf-8"))
notes = defaultdict(list)
with open(sys.argv[2], newline="", encoding="utf-8-sig") as f:
for n in csv.DictReader(f):
notes[n["id"]].append(n)
made = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC")
body = "".join(section(r, notes[r["id"]]) for r in snap["rows"])
page = f"""<!doctype html><html lang="en"><meta charset="utf-8"><title>Research packet</title>
<style>{CSS}</style><h1>Property research packet</h1>
<p>Prepared {made} from a snapshot exported {escape(snap.get("exported_at") or "unknown")}.
Property data is as shown on each public Zillow page when collected, may be incomplete and is not
an appraisal. Zestimates are Zillow's estimates. Photos are not reproduced; follow the source links.
Analyst notes are opinions of the named author. Not affiliated with Zillow.</p>{body}</html>"""
open("packet.html", "w", encoding="utf-8").write(page)
print(f"packet.html: {len(snap['rows'])} properties")
Print it or save it as PDF
Open packet.html in a browser and use the print dialog. The stylesheet includes a print media query that starts each property on a new page and avoids splitting a property across pages. Choosing save as PDF in the same dialog produces a file you can attach to an email or a data room.
Look at the first page before you send anything. Check that requested and returned addresses match, that warnings are visible, and that every note is one you are comfortable sharing with the recipient.
python make_packet.py snapshot.json notes.csv
# open packet.html, then print or save as PDFRespect data use and photo rights
Listing photos usually belong to the photographer or the listing brokerage. The API returns photo links, never copies, and the packet follows the same approach by linking to the source page. If you need images in a packet, get permission from whoever holds the rights.
Check that sharing the data fits the terms you work under, especially if the packet goes to a client or outside your company. The data-rights page summarizes how PropertiesAPI data may be used. Agent and broker names in a response belong to past or current listings, so leave them out of a packet unless your recipient needs them for a legitimate reason.
Keep the packet current
A packet is a snapshot. Its header records when it was prepared and when the data was exported, and each property records when its data was collected. A cached response keeps its original collection time, so the packet marks those rows as from cache.
When the packet needs refreshing, collect a new snapshot, keep notes.csv as it is and regenerate. Archive the old packet next to the new one if decisions were made from it.
| Time shown | Where it comes from | What it tells the reader |
|---|---|---|
| Prepared | When make_packet.py ran | When this document was produced |
| Snapshot exported | exported_at in the snapshot | When the collection run finished |
| Collected | meta.fetched_at per property | When the source data was read |
Sources and documentation
Product details checked against the linked documentation on October 22, 2026. Code and sample inputs are illustrative unless stated otherwise.
- PropertiesAPI property fields and meta
- Data rights and listing photos
- Python HTML escaping
- MDN: printing with CSS media queries
- MDN: break-inside
Written by PropertiesAPI, an independent property data service. We are not affiliated with Zillow. Source data may be incomplete or change over time.