vacpac-content-refresh
Keep VacationDeals.to's existing blog_posts fresh — weekly, in place. Selects a prioritized batch of stale posts, has ≤5 agents refresh them (dates/seasons, re-verify $ prices against live deals, expa
SKILL.md.claude/skills/vacpac-content-refresh/SKILL.md
GitHubVacationDeals.to — Content Refresh Runbook (weekly)
Keeps the existing ~1,235 blog_posts fresh by UPDATING rows in place — never inserting, never deleting, never changing slugs. Sibling of vacdeals-evergreen (which adds +50 new posts); run this on the off-weeks from evergreen (same-week doubles token load). Full design rationale: docs/seo-audit-2026-09-24/07-vacpac-content-refresh-skill.md.
FIRST-RUN SETUP (once — idempotent, safe to re-run)
The last_refreshed_at column drives selection + cooldown. Apply the additive migration on the VPS before the first real run (no-op if already applied):
ssh root@72.60.126.82 'cd /var/www/vacationdeals && set -a && source .env && set +a && psql "$DATABASE_URL" -f scripts/sql/2026-10-15-blog-last-refreshed.sql'
schema.ts already declares the column (blogPosts.lastRefreshedAt). The companion scripts/update-blog-batch-json.ts sets it on every refresh.
GUARDRAILS (inherited from vacdeals-evergreen — non-negotiable)
- Agent waves ≤5. One wave of ≤5 agents per run; agents write once and stop (no self-expansion loops — they tripped the session limit before).
- Guarded deploys:
pnpm buildBEFOREpm2 restart; if build fails, do NOT restart (site stays up on the old build). - $25 all-in cap per run (agent tokens + any price-check fetches). Don't exceed without asking.
- Re-validate agent output programmatically (word floor, FAQ count, clean title/meta) — never trust "I validated it." The
update-blog-batch-json.ts --dry-runis that gate. - Sitemap-cache trap: never
rm.next/server/app/sitemap.xml*(500s the route).sitemap.tshasrevalidate=3600; a normal guarded rebuild regenerates it. - Known-walled price sources (
vegas-timeshare,holiday-inn,hyatt,branson-travel-group): FETCH_BLOCKED on curl/WebFetch is a verification gap, not a data error — usestealth-browser-mcpor fall back todeals.scraped_atfreshness; never "correct" a price down to nothing on a block.
THE "NEVER DEGRADE" RULE (this skill's prime directive)
- Never shorten a post below the word bar (≥750 words prose; ≥900 incl. FAQs). Shorter output = validation failure, full stop. The script REJECTS a length regression on a non-thin post.
- Never break the anti-AI blog style. Humanization (deliberate typos, grammar slips, casual first-person) stays in the BODY ONLY; title/meta/FAQ stay clean. Do NOT "correct" the intentional body slips.
- Never republish without diffing — review the
--dry-runfield-level diff before the real UPDATE. - Never change a slug (URL stability + internal links + no 404s). The script rejects a slug it can't find.
- Never delete/expire content (blog posts are refreshed, not retired).
- Never restructure a pillar hub (
/timeshare-presentation-deals,/all-inclusive-vacation-deals, the 8 topic hubs) — freshen copy only; they are link targets per CLAUDE.md. - Backup before the batch UPDATE (Phase 0) — UPDATE has no skip-existing safety net.
PHASE 0 — Safety backup
UPDATE-in-place has no undo. Take a fresh dump first (crown-jewel = deal_price_history, but this also captures blog_posts):
ssh root@72.60.126.82 "/usr/local/bin/vacdeals-db-backup.sh >> /var/log/vacdeals-dbbackup.log 2>&1 && ls -t /root/db-backups/*.pgdump | head -1"
(If a current dump from a same-day evergreen run exists, that suffices.)
PHASE 1 — Select the weekly batch (default 20 = 5 agents × 4)
Run the priority query, dump to a scratch PSV so the run log shows WHY each post was picked. Exclude anything refreshed in the last 60 days (cooldown). Base query:
SELECT slug, category, created_at::date AS created, last_refreshed_at::date AS last_refreshed,
length(content) AS content_length,
round(
EXTRACT(epoch FROM now() - GREATEST(created_at, COALESCE(last_refreshed_at, created_at)))/86400 -- age (days)
+ CASE WHEN length(content) BETWEEN 3500 AND 4999 THEN 120 ELSE 0 END -- thin boost
+ CASE WHEN category IN ('brands','destinations') THEN 60 ELSE 0 END -- price-drift risk
+ CASE WHEN title ~* '20(25|26)|summer|winter|spring|fall|thanksgiving|christmas|new year|labor day|memorial|easter|valentine' THEN 90 ELSE 0 END -- season/year rot
+ CASE WHEN slug ~* 'cheapest|^best-|-best-|-vs-|deals-under-|presentation-deal' THEN 40 ELSE 0 END -- commercial ROI
) AS priority
FROM blog_posts
WHERE is_published = true
AND (last_refreshed_at IS NULL OR last_refreshed_at < now() - interval '60 days')
ORDER BY priority DESC
LIMIT 30;
From the 30, pick ~20, capping any single category at ~60% of the batch (soft round-robin) so one category isn't starved. Skip pillar-hub + topic-hub slugs (freshen-copy-only; don't batch-restructure them). Dump the chosen set to slug|category|created|last_refreshed|content_length|priority PSV.
For each chosen slug, export the current row (title, meta, category, content, faqs, internalLinks, content_length, created_at, last_refreshed_at) to a per-slug JSON in a scratch dir — this is the agents' input AND the diff baseline.
Also dump live deal context for price re-verification — active deals with brand, destination, price, url — so agents can confirm quoted $ figures against real current deals.
PHASE 2 — Dispatch ≤5 refresh agents (one wave)
Split the 20 into ≤5 chunks (4 each), one agent per chunk. Each agent refreshes its posts, writes updated JSON once, and stops. Per post:
- Freshen dates/seasons — advance year tokens, rotate season/holiday framing to the current/upcoming window, update "as of <month>" lines.
- Re-verify every
$price against the provided livedealsdata (WebFetch / stealth-browser the partner lander when a figure is load-bearing and not indeals). Correct stale/repurposed prices. Apply the CLAUDE.md cautions: a "$X gift card / resort credit / deposit" figure is NOT the package price; drop placeholderoriginal_price(≥20000 or < price). - Expand thin bodies past the bar — ONLY posts flagged thin; add substantive prose + a data table to clear 750+ words (≥900 incl. FAQs). Never pad a healthy long post.
- Refresh interlinks — fix stale/404 authored
internalLinks; keep them minimal (the render-time resolver inlib/internal-links.tsadds geo/keyword-pillar + sibling links — do NOT hardcode a big related block). - Fix SERP-drifted title/meta — rewrite where intent or a quoted number drifted; keep the "vacation deals" primary-keyword posture. Title + meta + FAQ text must be clean (no deliberate typos) — humanization is BODY-only.
- Preserve style/structure — BLUF box, ≥6 FAQs (aim 8–10), first-person voice, real vacpack data.
Agents return per-slug JSON matching the update-blog-batch-json.ts schema (same shape as blog batches) + a short change note (what changed + old→new price diffs).
PHASE 3 — Re-validate programmatically (trust nothing)
Run the validator built into the companion in --dry-run (Phase 4) AND an independent cross-file check: rendered prose ≥750 words / ≥900 incl. FAQs, ≥6 FAQs each (unique, no cross-page dupes), title/meta/FAQ clean of the deliberate body typos (check those fields only — do NOT flag body slips), slug unchanged, no length regression below bar, any changed $ references a real current deal. Bounce failures back to the agent; never accept a shortened post.
PHASE 4 — UPDATE rows in place (dry-run first)
Commit the refreshed JSON to research/content-refresh/<YYYY-MM-DD>/ (git = source of record) and push. Then on the VPS, run the update companion (NOT the insert script — it skips existing slugs):
cd /var/www/vacationdeals && git pull -q origin main
cd /var/www/vacationdeals/apps/scraper && set -a && source /var/www/vacationdeals/.env && set +a
./node_modules/.bin/tsx /var/www/vacationdeals/scripts/update-blog-batch-json.ts /var/www/vacationdeals/research/content-refresh/<YYYY-MM-DD> --dry-run
# review the field-level diff (title/meta/price/word-count deltas), then re-run WITHOUT --dry-run
The script UPDATEs by slug, sets updated_at + last_refreshed_at = now(), refuses to create a missing slug, and rejects a below-bar length regression.
PHASE 5 — Guarded rebuild
cd /var/www/vacationdeals && git pull -q origin main && set -a && source .env && set +a && pnpm build && pm2 restart vacationdeals-web --update-env && pm2 save
Build fails → do NOT restart. Don't touch the compiled sitemap route; ISR regenerates it with fresh lastmod.
PHASE 6 — Verify sample renders
Curl 3–5 refreshed slugs at top-level (https://vacationdeals.to/<slug>): expect 200, FAQPage JSON-LD present, the corrected price/date visible, "Explore More" internal-links block intact. /blog/<slug> 308s to top-level. Within the hour, spot-check that /sitemap.xml <lastmod> advanced for a refreshed slug (only if sitemap.ts derives lastmod from updated_at/last_refreshed_at — see open item below).
FINISH / report
- Commit refreshed JSON (
research/content-refresh/<date>/); push. - Update
docs/NEXT-ENHANCEMENTS.md(dated entry) + relevant memory. - Consolidated summary: batch (N by category + selection reason) · prices re-verified + corrections (old→new) · thin posts expanded (before→after words) · titles/metas rewritten · rows UPDATEd +
last_refreshed_atset · build/verify status + sitemap lastmod · cost vs $25 cap · coverage (running total refreshed / 1,235 + weeks to clear the 644-post stale cohort at this cadence).
OPEN ITEMS (owner decisions — see spec §7)
- Sitemap
lastmod: wiresitemap.ts<lastmod>toGREATEST(updated_at, last_refreshed_at)so refreshes are advertised to crawlers (recommended; small change — otherwise Phase 6's lastmod check is moot). - Title rewrites: default is agents may rewrite titles; if you'd rather approve title changes (they affect established rankings), have agents propose titles and apply only body/price/date.
- Cadence: weekly manual
/vacpac-content-refresh, alternating weeks with evergreen. 20/week clears the 644 stale cohort in ~8 months.