Real Estate Enrichment Automation Framework
01

Real Estate Enrichment Automation Pipeline

README

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.

Which orchestrator. On this branch Claude Cowork runs 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

BranchRoleOrchestrator
Real_Estate_Auto_Enrichment_Claude_CoWorkCoWork Orchestationscripts/run_enrichment.py
Real_Estate_Auto_Enrichment_GCP_CloudRun_DeploymentCloud Run imageADK + Cloud Run Jobs
mainDefault GitHub landingPoints 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.

FunctionClaude CoWorkGCP Cloud Run ADK
Execution modelOn-demand, chat-enabled — or scheduled — orchestrationNo-touch, scheduled batch processing
Orchestratorscripts/run_enrichment.pyADK agent + Cloud Run Jobs
BranchReal_Estate_Auto_Enrichment_Claude_CoWorkReal_Estate_Auto_Enrichment_GCP_CloudRun_Deployment
How a run startsA chat command in a CoWork sessionA scheduled job, set either daily or every 15 / 5 / 1 minute
Human interactionInteractive — human-in-the-loop; inspect and adjust parameters mid-flightUnattended — zero touch once deployed
RuntimeClaude Cowork session / containerCloud Run Jobs container image
Best forAd-hoc runs, catch-up, county targeting, debugging, trial passes (--limit 10)Steady-state daily / weekly enrichment at scale
ThroughputConversational scale — one run at a time, orchestrated liveBatch scale — many jobs upserted and run on schedule
Control surfaceCLI flags: --mode, --stages, --county, --limit, --dry-runThe same CLI, wrapped in scheduled Cloud Run jobs
Pick by cadence, not capability. The two produce the same enrichment. Choose Claude CoWork when a human wants to drive, target, or debug a run on demand; choose GCP Cloud Run ADK when the work should just happen on a schedule with nobody watching.

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.

LayerWhat it doesReference
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

ModeSelectsTriggerNotes
new leadsNew records - pending enrichment - overnightScheduled — daily 03:00 ET (A1 then C)—
weekly updateCompleted non-archive leadsScheduled — Fri 19:00 ET (A1 listing + C docket)—
catch-up A1Stage A1 not completeCLI — --mode catchup --stages A1 (--limit 10 = trial)—
catch-up A2Stage A2 not completeCLI — --stages A2—
catch-up BStage B not completeCLI — --stages B—
catch-up CStage C not completeCLI — --stages C --county Medina|Lorain|Cuyahoga—
open dealhs_lead_status Open DealScheduled — every 1 min (A2 + Contact→Deal)Optional — HubSpot Starter-level subscription
LOAD re-scoreLive contacts, no FirecrawlCLI — --mode catchup --stages LOADOptional — 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 IDLabelTypeDefaultWhat it does
minimum_property_valueMinimum Property Value (A1)number175 000A1 archive gate. Listings below this archive as "Archive - Value".
stage_b_engagedStage B Engaged (Recorder)checkboxNoWeekly recorder on/off.
stage_c_engagedStage C Engaged (Probate)checkboxYesWeekly probate on/off.
stage_a1_completeStage A1 CompletecheckboxunsetPer-record. Catch-up A1 selects where this is not true.
stage_a2_completeStage A2 CompletecheckboxunsetPer-record. Catch-up A2 selects at A1_COMPLETE.
stage_b_completeStage B CompletecheckboxunsetPer-record. Catch-up B selects where A2 is done.
stage_c_completeStage C CompletecheckboxunsetPer-record. Catch-up C selects where A2 is done.

Resolution order — first hit wins

  1. Env vars: RAPIDUPLOAD_STAGE_B_ENGAGED / RAPIDUPLOAD_STAGE_C_ENGAGED / RAPIDUPLOAD_MINIMUM_PROPERTY_VALUE
  2. HubSpot card on lead_uid=PIPELINE_SETTINGS
  3. config/stage_engagement.json
  4. [engagement] / [tokens] in config/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_OPPORTUNITY while 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_ESTATE rows remapped to NOT_ARCHIVED on write
LaneMeaning
LEAD_REVIEWWorkable probate lead
EMAIL_MONITORAdversarial docket — email campaigns only
WHOLESALE_REFERWholesale referral

Where the Two Layers Meet

ContractDetail
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

PathLayerHolds
src/rapidupload/enrichmentParsers, indications, engagement lane, round trips, file routing, HubSpot write path
config/enrichmentTokens, stage order, engagement toggles, generated schema, county registry
firecrawl/agents/enrichmentOne saved agent per source: prompts, schema, validation rules
scripts/enrichmentcreate_hubspot_properties.py, schema generate/validate, field-catalog export, round trips, run_enrichment.py
tests/enrichmentParsers, runners, Firecrawl agents, HubSpot write path, adversarial monitor
docs/bothBuild plan, enrichment guide, adversarial monitor, source workbooks
↑ Back to top
02

Jobs, Cache & Schedules

Ops Guide

Canonical routing field: engagement_lane. Indication flags and hs_lead_status write during Stage C. Filter lists on engagement_lane.

Firecrawl Agent API is turned off to enforce scripted endpoint extraction, which eliminates hallucination. Every job calls the scripted endpoints (/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.

JobService IDSelectsTriggerNotes
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)

JobSelectsTriggerNotes
rapidupload-enrich-catchup-a1
trial: …-a1-trial
Not archived, no property_listing_urlS0, A1 — --mode catchup --stages A1 (--limit 10 = trial)—
rapidupload-enrich-catchup-a2
trial: …-a2-trial
Not archived, no property_auditor_url, stage_a2_complete ≠ YesA2 — --stages A2—
rapidupload-enrich-catchup-b
trial: …-b-trial
Not archived, no property_recorder_url, stage_b_complete ≠ YesB — --stages B (manual only; dormant on schedules)—
rapidupload-enrich-catchup-c-medina
trial: …-c-medina-trial
Not archived, no probate_court_url, stage_c_complete ≠ Yes — MedinaC — --stages C --county Medina—
rapidupload-enrich-catchup-c-lorain
trial: …-c-lorain-trial
Not archived, no probate_court_url, stage_c_complete ≠ Yes — LorainC — --stages C --county Lorain—
rapidupload-enrich-catchup-c-cuyahoga
trial: …-c-cuyahoga-trial
Not archived, no probate_court_url, stage_c_complete ≠ Yes — CuyahogaC — --stages C --county Cuyahoga—
rapidupload-enrich-load
trial: …-load-trial
Not archived — rewrites indication flags / lead statusLOAD re-score, no Firecrawl — --stages LOADOptional — 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

JobSelects contacts where…
A1Not archived, no property_listing_url
A2Not archived, no property_auditor_url, stage_a2_complete ≠ Yes
BNot archived, no property_recorder_url, stage_b_complete ≠ Yes
CNot archived, no probate_court_url, stage_c_complete ≠ Yes
LOADNot archived — rewrites indication flags / lead status
open-dealhs_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)

JobScriptWhen
rapidupload-validatevalidate_schema.pyManual smoke test
rapidupload-create-propertiescreate_hubspot_properties.py --object-type both --applyOnce per portal
rapidupload-import-appendimport_contact_append.py --applyAfter Drive append return
rapidupload-export-dncexport_dnc.pyManual (fallback to no-touch submit)
rapidupload-submit-dncsubmit_dnc.py --applyScheduled Wed 09:00 ET
rapidupload-import-dncimport_dnc.py --applyScheduled every 30 min

Retired (deleted on next setup.sh jobs): enrich-weekly, enrich-daily, enrich-watch, combined enrich-catchup.

↑ Back to top
03

Schema, HubSpot Properties & Deploy

DevOps

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 aliasRemote branchOrchestrator
coworkReal_Estate_Auto_Enrichment_Claude_CoWorkClaude Cowork / scripts/run_enrichment.py
gcpReal_Estate_Auto_Enrichment_GCP_CloudRun_DeploymentADK + Cloud Run Jobs
mainmainLanding 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.

  1. Pull this commit on both enrichment branches. S0 keeps sequential lead_uid; docket writes schema codes; A1 never writes CACHED.
  2. Dry-run the property create (no portal writes):
    export HUBSPOT_PRIVATE_APP_TOKEN=...   # contacts + schemas read/write
    python3 scripts/create_hubspot_properties.py
    Expect ~118 create, 5 modify (hs_lead_status, enrichment_stage, postal/zip/bar).
  3. Apply to the portal:
    python3 scripts/create_hubspot_properties.py --apply
    Creates missing A1/DNC/listing fields. Merges PENDING next to live Pending on enrichment_stage. Sets unique on lead_uid only.
  4. Confirm in HubSpot — Settings → Properties → Contact:
    • property_listing_status exists (dropdown)
    • enrichment_stage has both Pending and PENDING as values
    • lead_uid → Require unique values is ON; lead_id unique stays off
  5. Dry-run catch-up trial, then apply — one stage at a time:
    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 --apply
    GCP: gcloud run jobs execute rapidupload-enrich-catchup-a1 --region="$REGION" --wait — only after the image includes this commit. Stage D/E are not catch-up.
Do not paste repo Apps Script until step 3 is done (repo writes 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

  1. Creates missing property groups
  2. Creates every schema row with status NEW (409 = already exists → skip)
  3. For an existing enumeration, PATCHes options and merges live portal values so ingest labels (Pending) and Python codes (PENDING) both write
  4. PATCHes enrichment_stage and hs_lead_status option sets (status MODIFY)
  5. Repairs hasUniqueValue on lead_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 .gs files from michaelbardy/RapidUpload manually

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.

FieldManual action
property_postalRetype number → single-line text. If refused (data already stored), leave unformatted number and strip ZIP+4 before write.
attorney_zipSame as postal.
attorney_bar_noSame. After retype, drop t:'number' from Apps Script FIELD_MAP.
lead_uidRequire unique values. Confirm if the create-script PATCH fails.
lead_idLeave unique values OFF. HubSpot will not enable (historical blanks/duplicates). That is why lead_uid exists.
enrichment_stageAdd Python codes PENDING, A1_COMPLETE, … or run --apply (merges existing labels).
hs_lead_statusPoint 4 set only. Do not add PROBABLE_LISTING_INDICATION, ULTRA_HIGH_PROBABILITY, EMAIL_MONITOR, ARCHIVE_ADVERSARIAL. Do not write IN_PROGRESS.
engagement_laneCreate if missing: LEAD_REVIEW, EMAIL_MONITOR, WHOLESALE_REFER. Do not add ULTRA_HIGH.
archive_reasonCreate if missing. Do not add ADVERSARIAL_ESTATE or LISTING_CLOSED.
Target Prospects viewFilter: 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)

PropertyRetired / invalid values
engagement_laneULTRA_HIGH
hs_lead_statusPROBABLE_LISTING_INDICATION, ULTRA_HIGH_PROBABILITY, EMAIL_MONITOR
archive_reasonADVERSARIAL_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.

↑ Back to top