Real Estate Enrichment Automation Framework
Combined reference — README · Ops Guide · DevOps
Weekly probate filings land in Drive; what comes out the other end is a HubSpot CRM where dead leads are archived, live leads enriched from public records, and every record carries a routable contact channel.
Overview
This repository is the enrichment pipeline (Claude Cowork + GCP Cloud Run). The Rapid Upload - File Drop - Ingest (Google Apps Script) lives in its own repository, michaelbardy/RapidUpload. See the connector's README and DEPLOY.md for its setup.
scripts/run_enrichment.py. The same pipeline on Real_Estate_Auto_Enrichment_GCP_CloudRun_Deployment uses the ADK agent and Cloud Run Jobs. Parsers, schema, indications, round trips and the adversarial-monitor lane are identical; only the layer that calls them differs.
Branches
| Branch | Role | Orchestrator |
|---|---|---|
Real_Estate_Auto_Enrichment_Claude_CoWork | CoWork Orchestation | scripts/run_enrichment.py |
Real_Estate_Auto_Enrichment_GCP_CloudRun_Deployment | Cloud Run image | ADK + Cloud Run Jobs |
main | Default GitHub landing | Points to the two live branches |
Orchestrators — Claude CoWork vs GCP Cloud Run ADK
Both branches run the identical pipeline — the same parsers, schema, indications, round trips and adversarial-monitor lane. What differs is how a run starts and who is at the keyboard. In short: GCP Cloud Run ADK is no-touch, scheduled batch processing; Claude CoWork is on-demand, chat-enabled — or scheduled — orchestration.
| Function | Claude CoWork | GCP Cloud Run ADK |
|---|---|---|
| Execution model | On-demand, chat-enabled — or scheduled — orchestration | No-touch, scheduled batch processing |
| Orchestrator | scripts/run_enrichment.py | ADK agent + Cloud Run Jobs |
| Branch | Real_Estate_Auto_Enrichment_Claude_CoWork | Real_Estate_Auto_Enrichment_GCP_CloudRun_Deployment |
| How a run starts | A chat command in a CoWork session | A scheduled job, set either daily or every 15 / 5 / 1 minute |
| Human interaction | Interactive — human-in-the-loop; inspect and adjust parameters mid-flight | Unattended — zero touch once deployed |
| Runtime | Claude Cowork session / container | Cloud Run Jobs container image |
| Best for | Ad-hoc runs, catch-up, county targeting, debugging, trial passes (--limit 10) | Steady-state daily / weekly enrichment at scale |
| Throughput | Conversational scale — one run at a time, orchestrated live | Batch scale — many jobs upserted and run on schedule |
| Control surface | CLI flags: --mode, --stages, --county, --limit, --dry-run | The same CLI, wrapped in scheduled Cloud Run jobs |
The Two Layers
Two deployments that meet at three well-defined contracts and nowhere else. Each stands alone — the connector runs with no enrichment layer; the pipeline runs against any HubSpot portal populated by any means.
| Layer | What it does | Reference |
|---|---|---|
| Rapid Upload - File Drop - Ingest | Watches Drive File Drop, validates 46-column vendor contract, mints identifiers, dedupes, appends to master sheet, batch-loads HubSpot | michaelbardy/RapidUpload |
| Enrichment pipeline | Filters and augments from listing sites, county auditors, probate dockets; writes indication flags; runs DNC and contact-append round trips | docs/enrichment-pipeline.md |
weekly .xlsx dropped in File Drop
│
├─ RAPID UPLOAD - FILE DROP - INGEST ────────────────────
│ header contract checked · rows validated · id minted (UID on new rows)
│ appended to Warren Group Master · batch upserted to HubSpot
│
└─ ENRICHMENT PIPELINE ──────────────────────────────────
S0 reconcile ── adopt lead_uid (sequential or UID), drop duplicates, snapshot
A1 listing ──── Firecrawl /search: Zillow → Realtor.com → Redfin
A2 auditor ──── Firecrawl /interact, parcel first, family name second
B recorder ─── Firecrawl /interact, recorded/released liens and TOD
(weekly default OFF — stage_b_engaged)
C probate ──── Firecrawl /interact then /extract (parties, costs, docket);
writes indication flags and lead status on the same upsert
A2 on Open Deal auditor + copy Contact → Deal
│
└─ Stage D contact enrichment · Stage E DNC review (Drive, not enrichment)
└─ Friday 7pm ET: --mode update (A1 listing + C docket)
└─ Daily 3am ET: new leads A1 then C
The master sheet is the ledger, not a staging area — rows land there before HubSpot is touched, making every load failure recoverable. Stage A1 is the cheapest filter and runs first; everything after sees only survivors.
Operating Modes
| Mode | Selects | Trigger | Notes |
|---|---|---|---|
| new leads | New records - pending enrichment - overnight | Scheduled — daily 03:00 ET (A1 then C) | — |
| weekly update | Completed non-archive leads | Scheduled — Fri 19:00 ET (A1 listing + C docket) | — |
| catch-up A1 | Stage A1 not complete | CLI — --mode catchup --stages A1 (--limit 10 = trial) | — |
| catch-up A2 | Stage A2 not complete | CLI — --stages A2 | — |
| catch-up B | Stage B not complete | CLI — --stages B | — |
| catch-up C | Stage C not complete | CLI — --stages C --county Medina|Lorain|Cuyahoga | — |
| open deal | hs_lead_status Open Deal | Scheduled — every 1 min (A2 + Contact→Deal) | Optional — HubSpot Starter-level subscription |
| LOAD re-score | Live contacts, no Firecrawl | CLI — --mode catchup --stages LOAD | Optional — HubSpot Starter-level subscription |
Stage Engagement Toggles
New-lead jobs run A1 then C. Stage B (recorder) is dormant on schedules. The A1 value gate is a number on the Stage Engagement card. Catch-up jobs ignore the B/C toggles and always run the named stage.
Card: uid=rapidupload-stage-engagement · title Stage Engagement · location crm.record.sidebar · objects Contacts and Deals. Pin the same card on the control contact lead_uid=PIPELINE_SETTINGS.
| Field ID | Label | Type | Default | What it does |
|---|---|---|---|---|
minimum_property_value | Minimum Property Value (A1) | number | 175 000 | A1 archive gate. Listings below this archive as "Archive - Value". |
stage_b_engaged | Stage B Engaged (Recorder) | checkbox | No | Weekly recorder on/off. |
stage_c_engaged | Stage C Engaged (Probate) | checkbox | Yes | Weekly probate on/off. |
stage_a1_complete | Stage A1 Complete | checkbox | unset | Per-record. Catch-up A1 selects where this is not true. |
stage_a2_complete | Stage A2 Complete | checkbox | unset | Per-record. Catch-up A2 selects at A1_COMPLETE. |
stage_b_complete | Stage B Complete | checkbox | unset | Per-record. Catch-up B selects where A2 is done. |
stage_c_complete | Stage C Complete | checkbox | unset | Per-record. Catch-up C selects where A2 is done. |
Resolution order — first hit wins
- Env vars:
RAPIDUPLOAD_STAGE_B_ENGAGED/RAPIDUPLOAD_STAGE_C_ENGAGED/RAPIDUPLOAD_MINIMUM_PROPERTY_VALUE - HubSpot card on
lead_uid=PIPELINE_SETTINGS config/stage_engagement.json[engagement]/[tokens]inconfig/pipeline.toml
python3 scripts/create_hubspot_properties.py --object-type both --apply
Adversarial Dockets — Soft Hold
A will contest, exceptions, land sale or removal of fiduciary used to archive the record. They now stay live:
engagement_lane=EMAIL_MONITOR+hs_lead_status=FUTURE_OPPORTUNITYwhile contested — email campaigns only, off the lead-review queue- Listing status and the probate docket keep being re-checked (
--mode update) - When the docket returns to an estate case:
engagement_lane=LEAD_REVIEW+hs_lead_status=PRIORITY_PROSPECT - Closed listings hard-archive (
archive_reason=SOLD,hs_lead_status=ARCHIVE_SOLD) - Legacy
archive_reason=ADVERSARIAL_ESTATErows remapped toNOT_ARCHIVEDon write
| Lane | Meaning |
|---|---|
LEAD_REVIEW | Workable probate lead |
EMAIL_MONITOR | Adversarial docket — email campaigns only |
WHOLESALE_REFER | Wholesale referral |
Where the Two Layers Meet
| Contract | Detail |
|---|---|
| File Drop folder | Every inbound file arrives here. Only the weekly batch starts enrichment; a return updates records that already went through the stages. Enforced in one place: filedrop.plan(). |
| Identifier | Ingest dual-writes to lead_id and lead_uid. New rows mint a UID (ULID). HubSpot upsert key is lead_uid. lead_id unique stays OFF. S0 adopts whatever is already on the record and only mints when both are empty. |
| HubSpot contact | Both layers write through the Batch API. Connector owns the 46 inbound columns; pipeline owns enrichment, indication and validation properties. Connector writes enrichment_stage on first load only — a retry cannot rewind a record the stages have advanced. It also does not overwrite engagement_lane, docket_status, or adversarial-ever fields on retry. |
Quick Start
pip install -r requirements-dev.txt
python3 -m pytest # no network, no credentials
python3 scripts/validate_schema.py # schema, agents, counties and code agree
python3 scripts/create_hubspot_properties.py # dry-run HubSpot property plan
python3 scripts/run_enrichment.py --mode weekly --dry-run
python3 scripts/run_enrichment.py --mode catchup --stages A1 --limit 10 --oldest --dry-run
python3 scripts/run_enrichment.py --mode catchup --stages A2 --oldest --dry-run
python3 scripts/run_enrichment.py --mode catchup --stages B --oldest --dry-run
python3 scripts/run_enrichment.py --mode catchup --stages C --oldest --dry-run
python3 scripts/run_enrichment.py --mode catchup --stages C --county Medina --limit 10 --oldest --dry-run
python3 scripts/run_enrichment.py --mode update --dry-run
Repo Layout
| Path | Layer | Holds |
|---|---|---|
src/rapidupload/ | enrichment | Parsers, indications, engagement lane, round trips, file routing, HubSpot write path |
config/ | enrichment | Tokens, stage order, engagement toggles, generated schema, county registry |
firecrawl/agents/ | enrichment | One saved agent per source: prompts, schema, validation rules |
scripts/ | enrichment | create_hubspot_properties.py, schema generate/validate, field-catalog export, round trips, run_enrichment.py |
tests/ | enrichment | Parsers, runners, Firecrawl agents, HubSpot write path, adversarial monitor |
docs/ | both | Build plan, enrichment guide, adversarial monitor, source workbooks |
Canonical routing field: engagement_lane. Indication flags and hs_lead_status write during Stage C. Filter lists on engagement_lane.
/search, /scrape · /interact, /extract) directly rather than delegating to the agent. --mode update is Friday listing + docket only; Stage B is dormant on scheduled jobs; --stages LOAD re-scores CRM flags without Firecrawl.
Scheduled Jobs
Timezone: America/New_York. ./deploy/setup.sh image rebuilds the image and upserts every job. ./deploy/setup.sh schedules creates/updates Scheduler. Turn off Apps Script ENRICHMENT_TRIGGER_URL — 3am Scheduler owns new leads.
| Job | Service ID | Selects | Trigger | Notes |
|---|---|---|---|---|
| New-Lead Enrichment | rapidupload-enrich-new-leads |
New-lead HubSpot upserts (PENDING) — S0 + A1 then C | Daily 03:00 ET — --mode weekly --stages A1,C --apply |
— |
| Weekly Update | rapidupload-enrich-update |
Completed non-archive leads — A1 listing + C docket | Fri 19:00 ET — --mode update --apply |
— |
| File-Drop Poll | rapidupload-poll-file-drop |
DNC / append returns in File Drop — does not start enrichment | Every 15 min — process_file_drop.py |
— |
| Weekly Append Export | rapidupload-weekly-append-export |
Contacts due for contact-append export | Tue 09:00 ET — export_contact_append.py |
— |
| Weekly DNC Submit | rapidupload-weekly-dnc-submit |
Numbers pending DNC submission — no-op if RAPIDUPLOAD_FREEDNC_ENDPOINT unset |
Wed 09:00 ET — submit_dnc.py --apply |
— |
| DNC Return Poll | rapidupload-dnc-return-poll |
freednclist returns waiting to apply — exits 0 if none | Every 30 min — import_dnc.py --apply |
— |
| Open-Deal Enrichment | rapidupload-enrich-open-deal |
Contacts at hs_lead_status Open Deal — A2 + copy Contact → Deal |
Every 1 min — --mode open-deal --apply |
Optional — HubSpot Starter-level subscription |
Catch-up Jobs (Manual)
| Job | Selects | Trigger | Notes |
|---|---|---|---|
rapidupload-enrich-catchup-a1trial: …-a1-trial | Not archived, no property_listing_url | S0, A1 — --mode catchup --stages A1 (--limit 10 = trial) | — |
rapidupload-enrich-catchup-a2trial: …-a2-trial | Not archived, no property_auditor_url, stage_a2_complete ≠ Yes | A2 — --stages A2 | — |
rapidupload-enrich-catchup-btrial: …-b-trial | Not archived, no property_recorder_url, stage_b_complete ≠ Yes | B — --stages B (manual only; dormant on schedules) | — |
rapidupload-enrich-catchup-c-medinatrial: …-c-medina-trial | Not archived, no probate_court_url, stage_c_complete ≠ Yes — Medina | C — --stages C --county Medina | — |
rapidupload-enrich-catchup-c-loraintrial: …-c-lorain-trial | Not archived, no probate_court_url, stage_c_complete ≠ Yes — Lorain | C — --stages C --county Lorain | — |
rapidupload-enrich-catchup-c-cuyahogatrial: …-c-cuyahoga-trial | Not archived, no probate_court_url, stage_c_complete ≠ Yes — Cuyahoga | C — --stages C --county Cuyahoga | — |
rapidupload-enrich-loadtrial: …-load-trial | Not archived — rewrites indication flags / lead status | LOAD re-score, no Firecrawl — --stages LOAD | Optional — HubSpot Starter-level subscription |
gcloud run jobs execute rapidupload-enrich-catchup-a1-trial \
--region="$REGION" --project="$PROJECT_ID" --async
gcloud run jobs execute rapidupload-enrich-catchup-a1 \
--region="$REGION" --project="$PROJECT_ID" --async
Common manual commands
# Manual Friday update
gcloud run jobs execute rapidupload-enrich-update --region="$REGION" --wait
# LOAD re-score after a CRM-logic change
gcloud run jobs execute rapidupload-enrich-load --region="$REGION" --async
python3 scripts/run_enrichment.py --mode catchup --stages LOAD --apply
# Prove file-drop does not trigger enrichment
gcloud storage cp dnc-return.csv "gs://${BUCKET}/file_drop/"
gcloud run jobs execute rapidupload-file-drop --region="$REGION" --wait
Catch-up Selection Criteria
| Job | Selects contacts where… |
|---|---|
| A1 | Not archived, no property_listing_url |
| A2 | Not archived, no property_auditor_url, stage_a2_complete ≠ Yes |
| B | Not archived, no property_recorder_url, stage_b_complete ≠ Yes |
| C | Not archived, no probate_court_url, stage_c_complete ≠ Yes |
| LOAD | Not archived — rewrites indication flags / lead status |
| open-deal | hs_lead_status is Open Deal, newest last-modified first |
Failed /scrape, /interact, /extract dump under gs://$BUCKET/failures/.
Cache (A1 URL + C docket pages)
First catch-up writes property_listing_url and probate case page URLs. --mode update reads those cached URLs first. A miss falls through to a live Firecrawl call.
Upsert / Export Jobs (keep)
| Job | Script | When |
|---|---|---|
rapidupload-validate | validate_schema.py | Manual smoke test |
rapidupload-create-properties | create_hubspot_properties.py --object-type both --apply | Once per portal |
rapidupload-import-append | import_contact_append.py --apply | After Drive append return |
rapidupload-export-dnc | export_dnc.py | Manual (fallback to no-touch submit) |
rapidupload-submit-dnc | submit_dnc.py --apply | Scheduled Wed 09:00 ET |
rapidupload-import-dnc | import_dnc.py --apply | Scheduled every 30 min |
Retired (deleted on next setup.sh jobs): enrich-weekly, enrich-daily, enrich-watch, combined enrich-catchup.
Operator reference for HubSpot property setup, schema regeneration, and the two enrichment branches. Catch-up / cache / schedule runbook is in the Ops Guide above.
Branches
| Local alias | Remote branch | Orchestrator |
|---|---|---|
cowork | Real_Estate_Auto_Enrichment_Claude_CoWork | Claude Cowork / scripts/run_enrichment.py |
gcp | Real_Estate_Auto_Enrichment_GCP_CloudRun_Deployment | ADK + Cloud Run Jobs |
main | main | Landing page pointing to the two live branches |
Parsers, schema, indications and the HubSpot write path are identical on both live branches. Only the layer that calls them differs. The Rapid Upload - File Drop - Ingest lives in michaelbardy/RapidUpload.
Code 1 (Cloud Run exit 1) — First Live Run
HubSpot 400 on the first enrichment upsert. Complete these in order. Do not start catch-up until step 4 is green.
-
Pull this commit on both enrichment branches. S0 keeps sequential
lead_uid; docket writes schema codes; A1 never writesCACHED. -
Dry-run the property create (no portal writes):
Expect ~118 create, 5 modify (export HUBSPOT_PRIVATE_APP_TOKEN=... # contacts + schemas read/write python3 scripts/create_hubspot_properties.pyhs_lead_status,enrichment_stage, postal/zip/bar). -
Apply to the portal:
Creates missing A1/DNC/listing fields. Mergespython3 scripts/create_hubspot_properties.py --applyPENDINGnext to livePendingonenrichment_stage. Sets unique onlead_uidonly. -
Confirm in HubSpot — Settings → Properties → Contact:
property_listing_statusexists (dropdown)enrichment_stagehas bothPendingandPENDINGas valueslead_uid→ Require unique values is ON;lead_idunique stays off
-
Dry-run catch-up trial, then apply — one stage at a time:
GCP:python3 scripts/run_enrichment.py --mode catchup --stages A1 --limit 10 --oldest --dry-run python3 scripts/run_enrichment.py --mode catchup --stages A1 --limit 10 --oldest --apply python3 scripts/run_enrichment.py --mode catchup --stages A2 --oldest --apply python3 scripts/run_enrichment.py --mode catchup --stages B --oldest --apply python3 scripts/run_enrichment.py --mode catchup --stages C --oldest --applygcloud run jobs execute rapidupload-enrich-catchup-a1 --region="$REGION" --wait— only after the image includes this commit. Stage D/E are not catch-up.
PENDING). Live ingest can keep writing Pending; after --apply, both values are valid portal options.
HubSpot Properties
scripts/create_hubspot_properties.py is the only automated writer of RapidUpload contact properties. It is on both enrichment branches. Dry run by default; nothing hits the portal until --apply.
export HUBSPOT_PRIVATE_APP_TOKEN=... # crm.objects.contacts.read/write
# crm.schemas.contacts.read/write
python3 scripts/create_hubspot_properties.py # plan (~118 create, 5 modify)
python3 scripts/create_hubspot_properties.py --apply # write
What --apply does
- Creates missing property groups
- Creates every schema row with status
NEW(409 = already exists → skip) - For an existing enumeration, PATCHes options and merges live portal values so ingest labels (
Pending) and Python codes (PENDING) both write - PATCHes
enrichment_stageandhs_lead_statusoption sets (statusMODIFY) - Repairs
hasUniqueValueonlead_uid
What it does not do (portal only)
- Change a property's type (number → text)
- Guarantee the unique-values flag if HubSpot rejects the PATCH
- Build saved views or lists
- Deploy Apps Script — paste
.gsfiles frommichaelbardy/RapidUploadmanually
Schema Source of Truth
docs/reference/hubspot-field-schema-gap-analysis.xlsx
│ python3 scripts/generate_schema_from_workbook.py
▼
config/schema/hubspot_properties.json ← never hand-edit
config/schema/inbound_column_map.json
│ python3 scripts/validate_schema.py
│ python3 scripts/export_hubspot_field_catalog.py
▼
docs/reference/hubspot-field-catalog.xlsx ← marketing / onboarding download
Edit the New Fields Required sheet in the workbook, regenerate, validate, export the catalog, and commit workbook + JSON + catalog together.
Portal Exceptions (Do by Hand)
HubSpot: Settings → Data Management → Properties → Contact properties.
| Field | Manual action |
|---|---|
property_postal | Retype number → single-line text. If refused (data already stored), leave unformatted number and strip ZIP+4 before write. |
attorney_zip | Same as postal. |
attorney_bar_no | Same. After retype, drop t:'number' from Apps Script FIELD_MAP. |
lead_uid | Require unique values. Confirm if the create-script PATCH fails. |
lead_id | Leave unique values OFF. HubSpot will not enable (historical blanks/duplicates). That is why lead_uid exists. |
enrichment_stage | Add Python codes PENDING, A1_COMPLETE, … or run --apply (merges existing labels). |
hs_lead_status | Point 4 set only. Do not add PROBABLE_LISTING_INDICATION, ULTRA_HIGH_PROBABILITY, EMAIL_MONITOR, ARCHIVE_ADVERSARIAL. Do not write IN_PROGRESS. |
engagement_lane | Create if missing: LEAD_REVIEW, EMAIL_MONITOR, WHOLESALE_REFER. Do not add ULTRA_HIGH. |
archive_reason | Create if missing. Do not add ADVERSARIAL_ESTATE or LISTING_CLOSED. |
| Target Prospects view | Filter: High Probable Listing Flag is true. Columns: High Probable Listing Flag, Out Of State Beneficiary, Additional Parties Flag, Estate Recovery/Medicaid Flag, Tax Delinquent, Letters Issued, Administrative Deed Recorded, Listing Status, Adversarial Flag, Property Market Value, Lead Status, Engagement Lane, Lead UID. |
Do not write these values (400 on the live portal)
| Property | Retired / invalid values |
|---|---|
engagement_lane | ULTRA_HIGH |
hs_lead_status | PROBABLE_LISTING_INDICATION, ULTRA_HIGH_PROBABILITY, EMAIL_MONITOR |
archive_reason | ADVERSARIAL_ESTATE, LISTING_CLOSED |
Pipeline remaps: adversarial → NOT_ARCHIVED + EMAIL_MONITOR; closed listing → SOLD / ARCHIVE_SOLD; high-probability / resolved adversarial → LEAD_REVIEW + PRIORITY_PROSPECT.
Field Catalog & GCP Deploy
docs/reference/hubspot-field-catalog.xlsx — Cover, Field Catalog (130 contact properties), Dropdowns, Portal Exceptions.
python3 scripts/export_hubspot_field_catalog.py
For GCP step-by-step deployment: docs/gcp-deployment.md on the GCP branch. Cloud Run jobs and schedules are in the Ops Guide above.