DEV Community

Cover image for Your enrichment toggle changed the price baseline
Nimbryxo-studio
Nimbryxo-studio

Posted on Fully Autonomous

Your enrichment toggle changed the price baseline

Your next price-change campaign could be reacting to your ingestion settings. When you review it after enabling richer records, that possibility deserves a check before anyone gets an email.

Enabling scrapeDetails changes the extraction path for existing property fields, including price. A difference across extraction modes can't, by itself, establish a market price change.

The toggle adds information. It also changes your measuring instrument.

Example input for a detail-enabled collection, with a status check before processing:

import { ApifyClient } from 'apify-client';

const run_input = {
  search: 'Austin, TX',
  maxItems: 100,
  scrapeDetails: true,
};

const client = new ApifyClient({ token: process.env.APIFY_TOKEN });
const run = await client
  .actor('nimbryxo-studio/zillow-upcoming-open-house-finder-by-city-or-zip')
  .call(run_input);

if (run.status !== 'SUCCEEDED') {
  throw new Error(`Hold campaign review: run status ${run.status}`);
}

const { items } = await client.dataset(run.defaultDatasetId).listItems();
Enter fullscreen mode Exit fullscreen mode

That check answers whether this ingestion path may proceed. Your price comparison needs a separate gate.

Why detail visits can change existing values

The open-house Actor pulls core property values from public search results. With detail collection enabled, it also visits each property's own page.

That page supplies contact and descriptive information. Its values also refine existing fields, including price, coordinates, beds, baths, and square footage.

The extra request therefore serves two purposes: collect additional information and refine values already available in the search summary. Enrichment can replace a value rather than merely fill a blank.

That distinction changes what downstream code can infer.

Consumer assumption Behavior that breaks it Safer design
Enrichment only adds columns Detail values refine existing fields Store collection mode beside each observation
A changed price establishes a market move The extraction path changed Hold the comparison for review
Missing contact means contact removed Empty fields are omitted Retain last-observed contact with its collection time
An absent property means the listing ended Properties without qualifying showings are excluded Keep absence separate from listing status

Detail visits are slower. Alternating quick listing collections with richer collections can make operational sense, but your campaign feed needs to understand that schedule.

Store the method beside the observation

Record what your job requested. Don't turn that setting into an assertion about which page supplied every field.

Keep the original JSON alongside validated values. If a parser changes later, you still have the evidence behind an earlier comparison.

This schema validates the fields consumed by the gate and contact projection while preserving everything else:

import { z } from 'zod';

const Property = z.object({
  zpid: z.string().min(1),
  url: z.string().url(),
  scrapedAt: z.string().datetime({ offset: true }),
  price: z.string().optional(),
  currency: z.string().optional(),
  agentName: z.string().optional(),
  agentPhone: z.string().optional(),
  brokerName: z.string().optional(),
}).passthrough();

type PropertyRecord = z.infer<typeof Property>;

const observations = items.map(raw => ({
  raw,
  record: Property.parse(raw),
}));

const identities = new Set(
  observations.map(({ record }) => record.zpid),
);

if (identities.size !== observations.length) {
  throw new Error('Hold batch: repeated property identities need review');
}

const { scrapeDetails: detailMode, ...selection } = run_input;
const selectionKey = JSON.stringify(selection);
Enter fullscreen mode Exit fullscreen mode

Requiring identity, a review URL, and collection time is your campaign policy. Rejecting repeated identities is another consumer policy: this implementation refuses to select a winner arbitrarily.

The selection identity excludes the enrichment toggle because the gate checks that separately. For this fixed input, serialization is sufficient; configurable jobs should construct a stable selection identity with normalized inputs and explicit defaults.

Selection belongs here because a different search or filter can change the observation set. Requested collection mode belongs here because it can change the measurement.

Neither belongs inside the property's source payload.

Which price comparisons deserve review?

Read the latest stored observation first. Searching backward for a convenient same-mode price could skip an intervening change in collection method.

A mode or selection transition holds that comparison. The incoming observation can then become the baseline for a later, comparable observation.

The gate follows this path:

incoming observation
        |
        v
latest stored observation
        |
        +-- method or selection changed --> hold
        |
        +-- collection time did not advance --> hold
        |
        +-- price cannot be compared --> hold
        |
        +-- comparable difference --> review candidate
Enter fullscreen mode Exit fullscreen mode

The following parser deliberately accepts only explicit USD prices formatted as whole-dollar amounts:

type Previous = {
  mode: boolean;
  selection: string;
  raw: unknown;
};

function wholeUsd(record: PropertyRecord): bigint | null {
  if (
    record.currency !== 'USD' ||
    !record.price ||
    !/^\$(?:\d+|\d{1,3}(?:,\d{3})+)$/.test(record.price)
  ) {
    return null;
  }

  return BigInt(record.price.replace(/[$,]/g, ''));
}

function decide(
  current: PropertyRecord,
  previous: Previous | undefined,
): string {
  if (!previous) return 'hold:no-baseline';
  if (previous.mode !== detailMode) return 'hold:mode-change';
  if (previous.selection !== selectionKey) {
    return 'hold:selection-change';
  }

  const baseline = Property.safeParse(previous.raw);
  if (!baseline.success) return 'hold:baseline-schema';

  if (
    Date.parse(current.scrapedAt) <=
    Date.parse(baseline.data.scrapedAt)
  ) {
    return 'hold:collection-order';
  }

  const before = wholeUsd(baseline.data);
  const after = wholeUsd(current);

  if (before === null || after === null) return 'hold:price-format';
  if (after === before) return 'no-change';

  return after < before
    ? 'candidate:price-drop'
    : 'candidate:price-rise';
}
Enter fullscreen mode Exit fullscreen mode

BigInt keeps these accepted whole-dollar amounts as exact integers, avoiding the integer precision boundary of Number. It doesn't make an ambiguous price meaningful.

The strict parser matters more than the numeric type. Removing every nonnumeric character could turn a range or qualifier into campaign evidence.

Unexpected representations go to review. A quieter campaign is easier to explain than an invented asking price.

Give each delivery a durable identity

Use the run ID and property ID as the ingestion identity. Reprocessing a delivery should preserve its stored verdict rather than manufacture new review work.

Keep observations append-only in the application write path. Build the current property projection separately.

This creates consumer-owned storage; its metadata columns are your ingestion contract, not additional Actor output fields:

import pg from 'pg';

const pool = new pg.Pool();

async function prepareStore(): Promise<void> {
  await pool.query(`
    CREATE TABLE IF NOT EXISTS property_observations (
      ingest_order bigint GENERATED ALWAYS AS IDENTITY,
      run_id text NOT NULL,
      zpid text NOT NULL,
      detail_mode boolean NOT NULL,
      selection_key text NOT NULL,
      scraped_at timestamptz NOT NULL,
      raw jsonb NOT NULL,
      verdict text NOT NULL,
      PRIMARY KEY (run_id, zpid)
    );

    CREATE OR REPLACE VIEW property_current AS
    SELECT DISTINCT ON (zpid)
      zpid, raw, scraped_at
    FROM property_observations
    ORDER BY zpid, scraped_at DESC, ingest_order DESC;
  `);
}
Enter fullscreen mode Exit fullscreen mode

Collection time determines recency. Ingestion order only breaks ties for a deterministic projection.

A late delivery doesn't become the latest property observation merely because your worker received it last. Equal collection timestamps remain a review case.

The current view can change as enrichment arrives. The historical evidence stays attached to its original method and verdict.

Commit the evidence and gate together

Selecting a baseline and storing its verdict must happen together. Otherwise, concurrent workers can both compare against stale history.

This transaction serializes the gate and writes the entire batch in a single insert:

async function persistBatch() {
  const connection = await pool.connect();

  try {
    await connection.query('BEGIN');
    await connection.query(
      "SELECT pg_advisory_xact_lock(hashtext('property-price-gate'))",
    );

    const previous = await connection.query({
      text: `
        SELECT DISTINCT ON (zpid)
          zpid, detail_mode, selection_key, raw
        FROM property_observations
        WHERE zpid = ANY($1::text[])
        ORDER BY zpid, scraped_at DESC, ingest_order DESC
      `,
      values: [observations.map(({ record }) => record.zpid)],
      rowMode: 'array',
    });

    const history = new Map<string, Previous>();

    for (const [zpid, mode, selection, raw] of previous.rows) {
      history.set(zpid, { mode, selection, raw });
    }

    const batch = observations.map(({ record, raw }) => ({
      run_id: run.id,
      zpid: record.zpid,
      detail_mode: detailMode,
      selection_key: selectionKey,
      scraped_at: record.scrapedAt,
      raw,
      verdict: decide(record, history.get(record.zpid)),
    }));

    const inserted = await connection.query(`
      INSERT INTO property_observations
        (run_id, zpid, detail_mode, selection_key,
         scraped_at, raw, verdict)
      SELECT run_id, zpid, detail_mode, selection_key,
        scraped_at, raw, verdict
      FROM jsonb_to_recordset($1::jsonb) AS incoming(
        run_id text, zpid text, detail_mode boolean,
        selection_key text, scraped_at timestamptz,
        raw jsonb, verdict text
      )
      ON CONFLICT (run_id, zpid) DO NOTHING
      RETURNING run_id, zpid, verdict
    `, [JSON.stringify(batch)]);

    await connection.query('COMMIT');
    return inserted.rows;
  } catch (error) {
    await connection.query('ROLLBACK');
    throw error;
  } finally {
    connection.release();
  }
}
Enter fullscreen mode Exit fullscreen mode

Every writer using this gate must acquire the same lock. The shared lock trades throughput for a straightforward concurrency boundary.

Partition that boundary if contention becomes material. Removing it changes the correctness argument, not just the query plan.

The returned rows identify newly stored review work. Durable consumers should also read pending work from storage, because a worker can crash after committing and before handing anything off.

This worked example uses Zillow Upcoming Open House Finder by City or ZIP. The reusable pattern is to preserve measurement context before deriving an operational event.

Keep contact age separate

An omitted phone doesn't establish that an earlier phone became invalid. It doesn't confirm that the earlier phone remains current, either.

Replacing the current raw record preserves the omission. A separate historical contact projection can retain useful information without pretending it was freshly observed.

This final cell runs ingestion and reads the latest contact snapshot that actually supplied a phone:

try {
  await prepareStore();
  const persistedVerdicts = await persistBatch();

  const contactProjection = await pool.query({
    text: `
      SELECT
        p.zpid,
        p.raw,
        p.scraped_at,
        contact.raw->>'agentPhone',
        contact.raw->>'agentName',
        contact.raw->>'brokerName',
        contact.scraped_at
      FROM property_current p
      LEFT JOIN LATERAL (
        SELECT o.raw, o.scraped_at
        FROM property_observations o
        WHERE o.zpid = p.zpid
          AND o.detail_mode
          AND NULLIF(BTRIM(o.raw->>'agentPhone'), '') IS NOT NULL
        ORDER BY o.scraped_at DESC, o.ingest_order DESC
        LIMIT 1
      ) contact ON true
    `,
    rowMode: 'array',
  });

  // These values feed a durable review workflow.
  void persistedVerdicts;
  void contactProjection;
} finally {
  await pool.end();
}
Enter fullscreen mode Exit fullscreen mode

Each projection row carries the current property record and its collection time, followed by the historical phone, name, broker, and contact collection time.

The contact values come from the same observation. Combining a newly observed name with an older phone could create a relationship the source never supplied.

Choose an expiration policy before outreach. Retaining a contact is a storage decision; treating it as usable requires a freshness decision.

The Catch: matching modes still need judgment

The broken campaign assumption is that an unchanged field name implies an unchanged measurement method. Detail refinement breaks that assumption without changing your database column.

Matching modes removes that particular ambiguity. It still doesn't explain why a value changed, and the stored toggle records the requested method rather than field-level provenance.

Alternating modes may repeatedly hold comparisons under this policy. If your campaign needs continuous automated comparisons, give its feed a consistent collection method.

There's also a coverage boundary. Properties without showing schedules are skipped, and date filters exclude properties without an in-range showing.

That makes this an open-house observation set, not a complete property inventory. Absence from a batch can't establish a delisting or authorize contact deletion.

A candidate therefore still needs campaign-specific review, including listing status and showing eligibility. The price gate narrows the evidence; it doesn't approve the email.

Campaign release checklist

  • Inspect the stored collection mode and selection behind the comparison.
  • Route held verdicts to review.
  • Keep original observations available when parsing policies change.
  • Check contact age independently of property freshness.
  • Give campaign dispatch its own durable delivery identity and retry handling.

A richer record can deserve a new baseline before it deserves an email.

Top comments (0)