Lacuna/Labs
cross-post-etsy-to-google
pink
#196 / 601

NAME

cross-post-etsy-to-google — Etsy publish → adapt + push to Google Shopping. Field mapping + category translation + Etsy-ism scrub.

SYNOPSIS

id cross-post-etsy-to-google tier pink section §7. Pink — Sakura local (65 baseline, 131 post-matrix) trigger event:listing-published-to-etsy tools merchant_center/sync source new

DESCRIPTION

Sakura's local 8B model handles this on-device. Free, private, Cortex-grounded. The 'I just know what you have' tier.

Etsy publish → adapt + push to Google Shopping. Field mapping + category translation + Etsy-ism scrub.

MECHANISM

PREfetchGUARDtier: pinkACTperformRESULTrenderON-ERRretry / esc
;;~ title    "Cross-post · Etsy → Google Shopping"
;;~ author   "lacuna"
;;~ version  1
;;~ mode     automation
;;~ flavor   purple
;;~ id       cross-post-etsy-to-google
;;~ touches  (etsy-listings google-merchant)
;;~ summary  "Event-triggered on listing-published-to-etsy. Sakura ADAPTS the listing (Etsy-isms scrubbed, category translated, fields mapped) and pushes to Google Shopping. Tier-gated to Magic+ ($39.99). Local-drafts NEVER sync — only published listings."

;; Cross-shop posting cart. Triggered when an Etsy publish completes.
;; The state machine mirrors the action-wrapper Abel established:
;;   start → preconditions → adapt → push → result → done
;;          (each step is named, no inline lambdas, serialisable)
;;
;; Adaptation = field mapping + format conversion + category translation.
;; The actual mapping work happens server-side in listing_mapper.py; the
;; cart's job is sequencing the calls + handling failure cleanly.

(cart 'cross-post-etsy-to-google
  '((author    . "lacuna")
    (version   . 1)
    (touches   . (etsy-listings google-merchant))
    (read-only . #f)))

;; ── start — precondition_fetch ──────────────────────────────────
;; The driver invokes this with ctx carrying the just-published Etsy
;; listing id. We need the full listing record + the operator's
;; merchant-center status before we can adapt.
(define (start ctx)
  (let ((id (ctx-get 'listing-id ctx)))
    (if (null? id)
        (escalate 'missing-listing-id null)
        (act 'etsy/listing (list id) 'check-listing))))

;; ── check-listing — guard the precondition ──────────────────────
;; Drafts never sync (local-drafts canon). A draft has no platform_id
;; assigned; the publish event shouldn't fire for a draft, but we guard
;; anyway because the event bus can replay.
(define (check-listing ctx)
  (let ((listing (ctx-result ctx)))
    (cond
      ((null? listing)
       (escalate 'listing-not-found null))
      ((listing-is-draft? listing)
       (escalate 'drafts-do-not-sync null))
      (else
       (next 'check-merchant (ctx-set 'listing listing ctx))))))

;; ── check-merchant — guard the merchant connection ──────────────
;; The merchant center must be connected (operator went through the
;; OAuth flow at Settings) for the push to succeed. If not, escalate
;; so the surface can prompt the operator to connect.
(define (check-merchant ctx)
  (act 'merchant_center.status '() 'check-merchant-result))

(define (check-merchant-result ctx)
  (let ((status (ctx-result ctx)))
    (cond
      ((not (status-connected? status))
       (escalate 'merchant-not-connected null))
      ((status-pending-verification? status)
       ;; Pending verification — Google hasn't finished verifying the
       ;; operator's account yet. Queue + retry; the sync replays
       ;; once the account flips to active (plan doc §2).
       (after 3600 'check-merchant ctx))
      (else
       (next 'push ctx)))))

;; ── push — act ──────────────────────────────────────────────────
;; The single side-effecting state. Calls merchant_center/sync with
;; the full listing; the server-side mapper handles the translation.
;; Result handler branches in a named state fn.
(define (push ctx)
  (let ((listing (ctx-get 'listing ctx)))
    (act 'merchant_center/sync (list listing) 'check-push-result)))

;; ── check-push-result — on_error grammar ────────────────────────
;; Four outcomes:
;;   · 'ok                 → record the google product id, done
;;   · 'rate-limited       → backoff 30s, retry
;;   · 'mapping-failed     → escalate with the closed kind so the
;;                           surface can show the operator which
;;                           fields are misshapen
;;   · anything else       → escalate with the closed kind
(define (check-push-result ctx)
  (let ((r (ctx-result ctx)))
    (cond
      ((push-ok? r)            (next 'announce (ctx-set 'google-product-id (push-product-id r) ctx)))
      ((eq? r 'rate-limited)   (after 30 'push ctx))
      ((eq? r 'mapping-failed) (escalate 'mapping-failed (push-errors r)))
      (else                    (escalate 'push-failed r)))))

;; ── announce — clean exit ──────────────────────────────────────
;; Emit a soft notification so the operator knows the cross-post
;; landed. The surface picks this up off the bus.
(define (announce ctx)
  (sakura/say (string-append
                "Cross-posted to Google Shopping · "
                (ctx-get 'google-product-id ctx)))
  (done))

source: curator-web/src/scheme/carts/google/cross-post-etsy-to-google.sks · 100 lines

EXAMPLES

Example forthcoming — docs/automations/examples.json entry editable in place.

TIER

pink
Sakura local 8B · free · on-device

FITNESS

Ready

Fits any shop today — this one has no sample-size floor.

SEE ALSO

HISTORY

canonical source: curator/docs/AUTOMATIONS-CANONICAL.md · §7-pink-sakura-local-65-baseline-131-post-matrix
last regenerated: 2026-06-05 20:31 UTC (mechanical — edits land in the MD or sidecars, not here)

KNOWN BUGS

(none recorded — flag in AUTOMATIONS-CANONICAL.md or open an issue against the cart file)