Strategic SEO Architecture for Deal
Aggregators in the 2026 Era: A
Comprehensive Roadmap for
Gottahavecoups.com
Executive Summary and Strategic Outlook
The digital ecosystem of 2026 presents a radically transformed environment for affiliate
marketers and deal aggregators. The evolution of search engines into "Answer Engines" and
the dominance of Generative AI in information retrieval have fundamentally altered the
mechanics of organic visibility. For an aggregator like Gottahavecoups.com, which operates on
a model of scraping and redistributing discount content similar to industry leaders like Slickdeals
and The Krazy Coupon Lady, the path to dominance is no longer paved solely with backlinks
and keywords. It requires a sophisticated transition from a display-oriented web property to a
structured data platform that feeds the semantic web.
In 2026, Google’s algorithms—heavily integrated with Gemini and other Large Language
Models (LLMs)—prioritize "Information Gain" and "Entity Authority" over raw indexation volume.
The "ten blue links" that characterized search for two decades have been supplanted by AI
Overviews and interactive shopping graphs that synthesize answers directly on the search
results page. This shift creates an existential imperative for deal aggregators: they must evolve
from mere repositories of scraped codes into authoritative sources of verified savings
intelligence. If the platform functions only as a mirror of retailer data, it risks being categorized
as "thin content" and de-indexed to conserve crawl resources.
This report provides an exhaustive, 15,000-word analysis of the necessary architectural,
technical, and content strategies required to secure and sustain a market-leading position for
Gottahavecoups.com. It moves beyond basic SEO advice to explore the deep mechanics of
crawl budget economics for high-velocity inventory, the precise implementation of advanced
JSON-LD structured data (referred to in the query as "Jason schemas"), and the integration of
Google Merchant Center as a vehicle for organic visibility. The roadmap detailed herein is
designed not just to survive the algorithm updates of 2026 but to leverage them, turning the
site’s data density into its most potent competitive advantage.

1. The 2026 Search Landscape: The Transition to
Generative Commerce
To engineer a winning strategy for Gottahavecoups.com, one must first deconstruct the
operational reality of the 2026 search landscape. The traditional funnel—where a user
searches, clicks a link, and browses a website—has been disrupted by "Zero-Click" experiences
where the search engine satisfies the user's intent immediately.

1.1 From Information Retrieval to Information Synthesis
Historically, search engines operated on a retrieval-based model. When a user queried "Nike
promo code," the engine retrieved a list of documents containing that phrase, ranked by
authority signals like backlinks. In 2026, the model is synthesis-based. The engine, powered by
generative AI, ingests content from multiple sources, verifies the validity of the claims (e.g., "Is
this code active?"), and constructs a composite answer.
For a deal aggregator, this is critical because the "answer" provided by the AI often includes the
coupon code itself, potentially bypassing the need for a click. However, this threat is
counterbalanced by the AI's need for citations. The AI cannot "know" a coupon code exists
unless a trusted source has published it in a machine-readable format. Therefore, the primary
SEO objective for Gottahavecoups.com shifts from "ranking #1" to "becoming the source of
truth" for the AI's training data and real-time retrieval.

The Role of "Grounding" in AI Search
AI models in 2026 are programmed to avoid "hallucinations"—inventing facts. To do this, they
rely on "grounding," which is the process of anchoring their generated text to verified data
sources. When Gottahavecoups.com provides a structured, verified data point (e.g., "Code
SAVE20 verified at 9:00 AM EST"), it becomes a grounding source. The strategies detailed in
this report regarding structured data and verification logs are specifically designed to maximize
the site's "grounding score," increasing the likelihood of being cited in AI Overviews.

1.2 The Commoditization of Scraped Content
The user's operational model involves scraping websites and reposting deals. In 2026, raw
scraped content is a liability. Google's "Helpful Content" systems and "SpamBrain" AI are
aggressively tuned to identify and suppress syndicated content that adds no unique value. If
Gottahavecoups.com simply republishes the same text description found on Amazon or
Walmart, the site's "Information Gain" score is zero.
The Thin Content Risk Matrix:
Content Type
2026 SEO Risk Level
Algorithm Response
Raw Scraped Text
Critical
De-indexation or "Soft 404"
classification.
Scraped + Rewritten (AI)
High
Potential "Scaled Content
Abuse" penalty if quality is low.
Scraped + Structured Data
Moderate
Indexable, but unlikely to rank
for competitive terms.
Scraped + Information Gain Low (Optimal)
High visibility; cited as a
primary source.
The strategy outlined in Section 6 ("Information Gain") focuses on moving the site from the
"Critical" risk zone to the "Optimal" zone by using automation to add proprietary data
layers—such as price history, stackability analysis, and verification timestamps—to the scraped
core.

1.3 The Fragmentation of Transactional Search

By 2026, users no longer search exclusively on Google. Transactional intent has fragmented
across vertical platforms.
● Visual Discovery: TikTok and Instagram are primary discovery engines for Gen Z
shoppers looking for "dupes" or flash sales.
● Verification: Reddit has become the de facto "trust engine." Users append "reddit" to
queries (e.g., "best deal site reddit") to find honest reviews.
● Direct Commerce: Amazon and specialized retailer apps retain a massive share of direct
navigation.
Gottahavecoups.com must exist as an entity across this ecosystem. While this report focuses
on Google SEO, the principles of Entity Authority and Brand Consistency discussed in
Section 7 are applicable across all platforms. The site must be recognizable as a distinct brand,
not just a generic URL, to survive in a decentralized search environment.

2. Advanced Technical Architecture: The Foundation
of Scale
For a deals aggregator, technical SEO is not merely a housekeeping task; it is the primary
scalability constraint. Deal sites are characterized by High Velocity (thousands of new URLs
created daily) and High Decay (thousands of URLs expiring daily). This creates immense
pressure on Crawl Budget—the finite amount of attention Googlebot assigns to a domain. If the
architecture is inefficient, Googlebot will spend its resources crawling expired deals from 2024,
leaving today's hottest offers unindexed.

2.1 The Dynamic Sitemap Index Strategy
The user query specifically requested information on "Site map/index." In 2026, a static,
monolithic sitemap.xml is obsolete for large-scale aggregators. The standard protocol requires a
Dynamic Sitemap Index that segments URLs by content type and update frequency. This
segmentation allows for granular monitoring in Google Search Console and signals priority to
search engines.

2.1.1 Semantic Segmentation of Sitemaps
Rather than grouping URLs arbitrarily (e.g., sitemap1.xml, sitemap2.xml), Gottahavecoups.com
must organize sitemaps semantically.
Recommended Sitemap Architecture:
1. sitemap_index.xml: The parent file referencing all child sitemaps.
2. sitemap_deals_realtime.xml: Contains only deals posted or updated in the last 24-48
hours.
○ Update Frequency: Hourly.
○ Strategic Intent: This is the only sitemap that requires aggressive pinging. By
isolating fresh content, we train Googlebot to crawl this file frequently.
3. sitemap_deals_active.xml: Contains active deals older than 48 hours.
4. sitemap_stores.xml: Contains Merchant Hub pages (e.g., /store/target, /store/best-buy).
These are high-priority, evergreen pages.
5. sitemap_categories.xml: Taxonomy pages (e.g., /category/electronics).
6.�� sitemap_archive.xml: Expired deals that are kept for historical pricing data (if the

strategy dictates keeping them live).

2.1.2 The Critical Role of <lastmod>
In 2026, Google relies heavily on the <lastmod> tag in XML sitemaps to schedule crawls.
However, it only respects this tag if it is verifiable. If the sitemap says a page changed at 10:00
AM, but the HTML is identical to the previous crawl, Google will blacklist the sitemap's dates.
● Requirement: The underlying CMS for Gottahavecoups.com must update the
last_modified database timestamp only when meaningful content changes occur (e.g.,
price update, status change, new verification log).
● Code Implementation: Ensure the sitemap output format adheres to W3C datetime
standards (e.g., 2026-10-05T14:30:00+00:00).

2.2 Robots.txt and The Economy of Crawl
The robots.txt file is the gatekeeper of crawl budget. For an aggregator scraping content, the
site likely generates thousands of low-value parameter URLs (sorting, filtering, session IDs).
These must be aggressively blocked to prevent "Crawl Waste".
Optimized Robots.txt Directive Strategy:
User-agent: *
# Block administrative and login paths
Disallow: /wp-admin/
Disallow: /login/
Disallow: /register/

# Block internal search results (Infinite Crawl Space Risk)
Disallow: /search/
Disallow: /?s=

# Block sorting and filtering parameters that create duplicate content
Disallow: /*?sort=
Disallow: /*?filter=
Disallow: /*?price_min=
Disallow: /*?price_max=
Disallow: /*?order=

# Block affiliate redirect intermediaries to conserve budget
# (Note: Ensure actual affiliate links use rel="nofollow" in HTML)
Disallow: /go/
Disallow: /out/

# Allow essential assets for rendering
Allow: /wp-content/themes/
Allow: /wp-includes/js/
Allow: /assets/css/

# Explicit Sitemap Definition
Sitemap: https://gottahavecoups.com/sitemap_index.xml

Strategic Reasoning: Blocking /search/ is critical. Spambots often attack internal search bars
with millions of queries to generate indexed spam pages on your domain. Disallowing this path
prevents Google from indexing these low-quality, user-generated pages.

2.3 The "Expired Deal" Lifecycle: 404 vs. 301 vs. Soft 404
The handling of expired content is the defining technical challenge for coupon sites.
● Option A: Delete (404). Bad for SEO. All backlinks pointing to that deal are lost. Users
hitting the URL bounce immediately.
● Option B: Redirect to Home (301). Bad for SEO. Google treats mass redirects to the
homepage as "Soft 404s," effectively ignoring the redirect and killing the link equity.
● Option C: The Merchant Hub Redirect (The 2026 Standard).
○ When the "Sony Headphones" deal expires, 301 redirect the URL to the Sony
Merchant Hub page (/store/sony).
○ Why: The user intent (saving on Sony) is still preserved. The link equity flows to the
Hub page, strengthening the authority of the parent category.
Alternative Strategy: The "Zombie" Page (For High-Traffic URLs) If a specific deal page has
immense authority (e.g., a viral "Glitch Deal" that got 500 backlinks), do not redirect it. Keep it
live.
● Update the H1: " Sony Headphones Deal..."
● UI Intervention: Place a massive, prominent banner above the fold: "This deal has
ended. Here are 3 active alternatives for Sony Headphones."
● Schema Update: Change the Offer schema availability property to Discontinued or
OutOfStock. This tells Google to remove the rich snippet price but keep the page indexed
for its informational value.

2.4 Indexing Protocols: IndexNow and Beyond
Speed is critical. Waiting for Google's passive crawl is insufficient for "Flash Sales" that last only
4 hours.
IndexNow Integration: While Google does not currently support IndexNow, Bing and Yandex
do. Implementing IndexNow allows Gottahavecoups.com to push URLs instantly to these
engines. This is vital because many 2026 AI tools aggregate data from Bing's index as well as
Google's.
● Mechanism: When a new deal is published, the server sends a simplified HTTP request
to the IndexNow API.
● Benefit: Instant indexation on secondary search engines, diversifying traffic sources.
Google Indexing API (The Gray Hat Area): Officially, Google's Indexing API is for Job Posting
and Broadcast events only. However, many large publishers use it for time-sensitive content.
Proceed with caution. A safer "White Hat" alternative for 2026 is the use of PubSubHubbub
(WebSub) protocols if the CMS supports it, or simply ensuring the Realtime Sitemap is pinged
immediately upon update.

3. The Semantic Web & "Jason Schemas": Mastering
JSON-LD

The user's reference to "Jason schemas" indicates a need for JSON-LD (JavaScript Object
Notation for Linked Data). In 2026, structured data is not merely a method for getting "stars" in
search results; it is the fundamental language of the semantic web. It allows the website to
communicate directly with Google's Knowledge Graph, bypassing the ambiguity of natural
language processing.
For Gottahavecoups.com, generic schema is insufficient. The site requires a complex, nested
schema architecture that creates a "Knowledge Graph" for every deal.

3.1 The Core Schema Entities
The implementation must leverage specific Schema.org types designed for commerce.
Schema Type
Description
Application for
Gottahavecoups.com
Product
Represents the item or service. Used on single-product deal
pages.
Offer
Represents the specific deal
Nested inside Product.
conditions.
Contains price and validity.
MerchantReturnPolicy
Critical 2026 Standard.
Defines return windows and
fees. A major trust signal.
ShippingDetails
Defines shipping costs.
Essential for "Free Shipping"
deal visibility.
Organization
Represents
Used on the Homepage and
Gottahavecoups.com.
About pages to build Entity
Authority.
CollectionPage
Represents a list of items.
Used on Merchant Hubs (e.g.,
"All Target Deals").
FAQPage
Question/Answer pairs.
Used on Merchant Hubs to
capture long-tail queries.

3.2 Deep Dive: The Offer Schema Architecture
The Offer schema is the heart of a deal site. In 2026, Google requires granular detail here to
power features like "Price Drop" notifications and "Deal" badges.
Key Properties to Implement:
● priceCurrency: ISO 4217 code (e.g., "USD").
● price: The discounted price.
● priceValidUntil: The expiration date. Crucial for removing expired deals from SERPs
automatically.
● availability: http://schema.org/InStock or http://schema.org/LimitedAvailability.
● hasMerchantReturnPolicy: A nested object defining the return rules.
● shippingDetails: A nested object defining shipping costs.

3.3 Advanced JSON-LD Implementation Examples
The following examples demonstrate the precise coding standards required for 2026
compliance.

3.3.1 Single Product Deal Page
This markup is for a page offering a specific discount on a physical product (e.g., "Sony
WH-1000XM5 Headphones").
<script type="application/ld+json">
{
"@context": "https://schema.org/",
"@type": "Product",
"name": "Sony WH-1000XM5 Wireless Noise Canceling Headphones",
"image": [
"https://gottahavecoups.com/images/sony-xm5-front.jpg",
"https://gottahavecoups.com/images/sony-xm5-side.jpg"
],
"description": "Industry-leading noise canceling with two processors
controlling 8 microphones. Deal verified active.",
"brand": {
"@type": "Brand",
"name": "Sony"
},
"sku": "WH1000XM5/B",
"gtin13": "0027242921980",
"offers": {
"@type": "Offer",
"url": "https://gottahavecoups.com/deals/sony-xm5-bestbuy",
"priceCurrency": "USD",
"price": "298.00",
"priceValidUntil": "2026-12-31",
"itemCondition": "https://schema.org/NewCondition",
"availability": "https://schema.org/InStock",
"seller": {
"@type": "Organization",
"name": "Best Buy"
},
"hasMerchantReturnPolicy": {
"@type": "MerchantReturnPolicy",
"applicableCountry": "US",
"returnPolicyCategory":
"https://schema.org/MerchantReturnFiniteReturnWindow",
"merchantReturnDays": 15,
"returnMethod": "https://schema.org/ReturnByMail",
"returnFees": "https://schema.org/FreeReturn"
},
"shippingDetails": {
"@type": "OfferShippingDetails",
"shippingRate": {
"@type": "MonetaryAmount",
"value": "0",
"currency": "USD"

},
"shippingDestination": {
"@type": "DefinedRegion",
"addressCountry": "US"
},
"deliveryTime": {
"@type": "ShippingDeliveryTime",
"handlingTime": {
"@type": "QuantitativeValue",
"minValue": 0,
"maxValue": 1,
"unitCode": "d"
},
"transitTime": {
"@type": "QuantitativeValue",
"minValue": 1,
"maxValue": 3,
"unitCode": "d"
}
}
}
}
}
</script>
Strategic Commentary: Notice the inclusion of gtin13. In 2026, Google uses GTINs (Global
Trade Item Numbers) to match products across the web. By including this, you link your deal
page to the global "Product Graph," ensuring your page appears when users search for that
specific barcode or product ID. This is a powerful disambiguation signal.

3.3.2 Merchant Hub / Coupon Aggregation Page
For a page like "Best Home Depot Coupons," use a combination of CollectionPage and
FAQPage.
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@graph":
}
},
{
"@type": "FAQPage",
"mainEntity":
}
]
}
</script>

Strategic Commentary: The FAQPage schema is particularly potent for Merchant Hubs. It
allows Gottahavecoups.com to occupy additional vertical pixel space in the SERP (via
accordion snippets, though less common in 2026, still used for voice answers). More
importantly, it feeds the "Answer Engine," ensuring that when a user asks, "How do I use a
Home Depot code?", your content is structured to provide the answer.

3.4 Schema Validation and Monitoring
Implementing JSON-LD is risky if done incorrectly. A single syntax error can invalidate the entire
block.
● Tooling: Use the Google Rich Results Test tool during development.
● Monitoring: Monitor the "Merchant Listings" and "Product Snippets" reports in Google
Search Console. 2026 updates provide granular error reporting for missing properties like
returnPolicyCategory.
● Drift Control: Ensure that the data in the schema matches the visible text on the page
exactly. Mismatches (e.g., Schema says $50, Page says $60) are flagged by Google's
"SpamBrain" as deceptive and can lead to manual actions.

4. Content Strategy: "Information Gain" and Defeating
Thin Content
The "Gottahavecoups" model—scraping websites and reposting deals—is inherently vulnerable.
In the 2026 SEO landscape, Google’s algorithms are tuned to detect and suppress "Syndicated
Content." If a page does not provide Information Gain—defined as new information not
available in the original source documents—it will likely be excluded from the main index.

4.1 The Mechanism of Information Gain
Information Gain is a probabilistic measure used by search engines to determine if a new
document adds value to the existing corpus.
● Scenario: Amazon posts a deal for a blender at $40.
● Aggregator A: Scrapes the title and price. (Information Gain = 0).
● Aggregator B (Gottahavecoups): Scrapes the price, but adds a price history chart, a
verification log, and a comparison to the Walmart price. (Information Gain > 0).
To survive, Gottahavecoups.com must systematically inject "Information Gain" into every
scraped page.

4.2 Automated Value-Add Strategies
Since manual writing is not scalable for thousands of deals, we must use automation (AI) to
generate value-added layers.

4.2.1 The "Verification Log"
Trust is the scarcest resource in the coupon niche. Users are tired of fake codes.
● Strategy: Implement a system (bot or human) that tests the code.
● Content Output: Display a "Verification Log" on the page.

○ "Tested: Feb 18, 2026 at 14:02 EST"
○ "Outcome: Valid"
○ "Last Savings: $12.50"
● SEO Impact: This creates unique, dynamic text that the original retailer does not have. It
signals "Freshness" to Google.

4.2.2 The "Price Context" Widget
Scrape not just the current price, but historical data (or integrate via API with tools like
Keepa/CamelCamelCamel).
● Content Output: "This price ($29.99) is the lowest in 90 days. The previous low was
$35.00 on Jan 15."
● SEO Impact: This transforms the page from a simple advertisement into a data-rich
analytical resource. AI Overviews favor comparative data when answering "Is this a good
deal?" queries.

4.2.3 AI-Synthesized "Fine Print"
Retailer terms and conditions are often legalese. Use an LLM to summarize them.
● Input: The raw terms text.
● Prompt: "Summarize these coupon terms into 3 bullet points for a 5th grader. Highlight
exclusions."
● Content Output: "TL;DR: Works on shoes. Does not work on Clearance. Expires Friday."
● SEO Impact: Provides a better User Experience (UX) and generates unique text content
that targets "long-tail" queries about exclusions.

4.3 E-E-A-T Building for Aggregators
Experience, Expertise, Authoritativeness, and Trustworthiness (E-E-A-T) are critical.
Aggregators often lack "Experience" because they don't manufacture the product.
Strategies to Manufacture Experience:
1. The "Deal Editor" Persona: Move away from anonymous publishing. content should be
attributed to specific editors (e.g., "Curated by Mike, Tech Deal Specialist"). Create bio
pages for these personas detailing their history in bargain hunting.
2. Original Imagery: Encourage users or staff to upload photos of the product in the cart or
received. Even a screenshot of the checkout screen showing the discount applied is
"proof of experience" that separates the site from pure text scrapers.
3. Transparency: Clearly label the source of the deal. "Found on Amazon.com." Clear
affiliate disclosures are not just legal requirements (FTC); they are trust signals for
Google's Quality Raters.

5. Google Merchant Center: The Secret Weapon for
Organic Traffic
In 2026, Google Merchant Center (GMC) is the bridge between e-commerce data and organic
search. While traditionally for paid Shopping ads, GMC now powers "Free Listings" and the
"Deals" tab in organic search results. For an affiliate aggregator, integrating with GMC is a

game-changer.

5.1 Eligibility for Aggregators
Historically, only "Merchants" (sellers) could use GMC. However, policies have evolved.
Affiliates can participate if they meet specific "Value Added" criteria or utilize "Comparison
Listing Ads" (CLA) frameworks. Even without direct product sales, structuring data to match
GMC specifications ensures that the site's structured data (discussed in Section 3) is perfectly
aligned with what Google's Shopping Graph expects.

5.2 The "Promotions" Feed
For high-level affiliate partnerships, Gottahavecoups.com should utilize the Promotions Feed
specification within GMC (or simulate it via structured data).
● Function: This feed allows you to associate a specific promotion ID (e.g.,
"SummerSale2026") with a set of products.
● Visibility: When users browse Google Shopping, products associated with your feed
appear with a "Special Offer" badge.
● 2026 Update: The new support for Subscription Promotions (e.g., "3 Months Free") is
critical for the B2B/SaaS sector. Ensure the redemption_restriction attribute is set to
subscribe_and_save for these deals.

5.3 The "Deals" Carousel Optimization
Google's SERP features a dedicated "Deals" carousel for queries like "Laptop deals."
● Optimization Requirement: To appear here, the page must have a high-quality image (at
least 1200px wide) and the specific Offer schema details (Price, Currency, discount code).
● Title Strategy: Page titles must be explicit. Use: "[Product Name] Deal - Save [Amount]
at" rather than vague clickbait. Google parses the <title> tag to populate the carousel text.

6. Generative Engine Optimization (GEO): Writing for
AI
With the dominance of AI Overviews, Gottahavecoups.com must optimize for Generative
Engine Optimization (GEO). This involves formatting content so it is easily ingested,
understood, and cited by AI models.

6.1 The "Citation-Ready" Content Structure
AI models are lazy. They prefer content that is pre-structured.
● The "Inverted Pyramid" (BLUF): Start every paragraph with the "Bottom Line Up Front."
○ Bad: "If you are looking for a way to save money on your next purchase of running
shoes..."
○ Good: "The best Nike promo code for February 2026 is RUN20. It saves 20% on
Pegasus models."
● Key-Value Pairs: Use bolding to create semantic associations. Code: SAVE20.
Discount: 20%. Expires: Tomorrow. This helps the AI extract entities.

6.2 Managing Zero-Click Searches
If the AI gives the code, why click?
● The "Click Gap" Strategy: Provide information that creates curiosity or necessity.
○ Tactic: "This code has 5 specific exclusions. Click here to view the exclusion list."
○ Tactic: "See 3 other stackable coupons for this item."
○ Tactic: "Verify this code for your location" (Interactive button). This forces the user to
interact with the site for the complete utility, mitigating the traffic loss from zero-click
answers.

7. Off-Page Authority and Digital PR
In the 2026 link economy, buying links is dangerous and guest posting is devalued. The only
scalable, safe link-building strategy for aggregators is Digital PR based on data.

7.1 Data Journalism Campaigns
Gottahavecoups.com has access to pricing data across thousands of stores. This is
newsworthy.
● Campaign: "The 2026 Inflation Index: We tracked 10,000 grocery items. Here are the 5
stores that raised prices the most."
● Execution: Publish this as a white paper / blog post with charts. Pitch it to consumer
finance journalists.
● Result: High-authority news sites (CNN, Forbes) cite the study. These "editorial" links are
the gold standard for authority.

7.2 Entity Building
Google ranks Entities, not just URLs. Gottahavecoups.com must establish itself as a recognized
entity.
● Knowledge Panel: Aim to trigger a Knowledge Panel for the brand. This requires
consistent N.A.P. (Name, Address, Phone) data across the web (Crunchbase, LinkedIn,
etc.) and a Wikipedia entry if eligible.
● Social Signals: Active profiles on X, TikTok, and Instagram signal that the brand is alive
and engaging with real users, countering the "bot site" narrative.

8. Strategic Roadmap and Implementation Plan
To execute this vision, Gottahavecoups.com must follow a phased implementation plan.

Phase 1: The Technical Core (Weeks 1-4)
● Objective: Fix the foundation.
● Actions:
1. Implement the Dynamic Sitemap Index architecture (separate Daily Deals from
Archives).

2. Update robots.txt to block all parameterized URLs (?sort=, ?filter=).
3. Deploy the Merchant Hub Redirect system for expired deals (stop the 404
bleeding).
4. Register for IndexNow and integrate the API for real-time deal pushing.

Phase 2: The Semantic Layer (Weeks 5-8)
● Objective: Speak Google's language.
● Actions:
1. Roll out the advanced JSON-LD Offer templates across all deal pages. Ensure
GTIN and Return Policy fields are populated.
2. Add FAQ Schema to all top-tier Merchant Hub pages (Amazon, Walmart, Target).
3. Audit the site using Google's Rich Results Test to ensure zero errors.

Phase 3: Content & Value Injection (Weeks 9-12)
● Objective: Solve the "Thin Content" problem.
● Actions:
1. Launch the "Verification Log" feature (displaying timestamps of deal testing).
2. Implement the "Price History" widget on top deal pages.
3. Rewrite the top 50 Merchant Hub descriptions using the "Information Gain"
principles (unique tips, stacking analysis).

Phase 4: Authority & Growth (Month 4+)
● Objective: Build the moat.
● Actions:
1. Publish the first Data Journalism report (e.g., "Best Time to Buy Tech in 2026").
2. Execute a Broken Link Building campaign targeting expired deal guides on
competitor blogs.
3. Explore Google Merchant Center "Promotions Feed" integration for direct
partners.

Conclusion
The path to "max SEO" for Gottahavecoups.com in 2026 is not about scraping more content; it
is about refining the data you already have. By treating your website as a structured database
that services both human users and AI agents, you align with the trajectory of search. The
transition from Display-First to Data-First is difficult, but it is the only way to build a defensible
business in the age of Generative AI. The winners in 2026 will not be the sites with the most
coupons; they will be the sites with the most trusted data.

Works cited
1. SEO Strategy in 2026 Explained: How to Build What Really Works - ElevateDigi,
https://www.theelevatedigi.com/blog/seo-strategy-in-2026-explained-how-to-build-what-really-wo
rks 2. Google AI Overviews SEO: 15 Expert Strategies - Yotpo,

https://www.yotpo.com/blog/optimize-google-ai-overviews-geo/ 3. How to Get Your Brand Cited
in Google AI Overviews (Practical SEO for 2026),
https://www.topseosydney.com.au/how-to-get-your-brand-cited-in-google-ai-overviews-practicalseo-for-2026/ 4. 10 Google SEO Guidelines for 2025–2026: Master E-E-A-T & Core Updates to
Rank Higher - Credofy.com, https://credofy.com/google-seo-guidelines-2025-2026/ 5.
eCommerce SEO in 2026 - 5 Things to have an eye on - Legiit Marketplace,
https://legiit.com/blog/ecommerce-seo-in-2026 6. The New Rules of SEO (2026) - YouTube,
https://www.youtube.com/watch?v=39o0uYPo4jU 7. Crawl Budget Optimization: A Complete
Guide - Seobility Blog, https://www.seobility.net/en/blog/crawl-budget-optimization/ 8. The
Secrets to Stacking Coupons,
https://thekrazycouponlady.com/tips/couponing/the-ultimate-guide-to-stacking-coupons 9. Your
guide to crawl budget optimization | Wix SEO Hub,
https://www.wix.com/seo/learn/resource/crawl-budget-optimization 10. Crawl Budget
Management - Google for Developers,
https://developers.google.com/crawling/docs/crawl-budget 11. How To Add Your Sitemap To
Your Robots.txt File - WooRank,
https://www.woorank.com/en/blog/how-to-locate-a-sitemap-in-a-robots-txt-file 12. Where to put
sitemap.xml and robots.txt in case of a Tomcat server with several Webapps,
https://stackoverflow.com/questions/34654249/where-to-put-sitemap-xml-and-robots-txt-in-caseof-a-tomcat-server-with-several 13. Full Technical SEO Checklist: The 2026 Guide - Yotpo,
https://www.yotpo.com/blog/full-technical-seo-checklist/ 14. How to Rank a Coupon Website on
Google – ClipMyDeals Blog, https://clipmydeals.com/blog/732-2/ 15. Is IndexNow Worth It If
Google Doesn't Support It? - Synscribe,
https://www.synscribe.com/blog/indexnow-worth-it-without-google 16. 9 Best Rapid URL Indexer
Tools for Faster Website Indexing in 2026 | Passionate Agency,
https://passionates.com/best-search-engine-optimization-rapid-url-indexer/ 17. Google's
absence from IndexNow raises questions about web indexing standards,
https://ppc.land/googles-absence-from-indexnow-raises-questions-about-web-indexing-standard
s/ 18. 9 Best Schema Markup Plugins for WordPress (2026) - WPBeginner,
https://www.wpbeginner.com/showcase/best-schema-markup-plugins-for-wordpress/ 19. How To
Add Merchant Listing Structured Data | Google Search Central | Documentation,
https://developers.google.com/search/docs/appearance/structured-data/merchant-listing 20.
Supported structured data attributes and values - Google Merchant Center Help,
https://support.google.com/merchants/answer/6386198?hl=en 21. The rise and fall of FAQ
schema – and what it means for SEO today - Search Engine Land,
https://searchengineland.com/faq-schema-rise-fall-seo-today-463993 22. Google Search
Console: The Complete 2026 Guide to Monitoring, Optimizing, and Ranking Your Website | ALM
Corp, https://almcorp.com/blog/google-search-console-complete-guide/ 23. 10 SEO Goals for
2026 That Actually Drive Revenue (Not Just Traffic),
https://www.connectmediaagency.com/seo-goals-2026/ 24. Best 18 Tips for Ranking in AI
Search in 2026 - Yotpo, https://www.yotpo.com/blog/ranking-ai-search-geo-tips/ 25. Data-Driven
Content Backlinks: Complete 2026 Guide - Koanthic,
https://koanthic.com/en/data-driven-content-backlinks-complete-2026-guide/ 26. Zero-Click SEO
Strategy 2026: How to Survive AI Overviews 2026,
https://www.clickrank.ai/zero-click-seo-strategy/ 27. How to Build E-E-A-T for Non-Expert
Brands: A Strategic Guide | Hashmeta,
https://hashmeta.com/blog/how-to-build-e-e-a-t-for-non-expert-brands-a-strategic-guide/ 28.
Google Merchant Center for Organic Traffic: High Value Clicks For ...,

https://digitalcommerce.com/google-merchant-center-for-organic-traffic/ 29. What Is Google
Merchant Center? Features + Tips (2026) - Shopify,
https://www.shopify.com/blog/what-is-google-merchant-center 30. Merchant Center
announcements change log - Google Help,
https://support.google.com/merchants/announcements/6192467?hl=en 31. Engage shoppers
with deal pages in Google Search | Google Search Central Blog,
https://developers.google.com/search/blog/2021/07/deals-best-practices 32. Local SEO sprints:
A 90-day plan for service businesses in 2026 - Search Engine Land,
https://searchengineland.com/local-seo-sprints-a-90-day-plan-for-service-businesses-in-2026-4
69059


This is a comprehensive SEO roadmap specifically for GottaHaveCoups.com. Let me analyze what's most immediately actionable and what we already have in place vs. what needs to be built.

Let me organize this into what we can do right now vs. what requires more work:

**Already done:**
1. Sitemap exists (but I'm fixing it now to include deal pages)
2. Robots.txt exists
3. Blog posts with internal backlinks (just implemented)
4. Frontend with meta tags

**High priority - can implement now:**
1. **Dynamic Sitemap Index** - Split into sitemap_deals_realtime.xml, sitemap_deals_active.xml, sitemap_stores.xml, sitemap_categories.xml, sitemap_archive.xml

2. **robots.txt refinements** - Need to block parameter-based URLs and search pages while keeping the affiliate redirect logic intact
3. **JSON-LD structured data** - Adding product and offer schemas to every deal page so search engines understand pricing and availability
4. **IndexNow integration** - Pushing real-time updates to Bing and Yandex when deals change
5. **Expired deal redirects** - Sending 301s to relevant category or store pages instead of 404s
6. **Schema markup for expiration** - Using priceValidUntil to signal when deals expire so they drop from search results automatically

**Medium priority:**
1. Verification logging
2. Price history widget with Keepa/CamelCamelCamel data
3. Deal editor E-E-A-T signals
4. FAQ schema for store and merchant hub pages
5. Google Merchant Center connection

**Already in progress:**
- Blog content strategy
- Internal linking structure
- Social media distribution

Now I need to give Skinny a prioritized roadmap showing what I can tackle right away versus what requires more groundwork.

Good doc. Here's my honest read on where we stand vs. what's in here:

**Already live / just done:**
- ✅ Sitemap (just expanded to include all deal pages)
- ✅ robots.txt (correct structure)
- ✅ Internal backlinks on blog posts (just implemented tonight)
- ✅ Daily blog posts for content velocity

**High impact — can implement this week:**

1. **Dynamic Sitemap Index** — split into `sitemap_deals_realtime.xml` (hourly, last 48hr deals), `sitemap_deals_active.xml`, `sitemap_categories.xml`, `sitemap_blog.xml`. Signals freshness to Google for new deals specifically

2. **JSON-LD Product/Offer schema on every deal page** — this is the biggest gap. Every deal needs `price`, `priceValidUntil`, `availability`, `shippingDetails` in structured data so Google can surface them in the Deals carousel and AI Overviews. Zero schema = invisible to shopping graph

3. **Expired deal handling** — right now expired deals probably 404. Should 301 to the store page instead (e.g., expired Amazon deal → `/retailer/amazon`). Preserves link equity

4. **IndexNow** — push new deals to Bing/Yandex instantly via API call on publish. Easy win, 1-day build

**Medium term:**
- Verification timestamps on deal pages ("Verified active: Feb 19, 9:00 AM")
- Price history context ("Lowest price in 90 days")
- FAQ schema on retailer/store hub pages
- Deal editor persona pages for E-E-A-T

**Skip or defer:**
- Google Merchant Center (aggregator eligibility is murky, wait until we have traction)
- Wikipedia / Knowledge Panel (too early)
- Data journalism campaigns (need more data history first)

Want me to start on the JSON-LD schema and the sitemap index split? Those two alone would be the biggest jump. [[reply_to_current]]


Conversation info (untrusted metadata):
```json
{
  "message_id": "3206",
  "conversation_label": "Maya's Group Chat id:-5121721159",
  "sender": "6558388607",
  "group_subject": "Maya's Group Chat",
  "was_mentioned": true
}
```

Sender (untrusted metadata):
```json
{
  "label": "Skinny",
  "name": "Skinny",
  "username": "skinnyc813"
}
```

Replied message (untrusted, for context):
```json
{
  "sender_label": "Maya Cooper",
  "body": "AI Overviews. Zero schema = invisible to shopping graph\n\n3. Expired deal handling — right now expired deals probably 404. Should 301 to the store page instead (e.g., expired Amazon deal → /retailer/amazon). Preserves link equity\n4. IndexNow — push new deals to Bing/Yandex instantly via API call on publish. Easy win, 1-day build\n\nMedium term:\n\n• Verification timestamps on deal pages (\"Verified active: Feb 19, 9:00 AM\")\n• Price history context (\"Lowest price in 90 days\")\n• FAQ schema on retailer/store hub pages\n• Deal editor persona pages for E-E-A-T\n\nSkip or defer:\n\n• Google Merchant Center (aggregator eligibility is murky, wait until we have traction)\n• Wikipedia / Knowledge Panel (too early)\n• Data journalism campaigns (need more data history first)\n\nWant me to start on the"
}
```

Let's do everything you recommend 1-4 at the least


Skinny wants me to implement all 4 high-priority items:
1. Dynamic Sitemap Index (split into multiple sitemaps)
2. JSON-LD Product/Offer schema on every deal page
3. Expired deal handling (301 redirect to store page)
4. IndexNow integration

Let me tackle these systematically. This is a substantial amount of work so let me spawn a sub-agent to handle this in the background.

Actually, let me think about how to approach this:

1. **Dynamic Sitemap Index** - Modify the sitemap.xml.ts to output a sitemap index, and create separate sitemap files for different content types

2. **JSON-LD Schema** - Add structured data markup to the deal page template
3. **Expired deal handling** - Set up middleware or page logic to redirect expired deals to their store pages
4. **IndexNow** - Integrate an API call to IndexNow when deals are published on the backend

I should spawn a sub-agent for the Carl server work while I handle the frontend pieces. This is a lot of changes across both systems, so I need to be methodical and careful not to break anything. Let me start by rebuilding the sitemap into a proper index structure.

Now I need to look at the deal page template to understand its current structure before implementing the JSON-LD schema, expired deal redirects, and IndexNow integration.

This is a solid chunk of work across the frontend and backend. Let me spawn a sub-agent to handle it in parallel so we can move fast:

You are working on the GottaHaveCoups site (Carl server). SSH as `crogers2287@carl`. The backend is at `/opt/ghc20/backend` (FastAPI, port 8001), frontend at `/opt/ghc20/frontend` (Astro SSR, port 4321). Restart commands: `sudo systemctl restart ghc20-backend` and `sudo systemctl restart ghc20-frontend`. Rebuild frontend with `cd /opt/ghc20/frontend && sudo -u ghc20 npm run build`.

Implement these 4 SEO improvements:

---

## TASK 1: Dynamic Sitemap Index
Replace the single `sitemap.xml` with a sitemap index at `/sitemap_index.xml` plus segmented child sitemaps. Edit `/opt/ghc20/frontend/src/pages/sitemap.xml.ts` to return a sitemap INDEX pointing to:
- `/sitemap_realtime.xml` — deals updated in last 48 hours (highest priority, changefreq=hourly)
- `/sitemap_deals.xml` — all other active deals
- `/sitemap_blog.xml` — blog posts
- `/sitemap_static.xml` — static pages + categories

Then create these 4 new Astro API route files:
- `/opt/ghc20/frontend/src/pages/sitemap_realtime.xml.ts`
- `/opt/ghc20/frontend/src/pages/sitemap_deals.xml.ts`
- `/opt/ghc20/frontend/src/pages/sitemap_blog.xml.ts`
- `/opt/ghc20/frontend/src/pages/sitemap_static.xml.ts`

Each fetches from `http://localhost:8001/api/v1/deals/?page=X&page_size=100` (paginate through all pages). Use `lastmod` with W3C datetime format. The realtime sitemap only includes deals where `created_at` or `updated_at` is within the last 48 hours. Also update `/opt/ghc20/frontend/public/robots.txt` to point to `https://gottahavecoups.com/sitemap_index.xml`.

---

## TASK 2: JSON-LD Product/Offer Schema on Deal Pages
Edit `/opt/ghc20/frontend/src/pages/deal/[id].astro` to inject a `<script type="application/ld+json">` block in the `<head>` for each deal. Schema must include:
```json
{
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": "[deal title]",
  "image": "[deal image_url]",
  "description": "[deal description or title]",
  "brand": {"@type": "Brand", "name": "[store_name]"},
  "offers": {
    "@type": "Offer",
    "url": "https://gottahavecoups.com/deal/[id]",
    "priceCurrency": "USD",
    "price": "[price]",
    "priceValidUntil": "[expires_at or 1 year from now]",
    "itemCondition": "https://schema.org/NewCondition",
    "availability": "[InStock if active, else Discontinued]",
    "seller": {"@type": "Organization", "name": "[store_name]"},
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": {"@type": "MonetaryAmount", "value": "0", "currency": "USD"},
      "shippingDestination": {"@type": "DefinedRegion", "addressCountry": "US"}
    }
  }
}
```
Also add an `Organization` schema for the site itself on the homepage (`/opt/ghc20/frontend/src/pages/index.astro`):
```json
{
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "GottaHaveCoups",
  "url": "https://gottahavecoups.com",
  "logo": "https://gottahavecoups.com/images/logo.png",
  "description": "Deal aggregator featuring daily discounts from top retailers"
}
```

---

## TASK 3: Expired Deal Redirect (301 to Store Page)
In `/opt/ghc20/frontend/src/pages/deal/[id].astro`, check if the deal is inactive/expired. If `deal.is_active === false` or `deal.expires_at` is in the past:
- Look up the deal's `store_name` or `retailer_id`
- 301 redirect to `/retailer/[store-slug]` if a matching retailer page exists, otherwise redirect to `/`
- Use Astro's `return Astro.redirect('/retailer/amazon', 301)` pattern

Check what retailer/store pages exist first: `curl http://localhost:8001/api/v1/retailers/ | head` to see the slug format.

---

## TASK 4: IndexNow Integration
Add IndexNow pinging to the GHC backend so every new deal triggers an instant index request to Bing/Yandex.

1. Create `/opt/ghc20/backend/app/services/indexnow.py`:
```python
import httpx
import logging

logger = logging.getLogger(__name__)
INDEXNOW_KEY = "ghc20indexnow"
SITE_URL = "https://gottahavecoups.com"
INDEXNOW_ENDPOINTS = [
    "https://www.bing.com/indexnow",
    "https://yandex.com/indexnow",
]

async def ping_indexnow(urls: list[str]):
    payload = {
        "host": "gottahavecoups.com",
        "key": INDEXNOW_KEY,
        "keyLocation": f"{SITE_URL}/{INDEXNOW_KEY}.txt",
        "urlList": urls
    }
    async with httpx.AsyncClient() as client:
        for endpoint in INDEXNOW_ENDPOINTS:
            try:
                r = await client.post(endpoint, json=payload, timeout=5)
                logger.info(f"IndexNow {endpoint}: {r.status_code}")
            except Exception as e:
                logger.warning(f"IndexNow failed {endpoint}: {e}")
```

2. Create the key verification file at `/opt/ghc20/frontend/public/ghc20indexnow.txt` with content: `ghc20indexnow`

3. Find where new deals are created in the backend (check `/opt/ghc20/backend/app/api/endpoints/deals.py` or the RSS aggregator service). After a deal is saved, call `await ping_indexnow([f"https://gottahavecoups.com/deal/{deal.id}"])`.

---

After all changes:
1. Rebuild frontend: `cd /opt/ghc20/frontend && sudo -u ghc20 npm run build`
2. Restart backend: `sudo systemctl restart ghc20-backend`
3. Restart frontend: `sudo systemctl restart ghc20-frontend`
4. Verify: `curl -s https://gottahavecoups.com/sitemap_index.xml | head -20`
5. Verify: `curl -s https://gottahavecoups.com/deal/35712 | grep -o 'application/ld+json'`

Report back with what was completed, any errors, and what the sitemap index looks like.


gateway closed (1008): pairing required
Gateway target: ws://192.168.1.195:18789
Source: local lan 192.168.1.195
Config: /home/crogers2287/.openclaw/openclaw.json
Bind: lan


The gateway pairing is required for spawning sub-agents. Let me just do the work directly myself instead.

Gateway pairing issue. I'll do it directly — let me tackle all 4 sequentially:

exec

ssh carl "curl -s 'http://localhost:8001/api/v1/retailers/?limit=5' | python3 -c 'import sys,json; d=json.load(sys.stdin); items=d if isinstance(d,list) else d.get(\"retailers\",d.get(\"items\",[])); [print(x.get(\"id\"), x.get(\"slug\",\"\"), x.get(\"name\",\"\")) for x in items[:5]]'" 2>&1
ssh carl "curl -s 'http://localhost:8001/api/v1/deals/35712' | python3 -c 'import sys,json; d=json.load(sys.stdin); print({k:v for k,v in d.items() if k in [\"id\",\"title\",\"price\",\"is_active\",\"expires_at\",\"store_name\",\"image_url\",\"description\",\"created_at\"]})'" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
None amazon Amazon
None walmart Walmart
None home-depot Home Depot
None ebay eBay
None costco Costco
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
{'title': 'NECA 2017 IT:Pennywise - 8" Clothed Scale Action Figure', 'description': 'Amazon price drop:NECA 2017 IT:Pennywise - 8" Clothed Scale Action Figure - down 47.72% ($27.38) to $30.00 from $57.38', 'price': 30.0, 'image_url': 'https://www.gottahavecoups.com/api/v1/deal-images/0c8e80abfe55e400.jpg', 'store_name': 'Amazon', 'id': 35712, 'is_active': True, 'expires_at': None, 'created_at': '2026-02-19T01:10:00.614829Z'}


Good. Now let me look at the deal page template to understand the structure, then implement all 4 tasks.

Good. Now let me check the deal page and implement all 4 tasks:

ssh carl "head -80 /opt/ghc20/frontend/src/pages/deal/[id].astro" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getDeal } from '../../lib/api';

const { id } = Astro.params;

if (!id) {
  return Astro.redirect('/');
}

let deal;
try {
  deal = await getDeal(parseInt(id));
} catch (error) {
  return Astro.redirect('/');
}

const title = `${deal.title} - GottaHaveCoups`;
const description = deal.description || `Great deal on ${deal.title}!`;

// Use watermarked image for social sharing (OG/Twitter cards)
// Falls back to original image if watermark service unavailable
const shareImageUrl = deal.image_url ? `/api/v1/share/image/${deal.id}` : '/images/logo.png';
const displayImageUrl = deal.image_url || '/images/logo.png';

// Format UTC date to local date string
function formatDate(dateStr: string | null | undefined): string {
  if (!dateStr) return '';
  // Append 'Z' if no timezone indicator - database stores UTC
  let d = dateStr;
  if (!d.includes('Z') && !d.includes('+') && !d.includes('-', 10)) {
    d = d.replace(' ', 'T') + 'Z';
  }
  return new Date(d).toLocaleDateString();
}
---

<BaseLayout title={title} description={description} image={shareImageUrl}>

        ← Back to All Deals

        {deal.image_url && (

        )}

{deal.title}

            {deal.is_hot && (

                🔥 HOT

            )}
            {deal.is_featured && (

                ⭐ Featured

            )}
            {deal.quality_score && deal.quality_score >= 7 && (

                ⭐ {deal.quality_score.toFixed(1)}/10

            )}

          {deal.price !== null && (


Good. Now I have enough context to implement all 4 tasks. Let me do them all. I'll write the code locally and SCP to Carl.

**Task 1: Dynamic Sitemap Index**
- Modify sitemap.xml.ts to output a sitemap index
- Create 4 child sitemap files

**Task 2: JSON-LD Schema on deal pages**
- Modify [id].astro to add JSON-LD

**Task 3: Expired deal redirect**
- In [id].astro, check if deal is expired and redirect

**Task 4: IndexNow**
- Create indexnow.py service
- Create key file

- Wire it up to trigger on deal creation

Now let me start writing all the files.

Good. Writing all 4 in sequence. Starting with the sitemap index:

write

import type { APIRoute } from 'astro';

export const GET: APIRoute = async () => {
  const now = new Date().toISOString().split('T')[0];
  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://gottahavecoups.com/sitemap_realtime.xml</loc>
    <lastmod>${now}</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://gottahavecoups.com/sitemap_deals.xml</loc>
    <lastmod>${now}</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://gottahavecoups.com/sitemap_blog.xml</loc>
    <lastmod>${now}</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://gottahavecoups.com/sitemap_static.xml</loc>
    <lastmod>${now}</lastmod>
  </sitemap>
</sitemapindex>`;

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=3600',
    },
  });
};


Successfully wrote 888 bytes to /tmp/sitemap_index.ts


Now create the sitemap_realtime.xml.ts - deals from last 48 hours

import type { APIRoute } from 'astro';

const API_BASE = 'http://localhost:8001';
const SITE_URL = 'https://gottahavecoups.com';

async function fetchJSON(url: string): Promise<any> {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
    if (!res.ok) return null;
    return await res.json();
  } catch { return null; }
}

export const GET: APIRoute = async () => {
  const cutoff = new Date(Date.now() - 48 * 60 * 60 * 1000);
  const urls: string[] = [];

  let page = 1;
  let hasMore = true;
  while (hasMore && page <= 20) {
    const data = await fetchJSON(`${API_BASE}/api/v1/deals/?page=${page}&page_size=100`);
    if (!data) break;
    const deals: any[] = Array.isArray(data) ? data : data.deals || data.items || [];
    const totalPages = data.total_pages || 1;
    if (deals.length === 0) break;

    for (const deal of deals) {
      if (!deal.id) continue;
      const updated = deal.updated_at || deal.created_at;
      if (updated && new Date(updated) >= cutoff) {
        const lastmod = new Date(updated).toISOString();
        urls.push(`  <url>
    <loc>${SITE_URL}/deal/${deal.id}</loc>
    <lastmod>${lastmod}</lastmod>
    <changefreq>hourly</changefreq>
    <priority>1.0</priority>
  </url>`);
      }
    }
    hasMore = page < totalPages;
    page++;
  }

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=1800',
    },
  });
};


Successfully wrote 1609 bytes to /tmp/sitemap_realtime.ts


Now create sitemap_deals.xml.ts - all active deals older than 48 hours

import type { APIRoute } from 'astro';

const API_BASE = 'http://localhost:8001';
const SITE_URL = 'https://gottahavecoups.com';

async function fetchJSON(url: string): Promise<any> {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
    if (!res.ok) return null;
    return await res.json();
  } catch { return null; }
}

export const GET: APIRoute = async () => {
  const cutoff = new Date(Date.now() - 48 * 60 * 60 * 1000);
  const urls: string[] = [];

  let page = 1;
  let hasMore = true;
  while (hasMore && page <= 50) {
    const data = await fetchJSON(`${API_BASE}/api/v1/deals/?page=${page}&page_size=100`);
    if (!data) break;
    const deals: any[] = Array.isArray(data) ? data : data.deals || data.items || [];
    const totalPages = data.total_pages || 1;
    if (deals.length === 0) break;

    for (const deal of deals) {
      if (!deal.id) continue;
      const updated = deal.updated_at || deal.created_at;
      // Only deals older than 48h (realtime sitemap covers fresh ones)
      if (updated && new Date(updated) < cutoff) {
        const lastmod = new Date(updated).toISOString();
        urls.push(`  <url>
    <loc>${SITE_URL}/deal/${deal.id}</loc>
    <lastmod>${lastmod}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>`);
      }
    }
    hasMore = page < totalPages;
    page++;
  }

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=86400',
    },
  });
};


Successfully wrote 1681 bytes to /tmp/sitemap_deals.ts


Now the blog sitemap and static sitemap.

import type { APIRoute } from 'astro';

const API_BASE = 'http://localhost:8001';
const SITE_URL = 'https://gottahavecoups.com';

async function fetchJSON(url: string): Promise<any> {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
    if (!res.ok) return null;
    return await res.json();
  } catch { return null; }
}

export const GET: APIRoute = async () => {
  const urls: string[] = [];

  const data = await fetchJSON(`${API_BASE}/api/v1/blog/?page=1&page_size=500&is_published=true`);
  if (data) {
    const posts: any[] = Array.isArray(data) ? data : data.posts || data.items || [];
    for (const post of posts) {
      const slug = post.slug || post.id;
      if (!slug || post.is_published === false) continue;
      const updated = post.updated_at || post.published_at || post.created_at;
      const lastmod = updated ? new Date(updated).toISOString() : undefined;
      urls.push(`  <url>
    <loc>${SITE_URL}/blog/${slug}</loc>${lastmod ? `\n    <lastmod>${lastmod}</lastmod>` : ''}
    <changefreq>monthly</changefreq>
    <priority>0.7</priority>
  </url>`);
    }
  }

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=86400',
    },
  });
};


Successfully wrote 1418 bytes to /tmp/sitemap_blog.ts


Now the static sitemap (static pages + categories + retailers).

import type { APIRoute } from 'astro';

const API_BASE = 'http://localhost:8001';
const SITE_URL = 'https://gottahavecoups.com';

const STATIC_PAGES = [
  { url: '/', changefreq: 'daily', priority: '1.0' },
  { url: '/categories', changefreq: 'daily', priority: '0.8' },
  { url: '/retailers', changefreq: 'weekly', priority: '0.7' },
  { url: '/blog', changefreq: 'daily', priority: '0.8' },
  { url: '/newsletter', changefreq: 'monthly', priority: '0.5' },
  { url: '/archive', changefreq: 'daily', priority: '0.6' },
  { url: '/privacy', changefreq: 'yearly', priority: '0.2' },
  { url: '/terms', changefreq: 'yearly', priority: '0.2' },
];

async function fetchJSON(url: string): Promise<any> {
  try {
    const res = await fetch(url, { signal: AbortSignal.timeout(8000) });
    if (!res.ok) return null;
    return await res.json();
  } catch { return null; }
}

export const GET: APIRoute = async () => {
  const urls: string[] = [];

  for (const page of STATIC_PAGES) {
    urls.push(`  <url>
    <loc>${SITE_URL}${page.url}</loc>
    <changefreq>${page.changefreq}</changefreq>
    <priority>${page.priority}</priority>
  </url>`);
  }

  // Categories
  const catData = await fetchJSON(`${API_BASE}/api/v1/categories/`);
  if (catData) {
    const cats: any[] = Array.isArray(catData) ? catData : catData.categories || [];
    for (const cat of cats) {
      const slug = cat.slug || (cat.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-');
      if (slug) {
        urls.push(`  <url>
    <loc>${SITE_URL}/category/${slug}</loc>
    <changefreq>daily</changefreq>
    <priority>0.7</priority>
  </url>`);
      }
    }
  }

  // Retailers
  const retData = await fetchJSON(`${API_BASE}/api/v1/retailers/?limit=200`);
  if (retData) {
    const retailers: any[] = Array.isArray(retData) ? retData : retData.retailers || retData.items || [];
    for (const r of retailers) {
      const slug = r.slug || (r.name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-');
      if (slug) {
        urls.push(`  <url>
    <loc>${SITE_URL}/retailer/${slug}</loc>
    <changefreq>daily</changefreq>
    <priority>0.7</priority>
  </url>`);
      }
    }
  }

  const xml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls.join('\n')}
</urlset>`;

  return new Response(xml, {
    headers: {
      'Content-Type': 'application/xml',
      'Cache-Control': 'public, max-age=86400',
    },
  });
};


Successfully wrote 2456 bytes to /tmp/sitemap_static.ts


Now the IndexNow service and deal page JSON-LD:

"""
IndexNow integration - push new deal URLs to Bing/Yandex for instant indexing.
"""
import httpx
import logging

logger = logging.getLogger(__name__)

INDEXNOW_KEY = "ghc20indexnow2026"
SITE_URL = "https://gottahavecoups.com"
INDEXNOW_ENDPOINTS = [
    "https://www.bing.com/indexnow",
    "https://yandex.com/indexnow",
]

async def ping_indexnow(urls: list[str]) -> None:
    """Ping IndexNow endpoints with new/updated URLs."""
    if not urls:
        return

    payload = {
        "host": "gottahavecoups.com",
        "key": INDEXNOW_KEY,
        "keyLocation": f"{SITE_URL}/{INDEXNOW_KEY}.txt",
        "urlList": urls[:10000],  # IndexNow limit
    }

    async with httpx.AsyncClient(timeout=5.0) as client:
        for endpoint in INDEXNOW_ENDPOINTS:
            try:
                r = await client.post(endpoint, json=payload)
                logger.info(f"IndexNow {endpoint}: {r.status_code}")
            except Exception as e:
                logger.warning(f"IndexNow ping failed {endpoint}: {e}")

async def ping_deal(deal_id: int) -> None:
    """Ping IndexNow for a single deal page."""
    await ping_indexnow([f"{SITE_URL}/deal/{deal_id}"])


Successfully wrote 1171 bytes to /tmp/indexnow.py


Now I need to modify the deal page to add JSON-LD schema and expired deal redirect. Let me look at the full deal page template to figure out the best place to insert the schema.

Now let me grab the full deal page to insert the JSON-LD and redirect logic:

exec

ssh carl "wc -l /opt/ghc20/frontend/src/pages/deal/[id].astro && sed -n '1,40p' /opt/ghc20/frontend/src/pages/deal/[id].astro" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
136 /opt/ghc20/frontend/src/pages/deal/[id].astro
---
import BaseLayout from '../../layouts/BaseLayout.astro';
import { getDeal } from '../../lib/api';

const { id } = Astro.params;

if (!id) {
  return Astro.redirect('/');
}

let deal;
try {
  deal = await getDeal(parseInt(id));
} catch (error) {
  return Astro.redirect('/');
}

const title = `${deal.title} - GottaHaveCoups`;
const description = deal.description || `Great deal on ${deal.title}!`;

// Use watermarked image for social sharing (OG/Twitter cards)
// Falls back to original image if watermark service unavailable
const shareImageUrl = deal.image_url ? `/api/v1/share/image/${deal.id}` : '/images/logo.png';
const displayImageUrl = deal.image_url || '/images/logo.png';

// Format UTC date to local date string
function formatDate(dateStr: string | null | undefined): string {
  if (!dateStr) return '';
  // Append 'Z' if no timezone indicator - database stores UTC
  let d = dateStr;
  if (!d.includes('Z') && !d.includes('+') && !d.includes('-', 10)) {
    d = d.replace(' ', 'T') + 'Z';
  }
  return new Date(d).toLocaleDateString();
}
---

<BaseLayout title={title} description={description} image={shareImageUrl}>


Now I need to modify the deal page to:
1. Check if the deal is expired and redirect
2. Add JSON-LD schema
3. The schema needs to be in the `<head>` - I'll need to check if BaseLayout accepts a `head` slot or if I need to pass it as a prop

Let me check the BaseLayout to see how to inject things into the head.

ssh carl "grep -n 'slot\|head\|schema\|ld+json' /opt/ghc20/frontend/src/layouts/BaseLayout.astro | head -20" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
32:  "@context": "https://schema.org",
48:  "@context": "https://schema.org",
56:// Build dynamic head tags as raw HTML string (Astro SSR drops {expressions} in head)
59:  <script type="application/ld+json">${websiteSchema}</script>
60:  <script type="application/ld+json">${organizationSchema}</script>
69:  <head>
112:  </head>
115:      <!-- Promotional Banner (above header) -->
118:      <header class="w-full border-b bg-white/95 backdrop-blur-sm sticky top-0 z-50">
166:      </header>
172:        <slot />


Great - BaseLayout already has JSON-LD schema built in (for website and org). It also has a `slot` for content. Let me check if it has a `head` slot for adding additional head elements.

ssh carl "sed -n '55,115p' /opt/ghc20/frontend/src/layouts/BaseLayout.astro" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.

// Build dynamic head tags as raw HTML string (Astro SSR drops {expressions} in head)
const dynamicHeadTags = `
  <link rel="canonical" href="${canonicalUrl}" />
  <script type="application/ld+json">${websiteSchema}</script>
  <script type="application/ld+json">${organizationSchema}</script>
  <meta property="og:url" content="${canonicalUrl}" />
  <script src="https://www.googletagmanager.com/gtag/js?id=G-25SKV5ZJXM"></script>
  <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-25SKV5ZJXM");</script>
`;
---

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="description" content={description} />
    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
    <link rel="icon" type="image/x-icon" href="/favicon.ico" />
    <link rel="icon" type="image/png" sizes="32x32" href="/favicon.png" />
    <link rel="icon" type="image/png" sizes="16x16" href="/images/icons/favicon-16x16.png" />

    <!-- PWA Meta Tags -->
    <link rel="manifest" href="/manifest.webmanifest" />
    <meta name="theme-color" content="#F59E0B" />
    <meta name="mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-capable" content="yes" />
    <meta name="apple-mobile-web-app-status-bar-style" content="default" />
    <meta name="apple-mobile-web-app-title" content="GottaHaveCoups" />
    <link rel="apple-touch-icon" href="/images/icons/favicon-180x180.png" />
    <link rel="apple-touch-icon" sizes="152x152" href="/images/icons/favicon-180x180.png" />
    <link rel="apple-touch-icon" sizes="180x180" href="/images/icons/favicon-180x180.png" />
    <link rel="apple-touch-icon" sizes="167x167" href="/images/icons/favicon-180x180.png" />

    <!-- Open Graph / Facebook -->
    <meta property="og:type" content="website" />
    <meta property="og:title" content={title} />
    <meta property="og:description" content={description} />
    <meta property="og:image" content={absoluteImageUrl} />

    <!-- Twitter -->
    <meta name="twitter:card" content="summary_large_image" />
    <meta name="twitter:title" content={title} />
    <meta name="twitter:description" content={description} />
    <meta name="twitter:image" content={absoluteImageUrl} />

    <link rel="preconnect" href="https://fonts.googleapis.com" />
    <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
    <link
      href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Poppins:wght@600;700;800&display=swap"
      rel="stylesheet"
    />
    <meta name="generator" content={Astro.generator} />
    <title>{title}</title>
    <Fragment set:html={dynamicHeadTags} />
  </head>
  <body class="min-h-screen bg-gradient-to-br from-amber-50 via-white to-yellow-50">
    <div class="flex flex-col min-h-screen">
      <!-- Promotional Banner (above header) -->


I see that BaseLayout uses `<Fragment set:html={dynamicHeadTags} />` to inject head content. I need to add a way to pass additional head content (like product-level JSON-LD schema) to the layout.

The cleanest approach is to:
1. Add a `schema` prop to BaseLayout that accepts a JSON-LD string
2. Inject it in the head alongside the existing schemas

Let me modify:
1. BaseLayout to accept a `schema` prop
2. Deal page to pass the Product/Offer schema
3. Add expired deal redirect in the deal page frontmatter

Let me write all the patches.

Good — I'll pass the JSON-LD via BaseLayout's `dynamicHeadTags`. Now building all changes:

ssh carl "sed -n '1,55p' /opt/ghc20/frontend/src/layouts/BaseLayout.astro" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
---
import '../styles/global.css';
import MobileMenu from '../components/MobileMenu';
import InstallBanner from '../components/islands/InstallBanner';
import PromoBanner from '../components/PromoBanner';
import HeaderInstallButton from '../components/islands/HeaderInstallButton';
import PWASplashScreen from '../components/islands/PWASplashScreen';
import CategorySwipeBar from '../components/islands/CategorySwipeBar';

interface Props {
  title?: string;
  description?: string;
  image?: string;
}

const {
  title = 'GottaHaveCoups - Feather-ruffling Deals Daily!',
  description = 'Discover the best deals, coupons, and discounts from across the web. Fresh deals daily!',
  image = '/images/og-image.jpg'
} = Astro.props;

// Convert relative image URLs to absolute URLs for social media crawlers
// They need full URLs like https://example.com/api/v1/share/image/123
const siteUrl = 'https://gottahavecoups.com';
const absoluteImageUrl = image.startsWith('http') ? image : `${siteUrl}${image}`;

// Canonical URL - use the current page path
const canonicalUrl = `${siteUrl}${Astro.url.pathname}`;

// Schema.org structured data
const websiteSchema = JSON.stringify({
  "@context": "https://schema.org",
  "@type": "WebSite",
  "name": "GottaHaveCoups",
  "url": siteUrl,
  "description": "Discover the best deals, coupons, and discounts from across the web. Fresh deals daily!",
  "potentialAction": {
    "@type": "SearchAction",
    "target": {
      "@type": "EntryPoint",
      "urlTemplate": `${siteUrl}/search?q={search_term_string}`
    },
    "query-input": "required name=search_term_string"
  }
});

const organizationSchema = JSON.stringify({
  "@context": "https://schema.org",
  "@type": "Organization",
  "name": "GottaHaveCoups",
  "url": siteUrl,
  "logo": `${siteUrl}/images/logotrans.png`,
  "sameAs": []
});


The BaseLayout has a `Props` interface with `title`, `description`, `image`. I need to add a `schema` prop. Let me create patches to:
1. Add `schema?: string` prop to BaseLayout
2. Inject it in dynamicHeadTags
3. Modify the deal page to pass schema + handle expired redirects
4. Install all the sitemap files
5. Install IndexNow service
6. Wire IndexNow to deal creation

cat > /tmp/patch_baselayout.py << 'EOF'
import subprocess

result = subprocess.run(['ssh', 'carl', 'sudo cat /opt/ghc20/frontend/src/layouts/BaseLayout.astro'], capture_output=True, text=True)
content = result.stdout

# 1. Add schema prop to interface
old_interface = """interface Props {
  title?: string;
  description?: string;
  image?: string;
}

const {
  title = 'GottaHaveCoups - Feather-ruffling Deals Daily!',
  description = 'Discover the best deals, coupons, and discounts from across the web. Fresh deals daily!',
  image = '/images/og-image.jpg'
} = Astro.props;"""

new_interface = """interface Props {
  title?: string;
  description?: string;
  image?: string;
  schema?: string;
}

const {
  title = 'GottaHaveCoups - Feather-ruffling Deals Daily!',
  description = 'Discover the best deals, coupons, and discounts from across the web. Fresh deals daily!',
  image = '/images/og-image.jpg',
  schema
} = Astro.props;"""

# 2. Inject schema into dynamicHeadTags
old_dynamic = """  <script src="https://www.googletagmanager.com/gtag/js?id=G-25SKV5ZJXM"></script>
  <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-25SKV5ZJXM");</script>
`;"""

new_dynamic = """  <script src="https://www.googletagmanager.com/gtag/js?id=G-25SKV5ZJXM"></script>
  <script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag("js",new Date());gtag("config","G-25SKV5ZJXM");</script>
  ${schema ? `<script type="application/ld+json">${schema}</script>` : ''}
`;"""

if old_interface in content:
    content = content.replace(old_interface, new_interface)
    print("Interface patched")
else:
    print("Interface pattern not found")

if old_dynamic in content:
    content = content.replace(old_dynamic, new_dynamic)
    print("Dynamic tags patched")
else:
    print("Dynamic tags pattern not found")
    # Try to find it
    idx = content.find('G-25SKV5ZJXM')
    print("Found GTM at:", idx)
    print(repr(content[idx:idx+200]))

# Write to temp file
with open('/tmp/BaseLayout_patched.astro', 'w') as f:
    f.write(content)
print("Written to /tmp/BaseLayout_patched.astro")
EOF
python3 /tmp/patch_baselayout.py


Interface patched
Dynamic tags patched
Written to /tmp/BaseLayout_patched.astro


Now I need to modify the deal page to:1. Add expired deal redirect2. Pass Product/Offer JSON-LD schema to BaseLayout
Let me write the patch.

cat > /tmp/patch_dealpage.py << 'EOF'
import subprocess

result = subprocess.run(['ssh', 'carl', 'sudo cat /opt/ghc20/frontend/src/pages/deal/[id].astro'], capture_output=True, text=True)
content = result.stdout

# Find insertion point - after deal is fetched, before const title line
old_after_deal = """const title = `${deal.title} - GottaHaveCoups`;
const description = deal.description || `Great deal on ${deal.title}!`;"""

new_after_deal = """// Task 3: Expired deal redirect
if (deal.is_active === false) {
  const storeSlug = (deal.store_name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, '');
  return Astro.redirect(storeSlug ? `/retailer/${storeSlug}` : '/', 301);
}
if (deal.expires_at && new Date(deal.expires_at) < new Date()) {
  const storeSlug = (deal.store_name || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/-+$/, '');
  return Astro.redirect(storeSlug ? `/retailer/${storeSlug}` : '/', 301);
}

const title = `${deal.title} - GottaHaveCoups`;
const description = deal.description || `Great deal on ${deal.title}!`;"""

# Task 2: Build JSON-LD schema to pass to BaseLayout
old_display = """const displayImageUrl = deal.image_url || '/images/logo.png';"""

new_display = """const displayImageUrl = deal.image_url || '/images/logo.png';

// Task 2: Product/Offer JSON-LD schema for Google Shopping Graph
const priceValidUntil = deal.expires_at
  ? new Date(deal.expires_at).toISOString().split('T')[0]
  : new Date(Date.now() + 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];

const productSchema = JSON.stringify({
  "@context": "https://schema.org/",
  "@type": "Product",
  "name": deal.title,
  "image": deal.image_url ? [deal.image_url] : undefined,
  "description": deal.description || deal.title,
  "brand": deal.store_name ? { "@type": "Brand", "name": deal.store_name } : undefined,
  "offers": {
    "@type": "Offer",
    "url": `https://gottahavecoups.com/deal/${deal.id}`,
    "priceCurrency": "USD",
    "price": deal.price ? String(deal.price) : undefined,
    "priceValidUntil": priceValidUntil,
    "itemCondition": "https://schema.org/NewCondition",
    "availability": "https://schema.org/InStock",
    "seller": deal.store_name ? { "@type": "Organization", "name": deal.store_name } : undefined,
    "shippingDetails": {
      "@type": "OfferShippingDetails",
      "shippingRate": { "@type": "MonetaryAmount", "value": "0", "currency": "USD" },
      "shippingDestination": { "@type": "DefinedRegion", "addressCountry": "US" }
    }
  }
});"""

# Update BaseLayout call to pass schema
old_baselayout = """<BaseLayout title={title} description={description} image={shareImageUrl}>"""
new_baselayout = """<BaseLayout title={title} description={description} image={shareImageUrl} schema={productSchema}>"""

changed = False
if old_after_deal in content:
    content = content.replace(old_after_deal, new_after_deal)
    print("Redirect logic inserted")
    changed = True
else:
    print("Redirect insertion point not found")

if old_display in content:
    content = content.replace(old_display, new_display)
    print("Schema generation inserted")
    changed = True
else:
    print("Schema insertion point not found")

if old_baselayout in content:
    content = content.replace(old_baselayout, new_baselayout)
    print("BaseLayout prop updated")
    changed = True
else:
    print("BaseLayout prop pattern not found")

with open('/tmp/dealpage_patched.astro', 'w') as f:
    f.write(content)
print("Written" if changed else "No changes made")
EOF
python3 /tmp/patch_dealpage.py


Redirect logic inserted
Schema generation inserted
BaseLayout prop updated
Written


Now let me deploy everything to Carl. I'll:
1. SCP all the files to Carl
2. Run the deployment

All patches ready. Now deploy everything:

# SCP all files to Carl
scp /tmp/sitemap_index.ts carl:/tmp/sitemap_index.ts
scp /tmp/sitemap_realtime.ts carl:/tmp/sitemap_realtime.ts
scp /tmp/sitemap_deals.ts carl:/tmp/sitemap_deals.ts
scp /tmp/sitemap_blog.ts carl:/tmp/sitemap_blog.ts
scp /tmp/sitemap_static.ts carl:/tmp/sitemap_static.ts
scp /tmp/BaseLayout_patched.astro carl:/tmp/BaseLayout_patched.astro
scp /tmp/dealpage_patched.astro carl:/tmp/dealpage_patched.astro
scp /tmp/indexnow.py carl:/tmp/indexnow.py
echo "All files copied"


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
All files copied


Conversation info (untrusted metadata):
```json
{
  "message_id": "3216",
  "conversation_label": "Maya's Group Chat id:-5121721159",
  "sender": "6558388607",
  "group_subject": "Maya's Group Chat",
  "was_mentioned": true
}
```

Sender (untrusted metadata):
```json
{
  "label": "Skinny",
  "name": "Skinny",
  "username": "skinnyc813"
}
```

Replied message (untrusted, for context):
```json
{
  "sender_label": "Maya Cooper",
  "body": "Good — I'll pass the JSON-LD via BaseLayout's dynamicHeadTags. Now building all changes:"
}
```

Make sure this implementation doesn't break anything we already have working.


Skinny wants me to be careful not to break anything. Let me be careful in the deployment. I should:
1. Back up files before overwriting
2. Test each change carefully
3. Validate syntax before deploying
4. Check that the build succeeds before restarting services

Let me deploy carefully.

Smart. Backing up everything first, then deploying with validation at each step:

ssh carl "
# Backup all files we're touching
sudo cp /opt/ghc20/frontend/src/layouts/BaseLayout.astro /opt/ghc20/frontend/src/layouts/BaseLayout.astro.bak
sudo cp '/opt/ghc20/frontend/src/pages/deal/[id].astro' '/opt/ghc20/frontend/src/pages/deal/[id].astro.bak'
sudo cp /opt/ghc20/frontend/src/pages/sitemap.xml.ts /opt/ghc20/frontend/src/pages/sitemap.xml.ts.bak
echo 'Backups done'
ls -la /opt/ghc20/frontend/src/layouts/BaseLayout.astro.bak
ls -la '/opt/ghc20/frontend/src/pages/deal/[id].astro.bak'
" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Backups done
-rw-r--r-- 1 root root 10340 Feb 19 01:49 /opt/ghc20/frontend/src/layouts/BaseLayout.astro.bak
-rw-r--r-- 1 root root 4858 Feb 19 01:49 /opt/ghc20/frontend/src/pages/deal/[id].astro.bak


Good. Backups are in place. Now let me deploy the files and validate them step by step.

Good. Now deploying one step at a time with a build check after each major change:

# Step 1: Deploy sitemap files (low risk - new files only)
ssh carl "
sudo cp /tmp/sitemap_index.ts /opt/ghc20/frontend/src/pages/sitemap_index.xml.ts
sudo cp /tmp/sitemap_realtime.ts /opt/ghc20/frontend/src/pages/sitemap_realtime.xml.ts
sudo cp /tmp/sitemap_deals.ts /opt/ghc20/frontend/src/pages/sitemap_deals.xml.ts
sudo cp /tmp/sitemap_blog.ts /opt/ghc20/frontend/src/pages/sitemap_blog.xml.ts
sudo cp /tmp/sitemap_static.ts /opt/ghc20/frontend/src/pages/sitemap_static.xml.ts
sudo chown ghc20:ghc20 /opt/ghc20/frontend/src/pages/sitemap_*.xml.ts
echo 'Sitemap files deployed'
ls /opt/ghc20/frontend/src/pages/sitemap*.xml.ts
" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Sitemap files deployed
/opt/ghc20/frontend/src/pages/sitemap.xml.ts
/opt/ghc20/frontend/src/pages/sitemap_blog.xml.ts
/opt/ghc20/frontend/src/pages/sitemap_deals.xml.ts
/opt/ghc20/frontend/src/pages/sitemap_index.xml.ts
/opt/ghc20/frontend/src/pages/sitemap_realtime.xml.ts
/opt/ghc20/frontend/src/pages/sitemap_static.xml.ts


Now let me deploy the IndexNow key file, the Python service, and then the riskier frontend changes.

# Step 2: Deploy IndexNow key file and service
ssh carl "
# Key verification file (served as static file)
echo 'ghc20indexnow2026' | sudo tee /opt/ghc20/frontend/public/ghc20indexnow2026.txt > /dev/null

# IndexNow service
sudo cp /tmp/indexnow.py /opt/ghc20/backend/app/services/indexnow.py
sudo chown ghc20:ghc20 /opt/ghc20/backend/app/services/indexnow.py

# Verify Python syntax
python3 -c 'import ast; ast.parse(open(\"/opt/ghc20/backend/app/services/indexnow.py\").read()); print(\"indexnow.py syntax OK\")'
" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
indexnow.py syntax OK


Now I need to wire IndexNow into the deal creation flow. Let me check where deals are created in the backend to find the right place to call ping_deal.

ssh carl "grep -n 'def create\|async def.*deal\|db.add\|db.commit\|is_active.*True' /opt/ghc20/backend/app/api/endpoints/deals.py | head -20" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
54:async def get_deals(
108:        DealModel.is_active == True,
264:async def get_deal_stats(
277:    active_query = select(func.count()).select_from(DealModel).where(DealModel.is_active == True)
283:        DealModel.is_active == True,
291:        DealModel.is_active == True,
315:async def get_featured_deals(
328:        .where(DealModel.is_active == True)
343:async def get_hot_deals(
356:        .where(DealModel.is_active == True)
371:async def get_trending_deals(
386:        .where(DealModel.is_active == True)
408:            .where(DealModel.is_active == True)
434:            .where(DealModel.is_active == True)
452:async def get_latest_deals(
470:        .where(DealModel.is_active == True)
484:async def get_deals_by_category(
525:        .where(DealModel.is_active == True)
605:        .where(DealModel.is_active == True)
647:            DealModel.is_active == True,


The deals endpoint doesn't seem to have a create endpoint. Deals are created via the aggregator/scraper. Let me check where deals are actually created in the system.

ssh carl "grep -rn 'db.add\|db.commit\|Deal(' /opt/ghc20/backend/app/services/ --include='*.py' | grep -i 'deal\|create\|save' | head -15" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
/opt/ghc20/backend/app/services/email_deal_parser.py:232:        return ParsedDeal(
/opt/ghc20/backend/app/services/email_deal_parser.py:335:        return ParsedDeal(


Let me check the aggregator service where deals are actually created.

ssh carl "grep -rn 'db.add\|\.add(' /opt/ghc20/backend/app/ --include='*.py' | grep -i 'deal\|Deal' | head -20" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
/opt/ghc20/backend/app/api/endpoints/blog.py:268:                    seen_deal_ids.add(deal.id)
/opt/ghc20/backend/app/api/endpoints/blog.py:288:                    seen_deal_ids.add(deal.id)
/opt/ghc20/backend/app/api/endpoints/quick_deal.py:335:        db.add(new_deal)
/opt/ghc20/backend/app/api/endpoints/aggregator.py:776:                        db.add(qa_deal)
/opt/ghc20/backend/app/api/endpoints/aggregator.py:779:                        db.add(new_deal)
/opt/ghc20/backend/app/api/endpoints/aggregator.py:781:                    db.add(new_deal)
/opt/ghc20/backend/app/api/endpoints/aggregator.py:783:                existing_urls.add(deal_url)
/opt/ghc20/backend/app/api/endpoints/deals.py:427:                existing_ids.add(fallback_deal.id)
/opt/ghc20/backend/app/api/endpoints/deals.py:446:                existing_ids.add(newest_deal.id)
/opt/ghc20/backend/app/api/endpoints/deals.py:917:    db.add(deal)
/opt/ghc20/backend/app/api/endpoints/qa.py:254:    db.add(deal)
/opt/ghc20/backend/app/tasks/email_tasks.py:123:                        session.add(pending_deal)
/opt/ghc20/backend/app/tasks/qa_tasks.py:93:                session.add(pending_deal)
/opt/ghc20/backend/app/tasks/qa_tasks.py:411:                session.add(deal)
/opt/ghc20/backend/app/tasks/qa_tasks.py:425:                promoted_deal_ids.add(pending.id)
/opt/ghc20/backend/app/tasks/scraper_tasks.py:587:                session.add(pending_deal)


The main deal creation happens in `aggregator.py` at line 781, and also in `scraper_tasks.py` at line 587, and `qa_tasks.py` (which promotes pending deals). The most important one to instrument is the aggregator and the qa_tasks (which promotes pending to active).

Let me check the aggregator at line 779-785 to understand the context.

ssh carl "sed -n '770,800p' /opt/ghc20/backend/app/api/endpoints/aggregator.py" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
                            content_hash=content_hash,
                            qa_status="pending",  # Start as pending for QA processing
                            auto_approve_at=auto_approve_time,
                            source="aggregator",
                            feed_name=deal_data.get("source"),
                        )
                        db.add(qa_deal)
                    except ImportError:
                        # QA model doesn't exist, save directly
                        db.add(new_deal)
                else:
                    db.add(new_deal)

                existing_urls.add(deal_url)
                imported += 1
                imported_deals.append({
                    "title": new_deal.title[:100],
                    "source": deal_data.get("source"),
                    "price": new_deal.price
                })

            except Exception as e:
                logger.error(f"Error importing deal: {e}")
                errors += 1

        await db.commit()

    pipeline_msg = "QA pipeline" if request.use_qa_pipeline else "directly to database"
    return ImportResult(
        imported=imported,
        skipped=skipped,


The aggregator does `await db.commit()` after adding all deals. The best place to wire in IndexNow is after the commit, calling `ping_indexnow` for all the newly imported deals. But this is more complex as I need to batch the URLs.

Let me look at the qa_tasks.py where deals get promoted from pending to active - that might be the best place to ping IndexNow since that's when deals actually become live.

ssh carl "sed -n '400,435p' /opt/ghc20/backend/app/tasks/qa_tasks.py" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
                    feed_name=deal_data.get('feed_name'),
                    content_hash=deal_data.get('content_hash'),
                    published_at=published_at,
                    expires_at=expires_at,
                    retailer_links=deal_data.get('retailer_links'),
                    is_active=True,
                    is_featured=badges.is_featured,
                    is_hot=badges.is_hot,
                    is_limited_time=badges.is_limited_time,
                )

                session.add(deal)
                await session.flush()  # Get the new deal ID

                # Update pending deal status
                pending.qa_status = QAStatus.APPROVED.value
                pending.promoted_deal_id = deal.id

                # Add to existing_deals so subsequent deals in this batch can see it
                existing_deals.append({
                    'id': deal.id,
                    'title': deal.title,
                    'content_hash': deal.content_hash,
                    'deal_url': deal.deal_url,
                })
                promoted_deal_ids.add(pending.id)

                promoted_count += 1

            except Exception as e:
                logger.error(f"Failed to promote deal {pending.id}: {e}")
                continue

        await session.commit()

    logger.info(f"Promoted {promoted_count} deals to production, rejected {rejected_as_duplicate} duplicates")


I can see that after `await session.commit()`, we should ping IndexNow with the promoted deals. Let me patch the qa_tasks.py to call IndexNow after commit.

Good — deals go live via `qa_tasks.py` after QA promotion. Let me wire in IndexNow there:

ssh carl "sudo python3 << 'EOF'
with open('/opt/ghc20/backend/app/tasks/qa_tasks.py', 'r') as f:
    content = f.read()

# Add import at top
if 'from app.services.indexnow' not in content:
    content = content.replace(
        'import logging\n',
        'import logging\nfrom app.services.indexnow import ping_indexnow\n'
    )
    print('Import added')

# Wire in after session.commit() at the promotion step
old = \"\"\"        await session.commit()

    logger.info(f\"Promoted {promoted_count} deals to production, rejected {rejected_as_duplicate} duplicates\")\"\"\"

new = \"\"\"        await session.commit()

        # Ping IndexNow for all newly promoted deals
        if promoted_deal_ids:
            try:
                promoted_urls = [f\"https://gottahavecoups.com/deal/{d['id']}\" for d in existing_deals[-len(promoted_deal_ids):] if d.get('id')]
                if promoted_urls:
                    import asyncio
                    asyncio.create_task(ping_indexnow(promoted_urls))
            except Exception as _ie:
                logger.warning(f\"IndexNow ping failed: {_ie}\")

    logger.info(f\"Promoted {promoted_count} deals to production, rejected {rejected_as_duplicate} duplicates\")\"\"\"

if old in content:
    content = content.replace(old, new)
    print('IndexNow wired into qa_tasks')
else:
    print('Commit pattern not found - checking...')
    idx = content.find('await session.commit()')
    print(f'Found {content.count(\"await session.commit()\")} commits')
    print(repr(content[idx:idx+200]))

with open('/opt/ghc20/backend/app/tasks/qa_tasks.py', 'w') as f:
    f.write(content)

import ast
try:
    ast.parse(content)
    print('qa_tasks.py syntax OK')
except SyntaxError as e:
    print(f'SYNTAX ERROR: {e}')
EOF" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Import added
IndexNow wired into qa_tasks
qa_tasks.py syntax OK


Now deploy the frontend changes (BaseLayout and deal page). These are the riskiest, so I need to be extra careful. Let me deploy them and then run a build to validate.

Now the riskier frontend changes — deploying then validating with a build before restart:

ssh carl "
# Deploy BaseLayout
sudo cp /tmp/BaseLayout_patched.astro /opt/ghc20/frontend/src/layouts/BaseLayout.astro
sudo chown ghc20:ghc20 /opt/ghc20/frontend/src/layouts/BaseLayout.astro

# Deploy deal page
sudo cp /tmp/dealpage_patched.astro '/opt/ghc20/frontend/src/pages/deal/[id].astro'
sudo chown 'ghc20:ghc20' '/opt/ghc20/frontend/src/pages/deal/[id].astro'

# Update robots.txt to point to sitemap index
echo 'User-agent: *
Allow: /

Disallow: /admin/
Disallow: /api/
Disallow: /go/
Disallow: /*?sort=
Disallow: /*?filter=
Disallow: /*?page=
Disallow: /*?order=

Sitemap: https://gottahavecoups.com/sitemap_index.xml' | sudo tee /opt/ghc20/frontend/public/robots.txt > /dev/null

echo 'Files deployed, running build...'
" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Files deployed, running build...


Now run the build and wait for the result.

ssh carl "cd /opt/ghc20/frontend && sudo -u ghc20 npm run build 2>&1 | tail -20" &
echo "Build started"


Build started
Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
01:51:30 [vite] dist/client/_astro/PromoBanner.Cj-_6T34.js                 2.59 kB │ gzip:  1.20 kB
01:51:30 [vite] dist/client/_astro/CategorySwipeBar.4pO9xmxv.js            3.05 kB │ gzip:  1.47 kB
01:51:30 [vite] dist/client/_astro/SearchBar.rDp0f0Xt.js                   3.83 kB │ gzip:  1.64 kB
01:51:30 [vite] dist/client/_astro/InstallBanner.BD-uvU4o.js               5.96 kB │ gzip:  2.27 kB
01:51:30 [vite] dist/client/_astro/hoisted.BuEuBB1f.js                     6.02 kB │ gzip:  1.80 kB
01:51:30 [vite] dist/client/_astro/index.CVf8TyFT.js                       6.72 kB │ gzip:  2.68 kB
01:51:30 [vite] dist/client/_astro/InfiniteDealsGrid.Dv80nI3r.js           7.07 kB │ gzip:  2.73 kB
01:51:30 [vite] dist/client/_astro/MobileMenu.DgR8DW4o.js                  9.40 kB │ gzip:  2.94 kB
01:51:30 [vite] dist/client/_astro/FeaturedCategoryCarousel.CHo6D1oY.js    9.86 kB │ gzip:  3.55 kB
01:51:30 [vite] dist/client/_astro/hoisted.bT3Qk-c0.js                     9.94 kB │ gzip:  3.37 kB
01:51:30 [vite] dist/client/_astro/hoisted.D_h24xt1.js                    10.53 kB │ gzip:  2.72 kB
01:51:30 [vite] dist/client/_astro/AdminSidebar.DCJV8v8l.js               10.63 kB │ gzip:  2.83 kB
01:51:30 [vite] dist/client/_astro/TrendingHero.DSJGBWX3.js               11.25 kB │ gzip:  3.64 kB
01:51:30 [vite] dist/client/_astro/CompactDealCard.DJcLd70Z.js            12.54 kB │ gzip:  4.28 kB
01:51:30 [vite] dist/client/_astro/hoisted.DCGQUtsP.js                    16.64 kB │ gzip:  4.53 kB
01:51:30 [vite] dist/client/_astro/client.BuOr9PT5.js                    135.60 kB │ gzip: 43.80 kB
01:51:30 [vite] ✓ built in 3.02s
01:51:30 [build] Rearranging server assets...
01:51:30 [build] Server built in 6.77s
01:51:30 [build] Complete!


The build succeeded! Now let me restart the backend and frontend services, then verify everything works.

Build clean. Now restart and verify:

ssh carl "
sudo systemctl restart ghc20-backend
sudo systemctl restart ghc20-frontend
sleep 5
echo 'backend:' $(systemctl is-active ghc20-backend)
echo 'frontend:' $(systemctl is-active ghc20-frontend)
" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
backend: activating
frontend: inactive


The frontend is inactive. Let me check the logs for what went wrong.

ssh carl "
sudo journalctl -u ghc20-frontend --no-pager -n 20
" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     at async lastNext (file:///opt/ghc20/frontend/dist/server/chunks/index_B1P9WUkb.mjs:1160:25)
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     at async callMiddleware (file:///opt/ghc20/frontend/dist/server/chunks/index_B1P9WUkb.mjs:551:10)
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     at async RenderContext.render (file:///opt/ghc20/frontend/dist/server/chunks/index_B1P9WUkb.mjs:1191:22) {
Feb 19 01:26:43 carl ghc20-frontend[1788531]:   [cause]: Error: connect ECONNREFUSED 127.0.0.1:8001
Feb 19 01:26:43 carl ghc20-frontend[1788531]:       at TCPConnectWrap.afterConnect [as oncomplete] (node:net:1611:16)
Feb 19 01:26:43 carl ghc20-frontend[1788531]:       at TCPConnectWrap.callbackTrampoline (node:internal/async_hooks:130:17) {
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     errno: -111,
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     code: 'ECONNREFUSED',
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     syscall: 'connect',
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     address: '127.0.0.1',
Feb 19 01:26:43 carl ghc20-frontend[1788531]:     port: 8001
Feb 19 01:26:43 carl ghc20-frontend[1788531]:   }
Feb 19 01:26:43 carl ghc20-frontend[1788531]: }
Feb 19 01:51:41 carl systemd[1]: Stopping GottaHaveCoups Frontend (Astro SSR)...
Feb 19 01:51:41 carl systemd[1]: ghc20-frontend.service: Deactivated successfully.
Feb 19 01:51:41 carl systemd[1]: Stopped GottaHaveCoups Frontend (Astro SSR).
Feb 19 01:51:41 carl systemd[1]: ghc20-frontend.service: Consumed 2.960s CPU time.
Feb 19 01:51:41 carl systemd[1]: Started GottaHaveCoups Frontend (Astro SSR).
Feb 19 01:51:41 carl ghc20-frontend[1814988]: 01:51:41 [@astrojs/node] Server listening on http://localhost:4321
Feb 19 01:51:42 carl ghc20-frontend[1814988]: [API Client] Server-side API_BASE_URL: http://127.0.0.1:8001/api/v1


The frontend actually started successfully (I can see "Server listening on http://localhost:4321"). The issue is that the backend was still starting when I checked. The log from 01:26:43 is old (before our restart). The latest lines show the frontend started correctly at 01:51:41. Let me check the current status.

ssh carl "
sleep 5
echo 'backend:' $(systemctl is-active ghc20-backend)
echo 'frontend:' $(systemctl is-active ghc20-frontend)
" 2>&1


Both services aren't starting properly. Let me check the frontend logs again to see what's happening, and also check if the backend has a syntax error.

ssh carl "sudo journalctl -u ghc20-frontend -u ghc20-backend --no-pager -n 30 --since '01:51:00'" 2>&1


Warning: Permanently added 'carl' (ED25519) to the list of known hosts.
Feb 19 01:51:10 carl ghc20-backend[1796407]: 127.0.0.1:57738 - "GET /api/v1/deals/archive/stats HTTP/1.1" 200
Feb 19 01:51:10 carl ghc20-backend[1796406]: 127.0.0.1:57730 - "GET /api/v1/deals/archive?page=188&page_size=50 HTTP/1.1" 200
Feb 19 01:51:10 carl ghc20-backend[1796404]: 127.0.0.1:57746 - "GET /api/v1/categories/ HTTP/1.1" 200
Feb 19 01:51:11 carl ghc20-backend[1796407]: 82.25.90.223:59986 - "GET /api/v1/deals/34775 HTTP/1.1" 200
Feb 19 01:51:11 carl ghc20-backend[1796404]: 82.25.90.223:59998 - "GET /api/v1/deal-images/d00cb29b26021a75.jpg HTTP/1.1" 200
Feb 19 01:51:18 carl ghc20-backend[1796403]: 82.25.90.223:53518 - "HEAD /api/v1/share/image/34775 HTTP/1.1" 405
Feb 19 01:51:18 carl ghc20-backend[1796407]: 82.25.90.223:53520 - "HEAD /api/v1/share/image/34775 HTTP/1.1" 405
Feb 19 01:51:18 carl ghc20-backend[1796407]: 82.25.90.223:53536 - "HEAD /api/v1/share/image/34775 HTTP/1.1" 405
Feb 19 01:51:19 carl ghc20-backend[1796407]: 127.0.0.1:46824 - "GET /api/v1/deals/34775 HTTP/1.1" 200
Feb 19 01:51:19 carl ghc20-backend[1796406]: 82.25.90.223:53552 - "GET /api/v1/share/image/34775 HTTP/1.1" 200
Feb 19 01:51:28 carl ghc20-backend[1796406]: 82.25.90.223:39482 - "HEAD /api/v1/share/image/34775 HTTP/1.1" 405
Feb 19 01:51:29 carl ghc20-backend[1796407]: 127.0.0.1:57412 - "GET /api/v1/deals/34775 HTTP/1.1" 200
Feb 19 01:51:30 carl ghc20-backend[1796406]: 127.0.0.1:57422 - "GET /api/v1/deals/34775 HTTP/1.1" 200
Feb 19 01:51:30 carl ghc20-backend[1796407]: 127.0.0.1:57412 - "GET /api/v1/deals/34775 HTTP/1.1" 200
Feb 19 01:51:31 carl ghc20-backend[1796406]: 82.25.90.223:39494 - "GET /api/v1/deals/34775 HTTP/1.1" 200
Feb 19 01:51:40 carl systemd[1]: Stopping GottaHaveCoups Backend API...
Feb 19 01:51:40 carl ghc20-backend[1796401]: [2026-02-19 01:51:40 +0000] [1796401] [INFO] Handling signal: term
Feb 19 01:51:40 carl ghc20-backend[1796404]: [2026-02-19 01:51:40 +0000] [1796404] [INFO] Shutting down
Feb 19 01:51:40 carl ghc20-backend[1796404]: [2026-02-19 01:51:40 +0000] [1796404] [INFO] Error while closing socket [Errno 9] Bad file descriptor
Feb 19 01:51:40 carl ghc20-backend[1796407]: [2026-02-19 01:51:40 +0000] [1796407] [INFO] Shutting down
Feb 19 01:51:40 carl ghc20-backend[1796407]: [2026-02-19 01:51:40 +0000] [1796407] [INFO] Error while closing socket [Errno 9] Bad file descriptor
Feb 19 01:51:40 carl ghc20-backend[1796403]: [2026-02-19 01:51:40 +0000] [1796403] [INFO] Shutting down
Feb 19 01:51:40 carl ghc20-backend[1796403]: [2026-02-19 01:51:40 +0000] [1796403] [INFO] Error while closing socket [Errno 9] Bad file descriptor
Feb 19 01:51:40 carl ghc20-backend[1796406]: [2026-02-19 01:51:40 +0000] [1796406] [INFO] Shutting down
Feb 19 01:51:40 carl ghc20-backend[1796406]: [2026-02-19 01:51:40 +0000] [1796406] [INFO] Error while closing socket [Errno 9] Bad file descriptor
Feb 19 01:51:40 carl ghc20-backend[1796404]: [2026-02-19 01:51:40 +0000] [1796404] [INFO] Waiting for application shutdown.
Feb 19 01:51:40 carl ghc20-backend[1796404]: [2026-02-19 01:51:40 +0000] [1796404] [INFO] Application shutdown complete.
Feb 19 01:51:40 carl ghc20-backend[1796404]: [2026-02-19 01:51:40 +0000] [1796404] [INFO] Finished server process [1796404]
Feb 19 01:51:40 carl ghc20-backend[1796407]: [2026-02-19 01:51:40 +0000] [1796407] [INFO] Waiting for connections to close. (CTRL+C to force quit)
Feb 19 01:51:40 carl ghc20-backend[1796403]: [2026-02-19 01:51:40 +0000] [1796403] [INFO] Waiting for application shutdown.


The previous backend was running fine and processing requests right up to when I killed it. The restart seems to be taking a long time. The frontend also started successfully (I saw "Server listening on http://localhost:4321" in the earlier logs) but is now showing as inactive. Let me check more recent logs.

Wait - "inactive" might mean it exited. Let me check the full service status.

ssh carl "
sudo systemctl status ghc20-backend --no-pager -n 5
echo '---'
sudo systemctl status ghc20-frontend --no-pager -n 15
" 2>&1