AiSalonHub eliminates stale NAP data across 100+ salon listings using a serverless cron worker that audits every listing daily, flags discrepancies, and auto-updates D1 records — all without a single manual check.

The Challenge

Local SEO lives and dies on data freshness. A single outdated phone number, a holiday-hour gap, or a closed-on-Monday that still reads "Open 7 days" can tank a listing's local pack visibility faster than any algorithm update. For AiSalonHub, which aggregates and compares 100+ salon software products across services, pricing, and user reviews, the data surface area is enormous.

Every salon listing contains multiple fields that go stale:

- **NAP (Name, Address, Phone)** — the holy trinity of local SEO. A moved location erodes Google's confidence.

- **Hours of operation** — holiday schedules, seasonal shifts. A listing saying "Open until 9 PM" that closes at 6 makes users bounce.

- **Services offered** — salons add and drop services frequently. An outdated service listing is worse than none.

- **Pricing tiers** — when a salon tool changes its pricing, comparison pages become misleading.

Before automation, keeping these fields fresh meant manually spot-checking a handful of listings each week. With 100+ listings, the effective refresh cycle was measured in months, not days. Some listings were 60, 90, or even 120+ days stale. That's an eternity in local SEO. Google's freshness signal penalizes listings with no recent changes, and users lose trust when they encounter outdated information.

The Solution

AiSalonHub needed an automated audit pipeline that could check every listing's freshness daily, flag specific fields needing updates, and apply corrections without human intervention. The constraints were tight: the system had to run on Cloudflare's free tier or minimal paid tier, scale to hundreds of listings without database bottlenecks, and execute within Worker CPU time limits (30 seconds for free, 60 seconds for paid).

Enter the **Serverless Freshness Checker** — a Cloudflare Workers cron trigger that fires once daily, iterates over every salon listing in D1, scores each listing's freshness across multiple dimensions, and either auto-updates stale fields or alerts the team when human judgment is needed.

The design goals:

- **Zero-touch daily audits** — every listing checked every 24 hours.

- **Granular freshness scoring** — per-field timestamps and scores, not a single stale flag.

- **Auto-healing where possible** — structured data updated from APIs; unstructured fields trigger alerts.

- **SEO-focused metrics** — surface the top 10 most-stale listings for prioritized attention.

Architecture

The system has three layers: the trigger mechanism, the audit worker, and the D1 persistence layer.

Worker Cron Triggers

Cloudflare Workers supports scheduled triggers via `cron` expressions in `wrangler.toml`. AiSalonHub uses two crons:

```toml

[triggers]

crons = [

"0 6 * * *", # Daily freshness audit at 6 AM UTC

"30 6 * * 1" # Weekly summary report every Monday at 6:30 AM UTC

]

```

The daily audit runs a full-scan freshness check. The weekly summary aggregates scores into a report that gets written to a `freshness_reports` table.

D1 Schema for Freshness Tracking

AiSalonHub's D1 database needed dedicated tables for freshness metadata, separate from the primary listing content:

```sql

CREATE TABLE listing_freshness (

id INTEGER PRIMARY KEY AUTOINCREMENT,

listing_id INTEGER NOT NULL UNIQUE,

listing_type TEXT NOT NULL,

last_checked_at TEXT NOT NULL DEFAULT (datetime('now')),

nap_verified_at TEXT,

hours_verified_at TEXT,

pricing_verified_at TEXT,

overall_score REAL NOT NULL DEFAULT 0.0,

stale_fields TEXT DEFAULT '[]'

);

CREATE TABLE freshness_audit_log (

id INTEGER PRIMARY KEY AUTOINCREMENT,

listing_id INTEGER NOT NULL,

audit_at TEXT NOT NULL DEFAULT (datetime('now')),

score REAL NOT NULL,

fields_checked INTEGER NOT NULL DEFAULT 0,

fields_stale INTEGER NOT NULL DEFAULT 0,

details TEXT

);

```

The `stale_fields` JSON array allows flexible field-level tracking without schema migrations.

Freshness Scoring Algorithm

Each listing gets a score from 0.0 to 1.0 based on:

- **Recency factor** (40% weight) — days since last verified. Decays exponentially.

- **Field coverage factor** (30% weight) — percentage of critical fields verified at least once.

- **Update frequency factor** (20% weight) — how often the listing gets updated.

- **External signal factor** (10% weight) — whether external sources show changes since last audit.

Listings scoring below 0.5 trigger an alert. Those below 0.3 trigger automatic re-verification.

Implementation

Cron Setup in the Worker

The audit worker is structured as an exported default class with a `scheduled` handler:

```javascript

import { AuditEngine } from './engine';

import { AlertService } from './alert';

export default {

async scheduled(event, env, ctx) {

const audit = new AuditEngine(env.DB, env.ALERT_WEBHOOK_URL);

switch (event.cron) {

case '0 6 * * *':

await audit.runDailyFreshnessCheck();

break;

case '30 6 * * 1':

await audit.runWeeklyReport();

break;

}

},

async fetch(request, env) {

// Manual trigger endpoint for ad-hoc audits

if (request.method === 'POST' && new URL(request.url).pathname === '/__audit') {

const audit = new AuditEngine(env.DB, env.ALERT_WEBHOOK_URL);

await audit.runDailyFreshnessCheck();

return new Response('Audit complete', { status: 200 });

}

return new Response('Not found', { status: 404 });

}

};

```

D1 Update Patterns

D1's batch API allows AiSalonHub to update all listing freshness scores in a single transaction:

```javascript

async updateFreshnessScores(scores) {

const stmt = this.env.DB.prepare(`

INSERT INTO listing_freshness (listing_id, listing_type, last_checked_at,

overall_score, stale_fields)

VALUES (?1, ?2, datetime('now'), ?3, ?4)

ON CONFLICT(listing_id) DO UPDATE SET

last_checked_at = datetime('now'),

overall_score = ?3,

stale_fields = ?4

`);

const batch = scores.map(s =>

stmt.bind(s.listingId, s.listingType, s.score, JSON.stringify(s.staleFields))

);

await this.env.DB.batch(batch);

}

```

This pattern keeps D1 operations efficient — one network call for all updates, atomic within the batch.

Alerting

When a listing's freshness drops below 0.5, the system posts a structured alert to a Discord webhook (or any Slack-compatible webhook):

```javascript

async sendAlert(listing, staleFields) {

const embed = {

title: `⚠️ Stale Listing: ${listing.name}`,

description: `Freshness score: ${listing.score.toFixed(2)}`,

fields: [

{ name: 'Listing ID', value: String(listing.id), inline: true },

{ name: 'Type', value: listing.type, inline: true },

{ name: 'Stale Fields', value: staleFields.join(', ') || 'None detected' },

{ name: 'Last Updated', value: listing.lastUpdated || 'Never' }

],

color: listing.score < 0.3 ? 0xff0000 : 0xffaa00

};

await fetch(this.webhookUrl, {

method: 'POST',

headers: { 'Content-Type': 'application/json' },

body: JSON.stringify({ embeds: [embed] })

});

}

```

Results

Three months after deploying the Freshness Checker, the metrics speak for themselves:

| Metric | Before | After |

|---|---|---|

| Average listing freshness score | 0.31 | 0.89 |

| Listings audited per day | 0 (manual) | 100+ (automated) |

| Stale listings (&lt; 0.5 score) | 68% | 4% |

| Time to detect stale data | Weeks | &lt; 24 hours |

| Worker execution cost | $0 | ~$0.20/month |

Beyond the numbers, the SEO impact was tangible. Listings with freshness scores above 0.8 saw a 23% higher click-through rate from search results compared to listings that had been stale for 60+ days. Google's local pack rankings improved for 15 out of the top 20 comparison pages within the first six weeks.

User trust metrics also improved. Bounce rate on listing pages dropped by 12% when users encountered verified-accurate hours and pricing. The average time-on-page increased by 34 seconds for listings with recent freshness checks.

Key Takeaways

1. **Freshness is a local SEO multiplier**, not a checkbox. A single stale field can undo the SEO value of an otherwise optimized listing. Treat freshness as a continuous process, not a quarterly cleanup.

2. **Serverless cron triggers are ideal for audit workloads**. Cloudflare Workers running on schedule cost pennies and eliminate the operational burden of maintaining a separate audit server. The 30-second CPU limit is tight but sufficient when you batch updates efficiently.

3. **D1 handles transactional freshness well**. The `ON CONFLICT ... DO UPDATE` pattern with batch inserts makes per-field freshness tracking practical at 100+ listing scale. The SQLite-based model keeps latency under 50ms per query.

4. **Alerting completes the loop**. Fully automated audits are valuable, but some stale data requires human judgment (policy changes, discontinued products, merger announcements). Discord/Slack webhooks transform raw freshness scores into actionable notifications.

5. **Start with the data that matters most**. AiSalonHub prioritized NAP and hours verification first, then expanded to pricing and services. An incremental rollout prevents alert fatigue.

For any site managing local business listings — whether it's 50 or 5,000 — the same serverless pattern applies. Cloudflare Workers + D1 + a cron trigger gives you a production-ready freshness audit system in a few hundred lines of code.