Skill: mutonby/autoecom (version 1.0.1)

--- name: autoecom description: "Turn any ecommerce store into daily Instagram & TikTok product carousels. AI pipeline: agent auto-extracts brand kit (logo, colors, font, voice) from the store URL via WebFetch + vision, picks the next bestseller round-robin, generates 3–8 stylized slide images with nano-banana (Gemini 2.5 Flash Image) using the real product photo as reference, composes branded slides with Pillow, you approve, Upload-Post publishes. Use when the user wants daily product carousels, mentions autoecom, ecommerce content, product slides, shop content automation, or asks for the daily carousel batch." license: MIT compatibility: "Requires Python 3.11+, google-genai SDK, Pillow, beautifulsoup4, lxml, requests, and internet access for the store URL, Gemini, and Upload-Post APIs. Designed to run inside an agent harness (openclaw / Claude Code) — works headless on a VPS as long as the agent has WebFetch." metadata: author: mutonby version: "1.0.1" homepage: "https://github.com/mutonby/skill-autoecom" primaryEnv: UPLOAD_POST_API_KEY requires: bins: [python3] env: [STORE_URL, GEMINI_API_KEY, UPLOAD_POST_API_KEY, UPLOAD_POST_PROFILE] envVars: - { name: STORE_URL, required: true, description: "Storefront URL the agent crawls for brand kit + bestsellers (e.g. https://www.your-shop.com)" } - { name: GEMINI_API_KEY, required: true, description: "Google Gemini API key — used for both text (2.5 Flash) and image generation (nano-banana, 2.5 Flash Image)" } - { name: UPLOAD_POST_API_KEY, required: true, description: "Upload-Post API key (https://app.upload-post.com → Settings → API Keys)" } - { name: UPLOAD_POST_PROFILE, required: true, description: "Upload-Post profile name with the connected Instagram + TikTok accounts" } - { name: TIMEZONE, required: false, description: "IANA timezone for daily scheduling (default: Europe/Madrid)" } - { name: OUTPUT_FOLDER, required: false, description: "Absolute path where carousel output is written (default: <skill>/output)" } - { name: GEMINI_IMAGE_MODEL, required: false, description: "Override the nano-banana image model (default: gemini-2.5-flash-image)" } - { name: GEMINI_TEXT_MODEL, required: false, description: "Override the Gemini text model (default: gemini-2.5-flash)" } - { name: BRAND_FONT_PATH, required: false, description: "Absolute path to a .ttf for slide text. Falls back to a system bold font if unset." } --- # AutoEcom — Daily Product Carousel Pipeline Pipeline tooling lives at `~/Documents/skill-autoecom/`. Each day this skill picks ONE product from the store's bestseller list, generates a 3–8 slide stylized carousel, shows it to the user for approval, and publishes it as a photo carousel to Instagram + TikTok via Upload-Post. ## Architecture: agent-driven, script-as-glue This skill deliberately splits responsibilities: - **You (the agent)** do the creative + identity work: identify the logo, pick brand colors, infer brand voice, pick which bestseller to feature, plan the slide structure, write the on-image text, write the post caption. You use `WebFetch`, `Read` (multimodal vision on images), and `Write` directly. **You are not delegating these to a closed Python script** — that's the whole point. A regex can mistake a featured-brand logo for the store's logo; you can't. - **The Python script (`autoecom.py`)** handles only the mechanical bits you can't do yourself: download a URL, extract a hex palette from an image, parse JSON-LD, call the nano-banana API, run Pillow composition, post a multipart carousel to Upload-Post, persist state. Every step below makes that split explicit. When you see "you do X" — write it / decide it directly. When you see a `python autoecom.py …` command — run it. ## Setup (only if not yet configured) ### 1. Python environment ```bash cd ~/Documents/skill-autoecom && python3 -m venv venv && source venv/bin/activate && pip install -r requirements.txt ``` ### 2. `.env` File lives at `~/Documents/skill-autoecom/.env`. Required keys: ``` STORE_URL=https://www.your-shop.com GEMINI_API_KEY=... UPLOAD_POST_API_KEY=... UPLOAD_POST_PROFILE=... TIMEZONE=Europe/Madrid ``` If a required key is missing, ask the user for it before continuing. ### 3. Upload-Post account - Sign up at https://upload-post.com → dashboard at https://app.upload-post.com. - Connect Instagram (Business/Creator account linked to a Facebook Page) and TikTok via OAuth in the dashboard. - In **Manage Users**, create a profile — its name is `UPLOAD_POST_PROFILE` (NOT the social handle). - Generate an API key in **Settings**. - Verify: `curl -H "Authorization: Apikey $UPLOAD_POST_API_KEY" https://api.upload-post.com/api/uploadposts/me`. ## Orchestration model This skill is invoked **daily** by an agent harness (Hermes / openclaw / Claude Code), which also handles the messaging bridge (Telegram, WhatsApp, or whatever channel the user has configured). The skill itself does NOT talk to Telegram or any messenger directly — it just runs the pipeline and presents the carousel as text + absolute file paths. The harness forwards the slides to the user's phone, captures the user's reply, and feeds it back into the conversation. If the skill is invoked outside a harness (e.g., user runs `/autoecom` directly in Claude Code), the same prompts work — they just appear in the terminal instead of on the phone. ### Two scheduled routines (REQUIRED — install on first run) This skill is **not designed to be run on demand**. It only works as expected when the harness has two cron-style routines installed. **On the very first invocation, BEFORE running Step 0, the agent MUST verify both routines exist and offer to create whichever is missing.** Without these, the round-robin cycle stalls and the learning loop never fires. | Routine | Cadence | Cron expression | What it does | |---|---|---|---| | **`/autoecom` daily** | every day, 09:00 local time | `0 9 * * *` | Runs Steps 0–9: pick next product → plan → generate → compose → present carousel **via the configured messenger** for approval → publish → mark-processed. | | **`/autoecom-learn` weekly** | Mondays 09:00 local time | `0 9 * * 1` | Runs `python autoecom.py learn`, then **posts a summary in the same messenger channel** with the patterns Gemini extracted + sample sizes + a link to the audit file. | **How to install** (pick the path that matches the harness): - **Hermes**: ask Hermes to schedule a recurring routine — *"schedule `/autoecom` every day at 09:00, and `/autoecom-learn` every Monday at 09:00, both reporting to my Telegram"* (or WhatsApp / whatever channel is configured). Hermes will write the routine itself. - **openclaw**: same pattern — openclaw has a built-in scheduler. Use its `schedule` / `routine` mechanism. - **Claude Code (local-only)**: install via system crontab. Example: ``` 0 9 * * * cd ~/Documents/skill-autoecom && ./venv/bin/python -c "import os; os.system('claude /autoecom')" 0 9 * * 1 cd ~/Documents/skill-autoecom && ./venv/bin/python autoecom.py learn ``` (For Claude Code without a messenger bridge, the daily run will surface the carousel in the terminal — which means the user has to be at their machine. Recommend Hermes / openclaw for hands-off operation.) **On first invocation, do this BEFORE Step 0:** 1. Detect the harness (Hermes vs openclaw vs Claude Code) by looking at environment / available tools. 2. Ask the user once: *"¿Quieres que programe las dos rutinas (carrusel diario 09:00 + aprendizaje semanal lunes 09:00) en \<harness\>, enviando los carruseles a tu \<canal: Telegram/WhatsApp/etc.\>? Necesitarás dar el ok cada día desde tu móvil."* 3. If yes → install both routines and confirm. If no → continue but **warn explicitly**: *"OK, sin rutinas el ciclo round-robin no avanza si te olvidas de invocarme manualmente, y el `learn` semanal no se dispara — los priors quedarán congelados."* 4. If the user says they already have routines → trust them but show what you found (whatever the harness reports) so they can verify. ### Learn-day reporting (the agent MUST do this on every weekly learn run) After `python autoecom.py learn` finishes, the agent reads `learnings/runs/learn-YYYY-MM-DD.md` and **sends a digest to the configured messenger** so the user understands what changed without opening files. Format: > 📊 **Aprendizaje semanal — \<date\>** > > Cohorte: N carruseles con métricas (ventana \<soak\>–\<max-age\> días). > > **Top hooks** (de `HOT_HOOKS.md`): > - bullet 1 — *evidencia: 4/5 winners, 0/5 losers* > - bullet 2 — *…* > > **Top imagery** (de `HOT_IMAGERY.md`): > - bullet 1 — *…* > - bullet 2 — *…* > > Cambios respecto a la semana pasada: \<resumen breve de qué se añadió/quitó\>. > Auditoría completa: `learnings/runs/learn-YYYY-MM-DD.md`. If `learn` returned "not enough data" (<5 winners + 5 losers), say so honestly: *"Esta semana no hay suficientes datos para refrescar los priors (cohorte de N carruseles). Se necesitan al menos 10 con métricas maduras. Volveré a intentarlo el próximo lunes."* This message is the user's only window into the learning loop — without it, the priors evolve invisibly and trust erodes. **Always send it.** ## Daily workflow ### Step 0 — Preflight (run on every invocation, do not skip) Check that the environment is ready and ask the user for whatever is missing: 1. **venv** — does `~/Documents/skill-autoecom/venv/bin/python` exist? If not, run setup step 1. Mechanical, do it without asking. 2. **`.env` file** — verify each required key: - `STORE_URL` → if missing, ask: *"¿Cuál es la URL de tu tienda? (la home, no una ficha)."* - `GEMINI_API_KEY` → if missing, ask: *"Falta la API key de Gemini. Pégamela (la generas en https://aistudio.google.com/apikey)."* - `UPLOAD_POST_API_KEY` and `UPLOAD_POST_PROFILE` → if missing, ask: *"Necesito la API key de Upload-Post y el nombre del profile (Manage Users en https://app.upload-post.com)."* If the user provides an API key in the conversation, write it to `.env` immediately, never echo it back, and **warn that the key is now in conversation logs and they should rotate it after testing.** ### Step 1 — Brand kit (you do this, with Python only as utility) Goal: produce `state/brand_kit.json` with fields: ```json { "store": "https://www.example.com", "fetched_at": "2026-05-06T12:00:00", "logo_url": "https://...", "logo_path": "/Users/.../skill-autoecom/state/logo.png", "primary_color": "#202828", "accent_color": "#f0a810", "palette": ["#202828", "#f0a810", "#f09810", "#202020", "#f0a010"], "font_family": "Roboto", "voice": { "language": "es", "tone": "professional, helpful, no-nonsense", "audience": "DIY-ers and small contractors", "positioning": "Affordable hardware delivered fast.", "do": ["short sentences", "concrete benefits", "use store name occasionally"], "dont": ["hype", "emojis everywhere", "salesy clichés"] } } ``` **Cache check first.** If `state/brand_kit.json` exists and `fetched_at` is < 7 days old AND `store` matches `STORE_URL`, reuse it. Print a one-line confirmation and skip to Step 2. Otherwise: 1. **Identify the logo URL.** `WebFetch <STORE_URL>` and ask the response model: *"What is the EXACT URL of this store's own logo (not a featured brand or product), looking at the HTML? Return only the URL."* Cross-check that the URL contains the store's brand name in its filename — if it doesn't, fetch again with a more pointed prompt or look at `<img class="custom-logo">` (WordPress) / `<header> img` directly. 2. **Download the logo.** `python autoecom.py download <LOGO_URL> state/logo.png`. (For SVG logos, save with `.svg` extension instead — Pillow can't read SVG, but you'll fall back to the colors hex you read from the page.) 3. **Extract the color palette.** Two ways, run both and reconcile: - Mechanical: `python autoecom.py palette state/logo.png` → JSON list of hex colors. - Visual: `Read state/logo.png` yourself. Look at the image. Pick the **primary** color (the dominant brand color, what the user would call "the brand's signature color") and the **accent** (the contrast color, what's used for text on top of the primary). The mechanical palette tells you what colors EXIST in the logo; you decide which is primary vs accent based on what looks like the brand identity. 4. **Identify the font.** `WebFetch <STORE_URL>` again, ask: *"What font-family does this site use for headings? Look at Google Fonts `<link>` tags first, then inline CSS. Return just the family name."* 5. **Infer brand voice.** `WebFetch <STORE_URL>` once more, ask: *"From the homepage copy, summarize the brand voice as JSON with keys `language` (ISO 639-1), `tone` (one line), `audience` (one line), `positioning` (one sentence), `do` (3 bullets), `dont` (3 bullets). Be concrete."* Take the JSON. 6. **Write `state/brand_kit.json`** with the `Write` tool, combining all of the above plus `store`, `fetched_at` (today), and `logo_path` (absolute path of the file you just downloaded). You can call WebFetch multiple times for the same page — it's cached for 15 minutes, so re-asking with different questions is cheap. ### Step 2 — Pick the product Goal: identify the next product URL to feature, in round-robin order over the top bestsellers. 1. **Fetch the bestsellers listing.** WooCommerce: `WebFetch <STORE_URL>/tienda/?orderby=popularity` (Spanish stores) or `<STORE_URL>/shop/?orderby=popularity` (English). Ask: *"List the first 50 product URLs in order, one per line. Skip category links, search results, and 'add to cart' links. A product URL on this site looks like `/producto/<slug>/` or `/product/<slug>/`."* - If the prompt returns < 20 URLs, paginate: fetch page `&paged=2`, `&paged=3`, etc. - For non-WooCommerce stores, ask the user how their bestsellers page is structured. 2. **Read state.** `python autoecom.py list-processed` returns the current `processed.json`. Look at: - `cycle_started_at` (ISO timestamp). - `products[]`: each entry has `url`, `last_processed_at`, `cycles_count`. 3. **Pick.** First product URL from the bestsellers list whose `last_processed_at` is older than `cycle_started_at` (i.e. not yet processed in the current cycle). If every product in the list has been processed in the current cycle, run `python autoecom.py new-cycle` to start a new one, then pick the top bestseller. Mention this to the user briefly: *"Empezando un nuevo ciclo — ya hicimos un carrusel de este producto N veces, vamos a por un ángulo nuevo."* 4. **Confirm with the user briefly** before spending tokens on planning + image generation: *"Hoy toca: **\<product name\>** → \<URL\>. ¿Sigo o prefieres saltar?"* (Skip optional but recommended for the first few runs while the user calibrates the round-robin.) ### Step 3 — Plan the carousel (you write `plan.json` directly) Goal: write `output/YYYY-MM-DD/<slug>/plan.json` with the full carousel plan. 0. **Pull the priors.** `python autoecom.py priors` returns `{"hooks": "...", "imagery": "..."}`. These are auto-managed `HOT_HOOKS.md` and `HOT_IMAGERY.md` files refreshed weekly by `learn` based on real engagement. **`hooks` is YOUR creative input** — read it before writing slide-1 `text_overlay` and treat its bullets as evidence-backed priors (e.g. "hooks <8 words convert 3x"). **`imagery` is auto-prepended by the script** to every nano-banana call inside `generate`, so you don't have to inject it manually — just be aware it's there. If both fields are empty (cold start), proceed without priors. 1. **Get the product data.** `python autoecom.py product <PRODUCT_URL>` returns the JSON-LD parsed dict (name, description, price, currency, images, rating, brand, sku). If it returns very thin data (no images, no description), `WebFetch <PRODUCT_URL>` and write the dict yourself. 2. **Decide the carousel structure.** 3–8 slides depending on substance. Slide 1 is always a **hook** that stops the scroll. Last slide is a **soft CTA**. Middle slides cover: feature/benefit, lifestyle/use-case, social proof (only if rating exists with reviews), spec/quality detail, "vs alternative" comparison if it makes sense. 3. **Write the on-image text** (`text_overlay`) for each slide. Hard rules: - ≤ 8 words per slide. - Match the brand's language (`brand.voice.language`). - Hook + CTA in uppercase work well — the composer auto-uppercases those roles. - Brand voice from `state/brand_kit.json` is the contract: hit the `do` bullets, avoid the `dont` bullets. 4. **Write the image generation prompt** (`image_prompt`) for each slide. The image model is nano-banana — it receives the **first product photo** plus this prompt. Hard rules: - **Always reference "the product in the attached photo"** so nano-banana keeps fidelity. - Then describe stylized scene, lighting, background, composition. Stylize but keep the product accurate and recognizable. (User policy: stylization is OK, drift that makes the product unrecognizable is not.) - You can override the reference image per slide with `"ref_image_index": N` (0-indexed into `product.images`) if a specific product photo fits better. 5. **Write the caption** — 2–5 sentences, brand voice, soft CTA at the end ("link en bio", "cómpralo en \<store domain\>", etc.). 6. **Write the hashtags** — 8–15, mix of broad + niche, matching the product category and brand language. 7. **Write `plan.json`** with the `Write` tool. Schema: ```json { "url": "<PRODUCT_URL>", "created_at": "<ISO_TIMESTAMP>", "language": "es", "caption": "...", "hashtags": ["#...", "#..."], "slide_count": 5, "brand": { "primary_color": "#...", "accent_color": "#...", "logo_path": "/abs/path/to/state/logo.png" }, "product": { "url": "...", "name": "...", "description": "...", "images": ["..."], "...": "..." }, "slides": [ { "role": "hook", "text_overlay": "BIG WORDS HERE", "image_prompt": "Detailed image-model prompt referencing the attached product photo.", "ref_image_index": 0 }, { "role": "feature", "text_overlay": "...", "image_prompt": "..." } ] } ``` The `brand` block in `plan.json` is only the subset the composer needs (primary, accent, logo path). The rest of the brand kit (voice, font) was already used as input when YOU wrote the slide text — it doesn't need to be passed to the composer. ### Step 3.5 — Log the candidate (before user editing) ```bash python autoecom.py log-candidate "output/YYYY-MM-DD/<slug>/plan.json" ``` This appends the INITIAL plan to `learnings/candidate-history.jsonl` so `reflect` can later compare what you proposed against what actually shipped (after any user edits in Step 6). **Run this exactly once, right after writing `plan.json` for the first time.** Do NOT re-run it after the user requests regenerations — that would muddy the learning signal. The script computes a `candidate_id` (sha1 of the plan); if the user publishes the carousel unchanged, the same id will appear in `post-history.jsonl` and `reflect` will count it as approved-unchanged. If they edit, the ids diverge and reflect counts the original as edited/rejected. ### Step 4 — Generate slide images with nano-banana ```bash python autoecom.py generate "output/YYYY-MM-DD/<slug>/plan.json" ``` For each slide in the plan, calls `gemini-2.5-flash-image` with the referenced product image plus the slide's `image_prompt`. Saves raw outputs to `output/YYYY-MM-DD/<slug>/raw/slide_NN.png`. Skips slides already generated unless `--force` is passed. This is the slow step (1–3 seconds per slide call). For a 5-slide carousel, expect ~10–15 seconds. Errors here are usually quota / rate limit issues — back off and retry. ### Step 5 — Compose final slides (Pillow overlay) ```bash python autoecom.py compose "output/YYYY-MM-DD/<slug>/plan.json" ``` Per slide: 1. Resize the raw nano-banana image to fully cover the **1080×1350** (4:5) canvas with a center crop. 2. Add a subtle bottom gradient (45% of frame, fades to 70% black) so text is always legible. 3. Render `text_overlay` left-aligned, bottom of frame, with brand-driven font + colors (uppercase for `hook`/`cta` roles). Auto-shrinks the font size until it fits in 86% of the canvas width. 4. Paste the brand logo at bottom-left. Outputs `slide_01.png` … `slide_NN.png` in the date/slug folder. ### Step 5.5 — Visual QA of the carousel (you do this yourself, no Gemini call) You are multimodal. **Use that.** Before showing the carousel to the user, open every composed `slide_NN.png` with the **Read** tool — Claude / openclaw both view PNGs directly. For each slide, evaluate: 1. Is the on-image text fully visible? Any letter clipped at the edges? 2. Is the product still recognizable in the stylized image? (nano-banana sometimes drifts — flag if the product looks wrong.) 3. Does the logo overlap with the text or the product? 4. Are accent marks / special characters (`á é í ó ú ñ ¿ ¡`) rendering correctly? 5. Does the slide order make narrative sense (hook → middle → cta)? 6. Any rendering glitch: garbled text, missing logo, gradient banding? **Add a "QA" column to the Step 6 table** with one of: - `✅` — clean - `⚠️ <issue>` — flag the specific problem (e.g. `⚠️ producto irreconocible`, `⚠️ acento "ó" recortado`) **Do NOT silently drop flagged slides** — show them to the user with the warning so they can decide whether to regenerate that single slide, regenerate the whole carousel, or ship as-is. ### Step 6 — Present to the user Show a markdown table: | # | Role | Text | QA | File | |---|------|------|----|------| | 1 | hook | "..." | ✅ | output/YYYY-MM-DD/<slug>/slide_01.png | | 2 | feature | "..." | ⚠️ producto irreconocible | output/YYYY-MM-DD/<slug>/slide_02.png | | … | … | … | … | … | Then show the **caption** and **hashtags** below the table. **Always include the absolute file paths in the table** — openclaw uses them to attach the actual slide images when it forwards the message to the user's messenger. Without absolute paths the user sees only metadata and cannot review the carousel visually. Then ask: > **¿Publico el carrusel? (`sí` para publicar, `no` para descartar, o dime qué slide regenerar.)** Wait for the user's reply (it will arrive via openclaw from the user's phone). **If the user replies `no`** (rejects the carousel), skip directly to Step 8 and `mark-processed` with `--slides 0`. This consumes the product so tomorrow's run picks the next one — otherwise the same rejected product would surface again. If the user wants to retry the same product later, they can manually remove its entry from `state/processed.json`. **If the user asks to regenerate slide N**, edit `plan.json` (you may rewrite the `image_prompt`, `text_overlay`, or `ref_image_index`), delete `raw/slide_NN.png`, then re-run `generate` (it will only regenerate the missing slide unless `--force`) and `compose`. Re-present. ### Step 7 — Publish ```bash python autoecom.py publish "output/YYYY-MM-DD/<slug>/plan.json" \ --platforms instagram,tiktok \ --tiktok-mode draft \ --dry-run ``` **Always run with `--dry-run` first** and show the user the exact request payload. Only execute the real publish (without `--dry-run`) after explicit "go". The publish call uploads the composed `slide_*.png` files (sorted, max 10) as a **photo carousel** via `POST /api/upload_photos`. The caption from `plan.json` plus hashtags is sent as the `caption` field. TikTok mode is `draft` by default per project policy (`MEDIA_UPLOAD` → goes to TikTok inbox, never auto-publishes). ### Step 8 — Mark product as processed ```bash python autoecom.py mark-processed "<PRODUCT_URL>" --slides <N> --store "<STORE_URL>" ``` Updates `state/processed.json` so tomorrow's `pick` skips this product. **Run this even if `--slides 0`** (the user rejected the carousel) — a rejected product is still consumed. ### Step 9 — Final summary Print one line: product name, number of slides published, platforms, and the carousel folder path. ## Learning loop (`learn` weekly, `reflect` on demand) This skill **gets smarter over time**. Two priors are maintained automatically and refreshed from real engagement data: - **`learnings/HOT_HOOKS.md`** — patterns of slide-1 hook copy that converted. You read this in **Step 3.0** when writing `text_overlay` for the hook slide. - **`learnings/HOT_IMAGERY.md`** — image-prompt patterns (lighting, composition, framing) that performed well. **Auto-prepended by `generate` to every nano-banana call** — no agent action needed. Two priors instead of one (vs. autoshorts) so the engine can isolate what's working visually from what's working textually. ### `learn` — weekly, metrics-driven ```bash python autoecom.py learn # optional flags: --soak-days 7 --max-age-days 90 --top-pct 0.20 --bottom-pct 0.20 ``` Pulls Upload-Post analytics for every carousel in `post-history.jsonl` whose age is in `[soak-days, max-age-days]`. Computes a composite z-score per carousel (`0.6·z(views) + 0.4·z(engagement_rate)`), takes the top 20% as winners and bottom 20% as losers, then makes **two separate Gemini calls** — one per prior: 1. Refresh `HOT_HOOKS.md` from winners' vs losers' `hook_text`. 2. Refresh `HOT_IMAGERY.md` from winners' vs losers' `image_prompts`. Old priors are backed up as `HOT_HOOKS.YYYYMMDD-HHMMSS.md.bak` and `HOT_IMAGERY.YYYYMMDD-HHMMSS.md.bak`. A full audit lands in `learnings/runs/learn-YYYY-MM-DD.md`. **When to run**: - Manually, on demand: `python autoecom.py learn`. - Scheduled, weekly via cron / openclaw / Hermes: `0 9 * * 1 cd ~/Documents/skill-autoecom && ./venv/bin/python autoecom.py learn`. - Skip if `post-history.jsonl` has fewer than ~10 entries — `learn` will short-circuit and write a "not enough data" note. ### `reflect` — on demand, qualitative ```bash python autoecom.py reflect --window-days 30 ``` Compares `candidate-history.jsonl` (initial agent proposals, logged in Step 3.5) against `post-history.jsonl` (final carousels that shipped) within the window. A candidate is "approved-unchanged" if its `candidate_id` appears in posts; otherwise it's "edited or rejected". Sends both buckets to Gemini and asks for **two sets of observations** — hook patterns + imagery patterns — that explain the user's filter. Output goes to `learnings/runs/reflect-YYYY-MM-DD-HHMM.md` and is **NOT auto-promoted** to `HOT_HOOKS.md` or `HOT_IMAGERY.md`. Reflect can lock in your past biases ("I always reject question hooks") rather than what actually performs, so it stays observational. Read it, copy whatever's useful into `learnings/insights/` (manual notes), and let `learn` keep refreshing the actual priors based on engagement. ### Why this is better than autoshorts' learning loop - **Two priors, not one**: hook and imagery are independent variables; mixing them masks signal. - **Auto-prepended imagery prior**: the agent doesn't have to remember to inject it — `generate` does it silently. - **Edit-as-rejection signal**: `reflect` uses `candidate_id` collision to detect whether the user shipped your plan unchanged or revised it. That's a stronger signal than the binary approve/reject autoshorts uses. ### Don'ts - Do not edit `HOT_HOOKS.md` or `HOT_IMAGERY.md` by hand AND keep running `learn` — `learn` will overwrite. Manual rules go in `learnings/insights/`. - Do not delete `post-history.jsonl`, `candidate-history.jsonl`, or `metrics.jsonl` — they're append-only memory. - Do not run `learn` more than once a week — Gemini will just churn the same patterns. - Do not call `log-candidate` more than once per planning session — only the FIRST plan, before any user editing. ## Carousel format constraints - **Aspect ratio**: 1080×1350 (4:5). IG carousel native. TikTok photo posts accept 4:5 fine. - **Slide count**: 2–10 (Instagram caps carousels at 10). The planner targets 3–8. - **TikTok always draft** (`post_mode=MEDIA_UPLOAD`). The user always reviews on the phone before publishing on TikTok. - **No Reels / Shorts video output** — this skill is image-only. For vertical video clips, use the `autoshorts` skill instead. ## Files & layout ``` skill-autoecom/ ├── SKILL.md # this file ├── autoecom.py # utility CLI: download, palette, product, generate, compose, publish, state ├── README.md # human-readable setup ├── requirements.txt ├── .env ├── input/ # currently unused (reserved for brand assets the user wants forced in) ├── output/ │ └── 2026-05-06/ │ └── <slug>/ │ ├── plan.json │ ├── raw/ │ │ ├── _ref.jpg │ │ └── slide_NN.png # raw nano-banana output │ ├── slide_NN.png # final composed slide │ └── publish.json ├── state/ │ ├── brand_kit.json │ ├── logo.png │ └── processed.json └── learnings/ ├── HOT_HOOKS.md # auto-managed by `learn`, read by agent in Step 3.0 ├── HOT_IMAGERY.md # auto-managed by `learn`, auto-prepended in `generate` ├── candidate-history.jsonl # every initial plan proposal (Step 3.5) ├── post-history.jsonl # every carousel that shipped (written by `publish`) ├── metrics.jsonl # snapshots from Upload-Post post-analytics ├── runs/ │ ├── learn-YYYY-MM-DD.md │ └── reflect-YYYY-MM-DD-HHMM.md └── insights/ # MANUAL notes (not used by the pipeline) ```

path: README.md
content: <div align="center">

# 🛍️ skill-autoecom

**Daily AI-driven product carousel pipeline for ecommerce — runs inside agent harnesses.**

*Identifies your brand. Picks the next bestseller. Generates stylized slides with nano-banana. Publishes to Instagram + TikTok.*

[![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
[![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT)
[![Gemini 2.5 Flash Image](https://img.shields.io/badge/model-nano--banana-orange.svg)](https://ai.google.dev/)
[![Upload-Post](https://img.shields.io/badge/publishing-Upload--Post-purple.svg)](https://upload-post.com/)

[Quick install](#-one-shot-install-paste-into-any-agent) · [How it works](#-how-it-works) · [How it learns](#-how-it-learns) · [Manual setup](#-manual-setup) · [Compatibility](#-compatibility)

</div>

---

## 🤖 Built for agent harnesses

This is **not** a one-shot script. It's a **Skill** — a structured workflow with explicit human-in-the-loop checkpoints, designed to be driven by a multimodal agent that can see, decide, and write.

Officially supported harnesses:

| Harness | Status | Notes |
|---|---|---|
| **[Hermes Agent](https://hermesagent.com)** | ✅ Primary target | Daily-routine orchestrator. Bridges Telegram / WhatsApp so you approve carousels from your phone. |
| **[OpenClaw](https://openclaw.com)** | ✅ Primary target | Self-hosted agent harness. Same daily-routine orchestration as Hermes. |
| **Claude Code** | ✅ Works | Run `/autoecom` directly in the terminal. Prompts appear in CLI instead of phone. |
| **Codex / any agent w/ shell + WebFetch** | ⚠️ Should work | Untested but the SKILL.md is harness-agnostic. |

The **agent** does the creative + visual work (identifying the logo, choosing colors, inferring brand voice, planning slides, writing copy). The **Python script** (`autoecom.py`) is glue — it does the mechanical parts the agent can't (calling APIs, compositing pixels, persisting state).

---

## ⚡ One-shot install (paste into any agent)

Open Claude Code, Codex, Hermes, OpenClaw, or any agent with shell access and paste:

```
Set up https://github.com/mutonby/skill-autoecom for me. Read README.md and SKILL.md, clone the repo into ~/Documents/skill-autoecom, create the venv, install requirements.txt, copy .env.example to .env, and ask me for the values of STORE_URL, GEMINI_API_KEY, UPLOAD_POST_API_KEY, and UPLOAD_POST_PROFILE one by one. After .env is filled, run a health check against the Upload-Post API and tell me whether Instagram and TikTok are connected. Then PROGRAM TWO RECURRING ROUTINES IN MY AGENT HARNESS: (1) `/autoecom` daily at 09:00 local time — generate the carousel and send it to my configured messenger (Telegram / WhatsApp / whatever) for approval; (2) `/autoecom-learn` weekly on Monday at 09:00 — run `python autoecom.py learn` and post a digest of what was learned to the same messenger. Ask me which messenger I want before scheduling. Do not echo any API key back to me after I paste it.
```

The agent will handle the entire bootstrap **including the two cron jobs**. Without those routines, the daily round-robin stalls and the priors never refresh — see [How it learns](#-how-it-learns). Total time: ~2 minutes + however long it takes you to paste 4 keys.

> [!IMPORTANT]
> **This skill is designed to run on a schedule, not on demand.** Two routines are mandatory:
> - **Daily 09:00** — generate today's carousel and ask for approval via your messenger.
> - **Weekly Monday 09:00** — refresh `HOT_HOOKS.md` + `HOT_IMAGERY.md` from real engagement data, and post a chat summary of what changed.
>
> Hermes and openclaw handle this natively (they have built-in schedulers + messenger bridges). For plain Claude Code, fall back to system `crontab`. The agent will offer to set both up on first invocation.

---

## 🧭 How it works

```
                          ┌─────────────────────────────────────┐
                          │  HARNESS  (Hermes / OpenClaw / CC)  │
                          │  schedules /autoecom daily          │
                          └─────────────┬───────────────────────┘
                                        │
                ┌───────────────────────▼───────────────────────┐
                │              AGENT (Claude / Opus)             │
                │  reads SKILL.md and orchestrates the workflow  │
                └───────────────────────┬───────────────────────┘
                                        │
   ┌────────────────────────────────────┼────────────────────────────────────┐
   │                                    │                                    │
   ▼                                    ▼                                    ▼
┌──────────────┐                  ┌──────────────┐                    ┌──────────────┐
│  Step 1-2    │                  │  Step 3-5    │                    │  Step 6-9    │
│ BRAND KIT    │                  │  PLAN +      │                    │  REVIEW +    │
│ + PRODUCT    │                  │  GENERATE    │                    │  PUBLISH     │
└──────┬───────┘                  └──────┬───────┘                    └──────┬───────┘
       │                                 │                                   │
       │  WebFetch homepage              │  Agent writes plan.json           │  Agent QAs
       │  Multimodal vision              │  (3-8 slides, copy, layout)       │  every slide
       │  → identifies logo,             │                                   │
       │    colors, font, voice          │  python autoecom.py generate ─┐   │  User approves
       │                                 │     ↓                         │   │     ↓
       │  python autoecom.py             │  ┌────────────────┐           │   │  python autoecom.py
       │   ├ download (logo)             │  │  nano-banana   │           │   │   publish
       │   ├ palette  (hex colors)       │  │  (Gemini 2.5   │           │   │     ↓
       │   └ product  (JSON-LD parse)    │  │  Flash Image)  │           │   │  ┌──────────┐
       │                                 │  └────────────────┘           │   │  │Upload-Post│
       │  → state/brand_kit.json         │                               │   │  └─────┬────┘
       │  → round-robin pick             │  python autoecom.py compose ──┘   │        │
       │    (state/processed.json)       │     ↓                             │        ▼
       │                                 │  ┌────────────────┐               │   ┌──────────┐
       │                                 │  │     Pillow     │               │   │ Instagram│
       │                                 │  │  text overlay  │               │   │  TikTok  │
       │                                 │  │  logo + grad.  │               │   └──────────┘
       │                                 │  └────────────────┘               │
       │                                 │     ↓                             │
       │                                 │  output/<sku>/slide_*.jpg         │
       └─────────────────────────────────┴───────────────────────────────────┘
```

**Daily flow** (≈ 5 min agent time, plus your approval taps):

1. **Preflight** — agent verifies venv + `.env` + Upload-Post platform health.
2. **Brand kit** — agent fetches the homepage, extracts logo / palette / font / voice. Cached for 7 days.
3. **Pick product** — agent reads the bestseller list, picks the next unprocessed item (round-robin).
4. **Plan** — agent writes a 3–8 slide structure: hook / benefit / proof / CTA, with on-image copy.
5. **Generate** — `nano-banana` re-imagines the product photo per slide (stylized, on-brand).
6. **Compose** — Pillow lays text + logo + gradient onto each slide.
7. **Visual QA** — agent looks at every slide and flags drift before showing the user.
8. **Approval** — user approves the carousel from Telegram / WhatsApp / CLI.
9. **Publish** — multipart POST to Upload-Post → IG carousel + TikTok draft.
10. **Mark processed** — round-robin state advances; tomorrow picks the next product.

---

## 🧠 How it learns

The skill **gets smarter every week**. Two evidence-backed priors are maintained from real engagement and re-injected into future runs:

```
                 ┌───────────────────────────────────────────┐
                 │           DAILY PIPELINE                  │
                 │                                           │
                 │   plan.json ──► log-candidate ──► raw     │
                 │       │                                   │
                 │       │ (agent reads HOT_HOOKS.md         │
                 │       │  for slide-1 copy)                │
                 │       ▼                                   │
                 │   generate ──► nano-banana                │
                 │       ▲                                   │
                 │       │ (script auto-prepends             │
                 │       │  HOT_IMAGERY.md)                  │
                 │                                           │
                 │   publish ──► post-history.jsonl ◄──┐     │
                 └─────────────────────────────────────┼─────┘
                                                       │
                              ┌────────────────────────┘
                              │
                              ▼
                      ┌───────────────┐         Upload-Post
                      │  learn (weekly)│ ◄──── post-analytics
                      └───────┬───────┘
                              │ z-score winners vs losers
                              ▼
                      ┌───────────────┐    ┌───────────────┐
                      │ HOT_HOOKS.md  │    │HOT_IMAGERY.md │
                      │ (slide-1 copy)│    │(image prompts)│
                      └───────────────┘    └───────────────┘
                              ▲                    ▲
                              │                    │
                              └────────┬───────────┘
                                       │
                              tomorrow's carousel
                              reflects what worked
```

| Subcommand | Cadence | What it does |
|---|---|---|
| `log-candidate <plan.json>` | once per planning session | Records the agent's INITIAL proposal to `learnings/candidate-history.jsonl`. |
| `publish` (auto) | every approved carousel | Appends the FINAL plan + `request_id` to `learnings/post-history.jsonl`. |
| `learn` | weekly (Monday 09:00 cron) | Pulls Upload-Post metrics, finds winners/losers (z-score on views + engagement), asks Gemini to refresh BOTH `HOT_HOOKS.md` AND `HOT_IMAGERY.md`. **Agent posts a digest of what changed to your messenger** (Telegram / WhatsApp / whatever the harness is bridging) so you don't have to read audit files. |
| `reflect` | on demand | Compares candidates vs published in a window. Emits qualitative observations on hooks AND imagery. NOT auto-promoted. |

### What's better than a single-prior system

Most "learning loops" mix copy and visual signals into one prior. Bad idea — they're independent variables, and a winner's hook could be carrying its mediocre imagery (or vice versa). This skill keeps them separate:

- **`HOT_HOOKS.md`** is creative input — the **agent reads it** before writing slide-1 `text_overlay`. Bullets like *"hooks <8 words convert 3× better, seen in 4/5 winners, 0/5 losers"* directly inform the planner.
- **`HOT_IMAGERY.md`** is mechanical injection — the **script auto-prepends it** as a prefix to every nano-banana prompt during `generate`. The agent doesn't have to remember; it just happens.

### Composite scoring

`learn` ranks carousels by:

```
composite = 0.6 · z(total_views) + 0.4 · z(engagement_rate)
```

Top 20% = winners, bottom 20% = losers. Defaults tunable via `--top-pct`, `--bottom-pct`, `--weight-views`, `--weight-engagement`.

`learn --soak-days 7` (default): carousels younger than 7 days are excluded — engagement metrics need time to mature, daily learning would chase noise. Older than 90 days = stale and ignored too.

If fewer than ~5 winners + 5 losers are eligible, `learn` skips the synthesis and writes a "not enough data" note to `learnings/runs/learn-YYYY-MM-DD.md`. Just keep publishing.

### Audit trail

Every `learn` run writes a full audit to `learnings/runs/learn-YYYY-MM-DD.md`: which carousels were called winners, with their composite scores, the previous priors, and the new priors side-by-side. Old priors are backed up as `HOT_HOOKS.YYYYMMDD-HHMMSS.md.bak` and `HOT_IMAGERY.YYYYMMDD-HHMMSS.md.bak`. You can roll back if Gemini synthesizes garbage.

### Reflect: edit-as-rejection signal

`reflect` exploits a smarter signal than autoshorts'. It uses `candidate_id` (sha1 of the plan content) to detect three distinct outcomes:

- **Approved-unchanged**: agent's initial plan was published verbatim → strong positive signal.
- **Edited-then-published**: initial plan logged, but post-history has a different `candidate_id` for the same product → user revised it before publishing (mild negative on the original).
- **Never-published**: candidate logged, no matching post → full rejection.

Output goes to `learnings/runs/reflect-...md` and is **not** auto-promoted to the HOT files — reflect could lock in your past biases rather than what actually performs. Read, curate manually.

### Don'ts

- Don't edit `HOT_HOOKS.md` / `HOT_IMAGERY.md` by hand AND keep running `learn` — `learn` will overwrite. Manual rules go in `learnings/insights/`.
- Don't delete `post-history.jsonl`, `candidate-history.jsonl`, or `metrics.jsonl` — they're append-only memory.
- Don't run `learn` more than once a week — Gemini will just churn the same patterns.
- Don't call `log-candidate` multiple times per planning session — only the FIRST plan, before user edits.

---

## 🧩 Architecture: agent-driven, script-as-glue

The Python script (`autoecom.py`) deliberately does **not** scrape the brand kit, plan slides, or write copy. Those tasks are creative + visual — the agent does them with `WebFetch`, `Read` (multimodal), and `Write`. The script only handles mechanical work the agent can't do directly:

| Subcommand | Purpose |
|---|---|
| `download <url> <out>` | Fetch a URL to a local file (logo, product image). |
| `palette <image> [--n 5]` | Extract dominant hex colors from an image. |
| `product <url>` | Parse a product page's JSON-LD into a JSON dict. |
| `generate <plan.json>` | Call nano-banana per slide (auto-prepends `HOT_IMAGERY.md` prior). |
| `compose <plan.json>` | Pillow composition: resize, gradient, text overlay, logo. |
| `publish <plan.json>` | Upload-Post photo carousel multipart POST. Logs to `post-history.jsonl`. |
| `mark-processed <url>` | Persist round-robin state. |
| `list-processed` / `new-cycle` | Round-robin admin. |
| `priors` | Dump current `HOT_HOOKS.md` + `HOT_IMAGERY.md` for the agent to read. |
| `log-candidate <plan.json>` | Append the agent's INITIAL plan proposal to `candidate-history.jsonl`. |
| `learn` | Weekly. Pull engagement, refresh both priors. |
| `reflect` | On-demand qualitative pass: candidates vs published. |

The agent decides **what** goes into `plan.json`; the script makes it real — and learns from what shipped.

---

## 🛠️ Manual setup

If you'd rather not delegate the install to an agent:

```bash
git clone https://github.com/mutonby/skill-autoecom ~/Documents/skill-autoecom
cd ~/Documents/skill-autoecom
python3 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# edit .env: STORE_URL, GEMINI_API_KEY, UPLOAD_POST_API_KEY, UPLOAD_POST_PROFILE
```

Then, in a Claude Code / OpenClaw / Hermes session:

```
/autoecom
```

The agent walks through Steps 0–9 from `SKILL.md`: preflight → brand kit → pick → plan → generate → compose → visual QA → present → publish → mark-processed.

### Required keys

| Variable | Where to get it |
|---|---|
| `STORE_URL` | Your shop's homepage. |
| `GEMINI_API_KEY` | https://aistudio.google.com/apikey |
| `UPLOAD_POST_API_KEY` | https://app.upload-post.com → Settings |
| `UPLOAD_POST_PROFILE` | https://app.upload-post.com → Manage Users (profile name, **not** the social handle) |

---

## ⚙️ Configuration knobs

| Variable | Default | Meaning |
|---|---|---|
| `STORE_URL` | — | Homepage of the ecommerce store. |
| `GEMINI_API_KEY` | — | Required. Used for nano-banana image generation. |
| `GEMINI_IMAGE_MODEL` | `gemini-2.5-flash-image` | Override to pin a stable GA tag. |
| `UPLOAD_POST_API_KEY` | — | Required. Auth for the publishing endpoint. |
| `UPLOAD_POST_PROFILE` | — | Required. The profile name in Upload-Post's Manage Users. |
| `BRAND_FONT_PATH` | — | Absolute path to a `.ttf` for slide text. Falls back to Impact / Helvetica Bold. |
| `TIMEZONE` | `Europe/Madrid` | Used by Upload-Post if scheduling is added later. |

---

## 🌐 Compatibility

- **Python**: 3.11+.
- **Stores**: tested against WooCommerce. Shopify and other platforms work too as long as product pages expose `schema.org/Product` JSON-LD (most do — Google Shopping requires it). For non-WooCommerce stores, the agent's WebFetch handles the platform differences.
- **Image model**: `gemini-2.5-flash-image` (nano-banana). Override via `GEMINI_IMAGE_MODEL` in `.env`.
- **Publishing**: Upload-Post `/api/upload_photos` carousel endpoint. Free tier supports IG + TikTok photo posts.

---

## ⚠️ Limits & caveats

- **Product fidelity** — nano-banana can drift the product's look on stylized scenes. The agent visually QAs every slide and flags drift before showing it to you.
- **Carousel size** — Instagram caps at 10 slides. The skill caps at 10 automatically.
- **TikTok** — always uploaded as draft (`post_mode=MEDIA_UPLOAD`). You finish the post in the TikTok app.
- **Rate limits** — nano-banana has per-minute quotas. Generating a full 8-slide carousel in one run is fine; running 10+ products back-to-back may hit limits.
- **API keys in chat** — if you paste a key into the agent conversation, the key ends up in the conversation logs. Rotate it after testing.

---

## 🧠 Why a Skill (not a one-shot script)

The pipeline has explicit human-in-the-loop checkpoints (slide QA, carousel approval, dry-run before publish) and the brand-identity work benefits massively from running on a multimodal agent rather than a regex scraper. A regex picks the wrong logo when the homepage features other brands' logos; the agent can look at the page and identify the actual store logo unambiguously. A regex can't infer brand voice — the agent reads the homepage and writes a voice profile that matches.

That's why this is a Skill, and that's why it's designed first-class for **Hermes** and **OpenClaw**: harnesses that already have the daily-routine + messaging-bridge plumbing this workflow needs.

---

## 📜 License

MIT © [@mutonby](https://github.com/mutonby)


path: autoecom.py
content: #!/usr/bin/env python3
"""autoecom CLI — utility commands for the daily ecommerce carousel pipeline.

Architecture: this script is the GLUE. The brand kit (logo, colors, font, voice),
the bestseller pick, the slide plan, the post caption — all of that creative /
identity work is done by the agent (Claude / openclaw) using its multimodal
abilities and direct WebFetch / Read / Write access. This script only handles
the mechanical bits the agent can't do directly:

    download <url> <out>          download a file (logo, product image)
    palette <image>               extract dominant hex colors from an image
    product <url>                 parse a product page's JSON-LD into a dict
    generate <plan.json>          nano-banana → raw stylized images per slide
    compose <plan.json>           Pillow overlay → final 1080x1350 slides
    publish <plan.json>           Upload-Post photo carousel → IG + TikTok
    mark-processed <url>
    list-processed

Run `autoecom.py --help` for CLI flags. The agent calls these one at a time
so it can stop between steps for human approval.
"""
from __future__ import annotations

import argparse
import io
import json
import os
import re
import sys
from collections import Counter
from datetime import datetime
from pathlib import Path
from urllib.parse import urljoin, urlparse

import requests
from bs4 import BeautifulSoup
from dotenv import load_dotenv
from PIL import Image, ImageDraw, ImageFont

ROOT = Path(__file__).resolve().parent
load_dotenv(ROOT / ".env")

OUTPUT_FOLDER = Path(os.getenv("OUTPUT_FOLDER", ROOT / "output")).expanduser()
STATE_FOLDER = ROOT / "state"
STATE_FILE = STATE_FOLDER / "processed.json"

GEMINI_IMAGE_MODEL = os.getenv("GEMINI_IMAGE_MODEL", "gemini-2.5-flash-image")
GEMINI_TEXT_MODEL = os.getenv("GEMINI_TEXT_MODEL", "gemini-2.5-flash")
UPLOAD_POST_BASE = "https://api.upload-post.com/api"

LEARNINGS_FOLDER = ROOT / "learnings"
RUNS_FOLDER = LEARNINGS_FOLDER / "runs"
HOT_HOOKS_FILE = LEARNINGS_FOLDER / "HOT_HOOKS.md"
HOT_IMAGERY_FILE = LEARNINGS_FOLDER / "HOT_IMAGERY.md"
POST_HISTORY = LEARNINGS_FOLDER / "post-history.jsonl"
CANDIDATE_HISTORY = LEARNINGS_FOLDER / "candidate-history.jsonl"
METRICS_FILE = LEARNINGS_FOLDER / "metrics.jsonl"

CANVAS_W, CANVAS_H = 1080, 1350  # 4:5 — IG carousel native, TikTok photo OK
DEFAULT_FONT_CANDIDATES = [
    "/System/Library/Fonts/Supplemental/Impact.ttf",
    "/System/Library/Fonts/Helvetica.ttc",
    "/Library/Fonts/Arial Bold.ttf",
    "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
]

UA = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
    "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36"
)


# ========== generic helpers ==========

def log(msg: str) -> None:
    print(f"[autoecom] {msg}", file=sys.stderr)


def http_get(url: str, *, timeout: int = 30) -> requests.Response:
    res = requests.get(url, headers={"User-Agent": UA}, timeout=timeout)
    res.raise_for_status()
    return res


def slugify(value: str) -> str:
    value = value.lower().strip()
    value = re.sub(r"[^a-z0-9]+", "-", value)
    return value.strip("-")[:60] or "product"


def product_slug_from_url(url: str) -> str:
    parsed = urlparse(url)
    parts = [p for p in parsed.path.split("/") if p]
    if not parts:
        return slugify(parsed.netloc)
    return slugify(parts[-1])


def today_dir() -> Path:
    return OUTPUT_FOLDER / datetime.now().strftime("%Y-%m-%d")


def write_json(path: Path, data: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(data, indent=2, ensure_ascii=False))


def read_json(path: Path) -> dict:
    return json.loads(path.read_text())


def append_jsonl(path: Path, obj: dict) -> None:
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("a", encoding="utf-8") as f:
        f.write(json.dumps(obj, ensure_ascii=False) + "\n")


def read_jsonl(path: Path) -> list[dict]:
    if not path.exists():
        return []
    out: list[dict] = []
    for line in path.read_text(encoding="utf-8").splitlines():
        line = line.strip()
        if not line:
            continue
        try:
            out.append(json.loads(line))
        except json.JSONDecodeError:
            continue
    return out


def candidate_id_from_plan(plan: dict) -> str:
    import hashlib
    payload = json.dumps({
        "url": plan.get("url"),
        "slides": [
            (s.get("role"), s.get("text_overlay"), s.get("image_prompt"))
            for s in plan.get("slides") or []
        ],
    }, sort_keys=True, ensure_ascii=False)
    return hashlib.sha1(payload.encode("utf-8")).hexdigest()[:12]


def hook_from_plan(plan: dict) -> str:
    for s in plan.get("slides") or []:
        if s.get("role") == "hook":
            return s.get("text_overlay") or ""
    slides = plan.get("slides") or []
    return (slides[0].get("text_overlay") if slides else "") or ""


def image_prompts_from_plan(plan: dict) -> list[str]:
    return [(s.get("image_prompt") or "") for s in plan.get("slides") or []]


def plan_summary(plan: dict) -> dict:
    product = plan.get("product") or {}
    return {
        "candidate_id": candidate_id_from_plan(plan),
        "product_url": plan.get("url") or product.get("url"),
        "product_name": product.get("name"),
        "category": product.get("category"),
        "hook_text": hook_from_plan(plan),
        "image_prompts": image_prompts_from_plan(plan),
        "slide_count": len(plan.get("slides") or []),
    }


# ========== download ==========

def cmd_download(args: argparse.Namespace) -> None:
    out = Path(args.out).resolve()
    out.parent.mkdir(parents=True, exist_ok=True)
    log(f"GET {args.url}")
    res = http_get(args.url, timeout=60)
    out.write_bytes(res.content)
    print(json.dumps({
        "url": args.url,
        "path": str(out),
        "bytes": len(res.content),
        "content_type": res.headers.get("content-type"),
    }, indent=2))


# ========== palette ==========

def extract_dominant_colors(img: Image.Image, *, n: int = 5) -> list[str]:
    """Return up to n hex colors, skipping near-white / near-black / transparent."""
    rgba = img.convert("RGBA").resize((128, 128))
    pixels: list[tuple[int, int, int]] = []
    for r, g, b, a in list(rgba.getdata()):
        if a < 128:
            continue
        if r > 240 and g > 240 and b > 240:
            continue
        if r < 15 and g < 15 and b < 15:
            continue
        # Quantize to 5-bit per channel so similar shades cluster.
        pixels.append((r >> 3 << 3, g >> 3 << 3, b >> 3 << 3))
    if not pixels:
        return ["#222222"]
    counts = Counter(pixels).most_common(n)
    return [f"#{r:02x}{g:02x}{b:02x}" for (r, g, b), _ in counts]


def cmd_palette(args: argparse.Namespace) -> None:
    path = Path(args.image).resolve()
    if not path.exists():
        raise SystemExit(f"image not found: {path}")
    with Image.open(path) as im:
        colors = extract_dominant_colors(im, n=args.n)
    print(json.dumps({"image": str(path), "colors": colors}, indent=2))


# ========== product page extractor (JSON-LD) ==========

def _iter_jsonld_objects(data):
    if isinstance(data, dict):
        yield data
        for v in data.values():
            yield from _iter_jsonld_objects(v)
    elif isinstance(data, list):
        for item in data:
            yield from _iter_jsonld_objects(item)


def find_product_jsonld(soup: BeautifulSoup) -> dict | None:
    for script in soup.find_all("script", type="application/ld+json"):
        try:
            data = json.loads(script.string or "{}")
        except json.JSONDecodeError:
            continue
        for candidate in _iter_jsonld_objects(data):
            t = candidate.get("@type")
            if t == "Product" or (isinstance(t, list) and "Product" in t):
                return candidate
    return None


def parse_product(url: str) -> dict:
    log(f"fetching product {url}")
    res = http_get(url, timeout=20)
    soup = BeautifulSoup(res.text, "lxml")
    product = find_product_jsonld(soup) or {}

    raw_images = product.get("image") or []
    if isinstance(raw_images, str):
        raw_images = [raw_images]
    elif isinstance(raw_images, dict):
        raw_images = [raw_images.get("url") or raw_images.get("@id")]
    images = [urljoin(url, i) for i in raw_images if i]

    if not images:
        for img in soup.select(".woocommerce-product-gallery__image img, .product-images img, figure img"):
            src = img.get("data-large_image") or img.get("data-src") or img.get("src")
            if src:
                images.append(urljoin(url, src))
        seen: set[str] = set()
        images = [i for i in images if not (i in seen or seen.add(i))]

    offers = product.get("offers") or {}
    if isinstance(offers, list) and offers:
        offers = offers[0]
    price = offers.get("price") if isinstance(offers, dict) else None
    currency = offers.get("priceCurrency") if isinstance(offers, dict) else None

    name = product.get("name") or (soup.title.string.strip() if soup.title else "Product")
    description = product.get("description") or ""
    if not description:
        og_desc = soup.find("meta", property="og:description") or soup.find("meta", attrs={"name": "description"})
        if og_desc:
            description = og_desc.get("content", "")

    aggregate = product.get("aggregateRating") or {}
    rating = aggregate.get("ratingValue") if isinstance(aggregate, dict) else None
    review_count = aggregate.get("reviewCount") if isinstance(aggregate, dict) else None

    brand = product.get("brand")
    if isinstance(brand, dict):
        brand = brand.get("name")

    return {
        "url": url,
        "name": str(name).strip(),
        "description": str(description).strip(),
        "price": str(price) if price is not None else None,
        "currency": currency,
        "images": images[:8],
        "rating": rating,
        "review_count": review_count,
        "brand": brand,
        "sku": product.get("sku"),
    }


def cmd_product(args: argparse.Namespace) -> None:
    print(json.dumps(parse_product(args.url), indent=2, ensure_ascii=False))


# ========== nano-banana image generation ==========

def download_image(url: str) -> Image.Image:
    res = http_get(url, timeout=30)
    return Image.open(io.BytesIO(res.content)).convert("RGB")


def imagery_prior() -> str:
    """Read HOT_IMAGERY.md and return as a prompt prefix, or '' if absent."""
    if not HOT_IMAGERY_FILE.exists():
        return ""
    body = HOT_IMAGERY_FILE.read_text(encoding="utf-8").strip()
    if not body:
        return ""
    return (
        "PRIOR LEARNINGS — visual patterns that have outperformed for this brand. "
        "Apply when interpreting the slide brief; do not contradict explicit slide instructions.\n\n"
        f"{body}\n\n---\n\n"
    )


def cmd_generate(args: argparse.Namespace) -> None:
    from google import genai
    from google.genai import types

    [REDACTED_SECRET]"GEMINI_API_KEY")
    if not api_key:
        raise SystemExit("GEMINI_API_KEY missing in .env")

    plan_path = Path(args.plan).resolve()
    plan = read_json(plan_path)
    out_dir = plan_path.parent
    raw_dir = out_dir / "raw"
    raw_dir.mkdir(parents=True, exist_ok=True)

    product = plan.get("product") or {}
    images = product.get("images") or []
    if not images:
        raise SystemExit("plan has no product.images — agent must include them in plan.json")

    # Reference image: agent can override per-slide via slide["ref_image_index"].
    default_ref_url = images[0]
    log(f"default reference image: {default_ref_url}")

    ref_cache: dict[int, Image.Image] = {}

    def get_ref(idx: int) -> Image.Image:
        if idx not in ref_cache:
            url = images[idx]
            log(f"downloading ref image [{idx}] {url}")
            ref_cache[idx] = download_image(url)
        return ref_cache[idx]

    # Always cache the default ref to disk so the user can inspect it.
    default_ref_img = get_ref(0)
    default_ref_img.save(raw_dir / "_ref.jpg", "JPEG", quality=92)

    client = genai.Client([REDACTED_SECRET])
    slides = plan.get("slides", [])
    if not slides:
        raise SystemExit("plan has no slides")

    prior_prefix = imagery_prior()
    if prior_prefix:
        log(f"using HOT_IMAGERY.md prior ({len(prior_prefix)} chars prepended to each slide)")

    for i, slide in enumerate(slides, start=1):
        slot = raw_dir / f"slide_{i:02d}.png"
        if slot.exists() and not args.force:
            log(f"  slide {i:02d} already exists, skip (use --force to regenerate)")
            continue
        slide_prompt = slide.get("image_prompt") or "Stylized product shot."
        prompt = prior_prefix + slide_prompt
        ref_idx = slide.get("ref_image_index", 0)
        if not (0 <= ref_idx < len(images)):
            ref_idx = 0
        ref = get_ref(ref_idx)
        log(f"  generating slide {i:02d} ({slide.get('role','?')}) using ref [{ref_idx}] …")
        try:
            res = client.models.generate_content(
                model=GEMINI_IMAGE_MODEL,
                contents=[ref, prompt],
                config=types.GenerateContentConfig(
                    response_modalities=["IMAGE", "TEXT"],
                ),
            )
        except Exception as e:  # noqa: BLE001
            raise SystemExit(f"nano-banana call failed on slide {i}: {e}")

        saved = False
        for cand in res.candidates or []:
            for part in (cand.content.parts or []):
                inline = getattr(part, "inline_data", None)
                if inline and inline.data:
                    img = Image.open(io.BytesIO(inline.data)).convert("RGB")
                    img.save(slot, "PNG")
                    log(f"    → {slot} ({img.size[0]}x{img.size[1]})")
                    saved = True
                    break
            if saved:
                break
        if not saved:
            raise SystemExit(f"nano-banana returned no image for slide {i}")

    print(str(raw_dir))


# ========== Pillow composer ==========

def find_font(*, size: int) -> ImageFont.FreeTypeFont:
    override = os.getenv("BRAND_FONT_PATH")
    candidates: list[str] = []
    if override:
        candidates.append(override)
    candidates.extend(DEFAULT_FONT_CANDIDATES)
    for path in candidates:
        if path and Path(path).exists():
            try:
                return ImageFont.truetype(path, size)
            except OSError:
                continue
    return ImageFont.load_default()


def fit_text(draw: ImageDraw.ImageDraw, text: str, *,
             max_width: int, max_size: int, min_size: int) -> tuple[ImageFont.FreeTypeFont, list[str]]:
    """Decrease font size until each wrapped line fits in max_width."""
    words = text.split()
    if not words:
        return find_font(size=max_size), [""]

    lines: list[str] = []
    for size in range(max_size, min_size - 1, -4):
        font = find_font(size=size)
        lines = []
        cur = words[0]
        for w in words[1:]:
            tentative = f"{cur} {w}"
            bbox = draw.textbbox((0, 0), tentative, font=font)
            if bbox[2] - bbox[0] <= max_width:
                cur = tentative
            else:
                lines.append(cur)
                cur = w
        lines.append(cur)
        if all(draw.textbbox((0, 0), ln, font=font)[2] <= max_width for ln in lines):
            return font, lines
    return find_font(size=min_size), lines


def hex_to_rgb(hex_color: str) -> tuple[int, int, int]:
    hex_color = hex_color.lstrip("#")
    if len(hex_color) == 3:
        hex_color = "".join(ch * 2 for ch in hex_color)
    return tuple(int(hex_color[i:i + 2], 16) for i in (0, 2, 4))


def fit_to_canvas(img: Image.Image, w: int, h: int) -> Image.Image:
    """Resize to fully cover w x h while keeping aspect, then center-crop."""
    src_ratio = img.width / img.height
    dst_ratio = w / h
    if src_ratio > dst_ratio:
        new_h = h
        new_w = int(round(h * src_ratio))
    else:
        new_w = w
        new_h = int(round(w / src_ratio))
    img2 = img.resize((new_w, new_h), Image.LANCZOS)
    left = (new_w - w) // 2
    top = (new_h - h) // 2
    return img2.crop((left, top, left + w, top + h))


def overlay_logo(canvas: Image.Image, logo_path: str | None) -> None:
    if not logo_path or not Path(logo_path).exists():
        return
    try:
        with Image.open(logo_path) as raw:
            logo = raw.convert("RGBA")
            target_w = int(CANVAS_W * 0.18)
            ratio = target_w / logo.width
            target_h = max(1, int(logo.height * ratio))
            logo = logo.resize((target_w, target_h), Image.LANCZOS)
            margin = int(CANVAS_W * 0.04)
            canvas.paste(logo, (margin, CANVAS_H - target_h - margin), logo)
    except Exception as e:  # noqa: BLE001
        log(f"logo overlay skipped: {e}")


def compose_slide(raw_path: Path, text: str, brand: dict, out_path: Path, *, role: str) -> None:
    primary = brand.get("primary_color") or "#000000"
    accent = brand.get("accent_color") or "#ffffff"

    with Image.open(raw_path).convert("RGB") as raw:
        canvas = fit_to_canvas(raw, CANVAS_W, CANVAS_H)

    # Bottom gradient so text stays legible on busy images.
    gradient = Image.new("RGBA", (CANVAS_W, CANVAS_H), (0, 0, 0, 0))
    gd = ImageDraw.Draw(gradient)
    grad_h = int(CANVAS_H * 0.45)
    for y in range(grad_h):
        alpha = int(180 * (y / grad_h))
        gd.line([(0, CANVAS_H - grad_h + y), (CANVAS_W, CANVAS_H - grad_h + y)], fill=(0, 0, 0, alpha))
    canvas = Image.alpha_composite(canvas.convert("RGBA"), gradient)

    if text:
        draw = ImageDraw.Draw(canvas)
        max_w = int(CANVAS_W * 0.86)
        max_size = 140 if role == "hook" else 96
        min_size = 48
        rendered_text = text.upper() if role in {"hook", "cta"} else text
        font, lines = fit_text(
            draw, rendered_text,
            max_width=max_w, max_size=max_size, min_size=min_size,
        )
        ascent, descent = font.getmetrics()
        line_h = ascent + descent + int(font.size * 0.15)
        block_h = line_h * len(lines)
        y = CANVAS_H - block_h - int(CANVAS_H * 0.10)
        margin_x = int(CANVAS_W * 0.07)
        for ln in lines:
            draw.text(
                (margin_x, y), ln, font=font,
                fill=accent if role in {"hook", "cta"} else "white",
                stroke_width=max(2, font.size // 24),
                stroke_fill=primary,
            )
            y += line_h

    overlay_logo(canvas, brand.get("logo_path"))
    canvas.convert("RGB").save(out_path, "PNG")


def cmd_compose(args: argparse.Namespace) -> None:
    plan_path = Path(args.plan).resolve()
    plan = read_json(plan_path)
    out_dir = plan_path.parent
    raw_dir = out_dir / "raw"
    if not raw_dir.exists():
        raise SystemExit("no raw/ folder — run `generate` first")

    brand = plan.get("brand") or {}
    finals: list[Path] = []
    for i, slide in enumerate(plan.get("slides", []), start=1):
        raw = raw_dir / f"slide_{i:02d}.png"
        if not raw.exists():
            raise SystemExit(f"missing raw image for slide {i}: {raw}")
        out = out_dir / f"slide_{i:02d}.png"
        compose_slide(
            raw, slide.get("text_overlay", ""), brand, out, role=slide.get("role", ""),
        )
        log(f"  composed slide {i:02d} → {out}")
        finals.append(out)

    print(json.dumps([str(p) for p in finals], indent=2))


# ========== Upload-Post carousel ==========

def cmd_publish(args: argparse.Namespace) -> None:
    [REDACTED_SECRET]"UPLOAD_POST_API_KEY")
    profile = os.getenv("UPLOAD_POST_PROFILE")
    if not api_key or not profile:
        raise SystemExit("UPLOAD_POST_API_KEY or UPLOAD_POST_PROFILE missing in .env")

    plan_path = Path(args.plan).resolve()
    plan = read_json(plan_path)
    out_dir = plan_path.parent

    slides = sorted(out_dir.glob("slide_*.png"))
    if not slides:
        raise SystemExit("no composed slides — run `compose` first")
    if len(slides) < 2:
        raise SystemExit(f"need at least 2 slides for a carousel, got {len(slides)}")
    if len(slides) > 10:
        log(f"capping carousel at 10 slides (had {len(slides)})")
        slides = slides[:10]

    caption = plan.get("caption", "")
    hashtags = plan.get("hashtags") or []
    description = caption + ("\n\n" + " ".join(hashtags) if hashtags else "")
    title = (plan.get("product") or {}).get("name") or "New product"

    platforms = [p.strip() for p in args.platforms.split(",") if p.strip()]

    data: list[tuple[str, str]] = [
        ("user", profile),
        ("title", title[:140]),
        ("caption", description),
    ]
    for p in platforms:
        data.append(("platform[]", p))
    if "tiktok" in platforms:
        post_mode = "MEDIA_UPLOAD" if args.tiktok_mode == "draft" else "DIRECT_POST"
        data.append(("post_mode", post_mode))

    if args.dry_run:
        print(json.dumps({
            "DRY_RUN": True,
            "endpoint": f"{UPLOAD_POST_BASE}/upload_photos",
            "slides": [str(p) for p in slides],
            "fields": data,
        }, indent=2, ensure_ascii=False))
        return

    files = []
    handles = []
    try:
        for s in slides:
            fh = s.open("rb")
            handles.append(fh)
            files.append(("photos[]", (s.name, fh, "image/png")))

        log(f"uploading {len(slides)} slides to {platforms} …")
        res = requests.post(
            f"{UPLOAD_POST_BASE}/upload_photos",
            headers={"Authorization": f"Apikey {api_key}"},
            data=data,
            files=files,
            timeout=600,
        )
    finally:
        for fh in handles:
            fh.close()

    if res.status_code >= 400:
        sys.stderr.write(res.text + "\n")
        raise SystemExit(f"upload-post HTTP {res.status_code}")

    body = res.json()
    log("publish OK")

    published_at = datetime.now().isoformat(timespec="seconds")
    history = out_dir / "publish.json"
    history.write_text(json.dumps({
        "published_at": published_at,
        "url": plan.get("url"),
        "platforms": platforms,
        "response": body,
    }, indent=2, ensure_ascii=False))

    request_id = body.get("request_id") if isinstance(body, dict) else None
    summary = plan_summary(plan)
    append_jsonl(POST_HISTORY, {
        **summary,
        "request_id": request_id,
        "published_at": published_at,
        "platforms": platforms,
        "out_dir": str(out_dir),
    })
    log(f"appended to {POST_HISTORY.relative_to(ROOT)} (candidate_id={summary['candidate_id']})")

    print(json.dumps(body, indent=2, ensure_ascii=False))


# ========== state subcommands ==========

def load_state() -> dict:
    if not STATE_FILE.exists():
        return {"cycle_started_at": None, "store": None, "products": []}
    state = json.loads(STATE_FILE.read_text())
    state.setdefault("cycle_started_at", None)
    state.setdefault("store", None)
    state.setdefault("products", [])
    return state


def save_state(state: dict) -> None:
    STATE_FOLDER.mkdir(parents=True, exist_ok=True)
    STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False))


def cmd_mark_processed(args: argparse.Namespace) -> None:
    state = load_state()
    now = datetime.now().isoformat(timespec="seconds")
    if state.get("cycle_started_at") is None:
        state["cycle_started_at"] = now
    if args.store:
        state["store"] = args.store

    existing = next((p for p in state["products"] if p["url"] == args.url), None)
    if existing:
        existing["last_processed_at"] = now
        existing["cycles_count"] = existing.get("cycles_count", 0) + 1
        existing["slides_published"] = args.slides
    else:
        state["products"].append({
            "url": args.url,
            "first_processed_at": now,
            "last_processed_at": now,
            "cycles_count": 1,
            "slides_published": args.slides,
        })
    save_state(state)
    log(f"marked {args.url} as processed (cycle started {state['cycle_started_at']})")


def cmd_list_processed(_: argparse.Namespace) -> None:
    print(json.dumps(load_state(), indent=2, ensure_ascii=False))


def cmd_new_cycle(_: argparse.Namespace) -> None:
    state = load_state()
    state["cycle_started_at"] = datetime.now().isoformat(timespec="seconds")
    save_state(state)
    log(f"new cycle started at {state['cycle_started_at']}")
    print(json.dumps({"cycle_started_at": state["cycle_started_at"]}, indent=2))


# ========== learnings: priors + log-candidate ==========

def cmd_priors(_: argparse.Namespace) -> None:
    """Dump current HOT_HOOKS.md and HOT_IMAGERY.md so the agent can inject them
    into the planning step. Empty strings if no prior exists yet."""
    payload = {
        "hooks": HOT_HOOKS_FILE.read_text(encoding="utf-8") if HOT_HOOKS_FILE.exists() else "",
        "imagery": HOT_IMAGERY_FILE.read_text(encoding="utf-8") if HOT_IMAGERY_FILE.exists() else "",
    }
    print(json.dumps(payload, indent=2, ensure_ascii=False))


def cmd_log_candidate(args: argparse.Namespace) -> None:
    """Append the agent's INITIAL plan proposal to candidate-history.jsonl, before
    any user editing. Reflect later compares this against post-history (what
    actually shipped) to extract the user's qualitative filter."""
    plan = read_json(Path(args.plan).resolve())
    summary = plan_summary(plan)
    append_jsonl(CANDIDATE_HISTORY, {
        **summary,
        "logged_at": datetime.now().isoformat(timespec="seconds"),
    })
    log(f"candidate {summary['candidate_id']} → {CANDIDATE_HISTORY.relative_to(ROOT)}")
    print(json.dumps({"candidate_id": summary["candidate_id"]}, indent=2))


# ========== learnings: learn (weekly metrics-driven) ==========

LEARN_HOOKS_PROMPT = """You are a senior ecommerce social-media strategist. You're refreshing a creator's HOT.md of HOOK COPY patterns based on real engagement data from their Instagram + TikTok carousels.

Below you will see:
1. The CURRENT HOT_HOOKS.md (or empty if none yet) — patterns we currently believe in.
2. WINNERS — carousels in the top percentile by composite score (z(views) * w_views + z(engagement_rate) * w_eng).
3. LOSERS — carousels in the bottom percentile.

For each carousel: the slide-1 hook_text on screen, slide_count, product_name, category, per-platform metrics, plus the rest of the slide hooks/text overlays for context.

YOUR JOB
Produce an updated HOT_HOOKS.md focused EXCLUSIVELY on slide-1 hook copy patterns. Examples of useful patterns: hook length, presence of numbers, question vs. statement form, emotional register, specificity, CTAs.

CONSTRAINTS
- Maximum 60 lines of markdown.
- Each bullet is single, actionable, falsifiable. No platitudes.
- Cite sample sizes: "(seen in 4/5 winners, 0/5 losers)".
- Merge with existing HOT_HOOKS.md: keep what's still corroborated, drop what's contradicted, add new patterns.
- Write in the dominant language of the hooks themselves.
- If evidence is weak (<3 winners or <3 losers), output the existing HOT_HOOKS.md plus a single appended bullet noting "evidence still thin, N carousels analyzed".

OUTPUT
Return ONLY the updated HOT_HOOKS.md content as plain markdown. No preamble, no JSON wrapper."""


LEARN_IMAGERY_PROMPT = """You are a senior visual director. You're refreshing a creator's HOT.md of IMAGE-PROMPT patterns based on real engagement data from their Instagram + TikTok carousels generated with Gemini 2.5 Flash Image (nano-banana).

Below you will see:
1. The CURRENT HOT_IMAGERY.md (or empty if none yet).
2. WINNERS — top-percentile carousels.
3. LOSERS — bottom-percentile carousels.

For each carousel: the per-slide image_prompts (the exact text sent to nano-banana), product_name, category, slide_count, per-platform metrics.

YOUR JOB
Produce an updated HOT_IMAGERY.md focused EXCLUSIVELY on image-prompt patterns. Examples of useful patterns: lighting style ("soft golden-hour", "studio softbox"), composition ("centered hero", "rule-of-thirds"), background ("minimalist gradient", "lifestyle context"), framing ("macro close-up", "wide flat-lay"), color treatment, subject pose, prop usage.

CONSTRAINTS
- Maximum 60 lines of markdown.
- Each bullet is single, actionable, falsifiable. No platitudes.
- Cite sample sizes: "(seen in 4/5 winners, 0/5 losers)".
- Merge with existing HOT_IMAGERY.md: keep what's still corroborated, drop what's contradicted.
- This file is auto-prepended to every nano-banana call, so write it AS DIRECT GUIDANCE TO AN IMAGE MODEL — use directive language ("prefer", "avoid").
- If evidence is weak (<3 winners or <3 losers), output the existing HOT_IMAGERY.md plus a single bullet noting "evidence still thin, N carousels analyzed".
- If the winners and losers have no image_prompts at all (empty lists everywhere), output the existing HOT_IMAGERY.md unchanged plus a single bullet noting "no image_prompt data available — keeping existing priors".

OUTPUT
Return ONLY the updated HOT_IMAGERY.md content as plain markdown. No preamble, no JSON wrapper."""


def _post_metrics(platforms: dict) -> dict:
    """Sum views/engagement across all platforms in a post-analytics response."""
    total_views = 0
    total_engagement = 0
    per_platform: dict = {}
    for platform, data in (platforms or {}).items():
        m = (data or {}).get("post_metrics") or {}
        views = int(m.get("views") or m.get("impressions") or m.get("reach") or 0)
        likes = int(m.get("likes") or 0)
        comments = int(m.get("comments") or 0)
        shares = int(m.get("shares") or 0)
        saves = int(m.get("saves") or 0)
        eng = likes + comments + shares + saves
        total_views += views
        total_engagement += eng
        per_platform[platform] = {
            "views": views, "likes": likes, "comments": comments,
            "shares": shares, "saves": saves, "engagement": eng,
        }
    eng_rate = total_engagement / total_views if total_views else 0.0
    return {
        "total_views": total_views,
        "total_engagement": total_engagement,
        "engagement_rate": eng_rate,
        "per_platform": per_platform,
    }


def _zscore(values: list[float]) -> list[float]:
    if not values:
        return []
    n = len(values)
    mean = sum(values) / n
    var = sum((v - mean) ** 2 for v in values) / n
    sd = var ** 0.5
    if sd == 0:
        return [0.0] * n
    return [(v - mean) / sd for v in values]


def cmd_learn(args: argparse.Namespace) -> None:
    from google import genai
    from google.genai import types

    api_key_up = os.getenv("UPLOAD_POST_API_KEY")
    api_key_g = os.getenv("GEMINI_API_KEY")
    if not api_key_up:
        raise SystemExit("UPLOAD_POST_API_KEY missing in .env")
    if not api_key_g:
        raise SystemExit("GEMINI_API_KEY missing in .env")

    history = read_jsonl(POST_HISTORY)
    if not history:
        raise SystemExit("post-history.jsonl is empty — publish some carousels first")

    now = datetime.now()
    soak_seconds = args.soak_days * 86400
    max_age_seconds = args.max_age_days * 86400

    eligible = []
    for h in history:
        try:
            pub = datetime.fromisoformat(h["published_at"])
        except (KeyError, ValueError):
            continue
        age = (now - pub).total_seconds()
        if soak_seconds <= age <= max_age_seconds:
            eligible.append(h)

    log(f"[learn] {len(eligible)} carousels in soak window ({args.soak_days}-{args.max_age_days}d)")
    if not eligible:
        raise SystemExit("no carousels in soak window — wait or shorten --soak-days")

    enriched = []
    for h in eligible:
        rid = h.get("request_id")
        if not rid:
            continue
        url = f"{UPLOAD_POST_BASE}/uploadposts/post-analytics/{rid}"
        try:
            r = requests.get(url, headers={"Authorization": f"Apikey {api_key_up}"}, timeout=30)
        except requests.RequestException as e:
            log(f"  {rid}: HTTP error {e}")
            continue
        if r.status_code >= 400:
            log(f"  {rid}: HTTP {r.status_code}: {r.text[:200]}")
            continue
        body = r.json()
        append_jsonl(METRICS_FILE, {
            "fetched_at": now.isoformat(timespec="seconds"),
            "request_id": rid,
            "candidate_id": h.get("candidate_id"),
            "raw": body,
        })
        m = _post_metrics(body.get("platforms") or {})
        enriched.append({**h, "metrics": m})

    if len(enriched) < args.min_cohort:
        msg = (f"only {len(enriched)} carousels have analytics — need >={args.min_cohort} "
               f"to refresh priors, retry later")
        log(f"[learn] {msg}")
        run_path = RUNS_FOLDER / f"learn-{now.strftime('%Y-%m-%d')}.md"
        run_path.parent.mkdir(parents=True, exist_ok=True)
        run_path.write_text(f"# Learn run — {now.date()}\n\n{msg}\n", encoding="utf-8")
        return

    views = [c["metrics"]["total_views"] for c in enriched]
    engs = [c["metrics"]["engagement_rate"] for c in enriched]
    z_views = _zscore(views)
    z_engs = _zscore(engs)
    for i, c in enumerate(enriched):
        c["composite"] = args.weight_views * z_views[i] + args.weight_engagement * z_engs[i]

    enriched.sort(key=lambda c: c["composite"], reverse=True)
    n = len(enriched)
    top_n = max(args.min_per_bucket, int(n * args.top_pct))
    bot_n = max(args.min_per_bucket, int(n * args.bottom_pct))
    # Guard against overlap when n is small.
    if top_n + bot_n > n:
        top_n = bot_n = max(1, n // 2)
    winners = enriched[:top_n]
    losers = enriched[-bot_n:]

    def render(c: dict) -> str:
        m = c["metrics"]
        return json.dumps({
            "product_name": c.get("product_name"),
            "category": c.get("category"),
            "hook_text": c.get("hook_text"),
            "image_prompts": c.get("image_prompts"),
            "slide_count": c.get("slide_count"),
            "metrics": {
                "total_views": m["total_views"],
                "total_engagement": m["total_engagement"],
                "engagement_rate": round(m["engagement_rate"], 4),
                "per_platform": m["per_platform"],
            },
            "composite_score": round(c["composite"], 3),
        }, ensure_ascii=False)

    winners_text = "\n".join(render(c) for c in winners)
    losers_text = "\n".join(render(c) for c in losers)

    client = genai.Client([REDACTED_SECRET])

    def refresh(meta_prompt: str, current: str, target: Path, label: str) -> str:
        full = (
            meta_prompt
            + f"\n\n## CURRENT {target.name}\n"
            + (current or "(empty — first learn run)")
            + f"\n\n## WINNERS (top {len(winners)} of {n})\n" + winners_text
            + f"\n\n## LOSERS (bottom {len(losers)} of {n})\n" + losers_text
        )
        log(f"  calling {GEMINI_TEXT_MODEL} for {label}…")
        resp = client.models.generate_content(
            model=GEMINI_TEXT_MODEL,
            contents=[full],
            config=types.GenerateContentConfig(response_mime_type="text/plain"),
        )
        new_body = (resp.text or "").strip()
        if not new_body:
            log(f"  {label}: model returned empty output, keeping current")
            return current
        if target.exists():
            backup = LEARNINGS_FOLDER / f"{target.stem}.{now.strftime('%Y%m%d-%H%M%S')}.md.bak"
            backup.write_text(target.read_text(encoding="utf-8"), encoding="utf-8")
        LEARNINGS_FOLDER.mkdir(parents=True, exist_ok=True)
        target.write_text(new_body + "\n", encoding="utf-8")
        log(f"  {target.name} updated ({len(new_body)} chars)")
        return new_body

    current_hooks = HOT_HOOKS_FILE.read_text(encoding="utf-8") if HOT_HOOKS_FILE.exists() else ""
    current_imagery = HOT_IMAGERY_FILE.read_text(encoding="utf-8") if HOT_IMAGERY_FILE.exists() else ""
    new_hooks = refresh(LEARN_HOOKS_PROMPT, current_hooks, HOT_HOOKS_FILE, "HOT_HOOKS.md")
    new_imagery = refresh(LEARN_IMAGERY_PROMPT, current_imagery, HOT_IMAGERY_FILE, "HOT_IMAGERY.md")

    run_path = RUNS_FOLDER / f"learn-{now.strftime('%Y-%m-%d')}.md"
    run_path.parent.mkdir(parents=True, exist_ok=True)
    audit = [
        f"# Learn run — {now.isoformat(timespec='seconds')}",
        "",
        f"- soak: {args.soak_days}d / max age: {args.max_age_days}d",
        f"- weights: views={args.weight_views} engagement={args.weight_engagement}",
        f"- cohort: {n} carousels with analytics",
        f"- winners ({len(winners)}):",
    ]
    for w in winners:
        audit.append(
            f"  - score={w['composite']:.2f}  views={w['metrics']['total_views']}  "
            f"eng_rate={w['metrics']['engagement_rate']:.4f}  hook=\"{w.get('hook_text')}\""
        )
    audit.append(f"- losers ({len(losers)}):")
    for l in losers:
        audit.append(
            f"  - score={l['composite']:.2f}  views={l['metrics']['total_views']}  "
            f"eng_rate={l['metrics']['engagement_rate']:.4f}  hook=\"{l.get('hook_text')}\""
        )
    audit += ["", "## New HOT_HOOKS.md", "", new_hooks, "", "## New HOT_IMAGERY.md", "", new_imagery]
    run_path.write_text("\n".join(audit), encoding="utf-8")
    log(f"[learn] audit → {run_path}")


# ========== learnings: reflect (qualitative pass) ==========

REFLECT_PROMPT = """You are observing how a creator manually edits AI-proposed ecommerce carousel plans BEFORE engagement data exists.

You will see:
1. CANDIDATES — every initial plan the agent proposed (logged via log-candidate, before any user editing).
2. PUBLISHED — every carousel that actually shipped.

A candidate is "approved unchanged" if its candidate_id appears in PUBLISHED. A candidate is "rejected/edited" if its candidate_id is NOT in PUBLISHED for the same product (the agent or user revised it before publishing).

YOUR JOB
Identify qualitative patterns that explain the user's filter. Two separate buckets:
- HOOK observations (slide-1 text)
- IMAGERY observations (image_prompts)

Examples: "approves hooks containing a specific number", "rejects question-form hooks", "approves image_prompts mentioning soft natural light", "rejects busy lifestyle backgrounds".

CONSTRAINTS
- 3-6 hook observations + 3-6 imagery observations.
- Each: rule + evidence count ("approved 4/5 hooks <8 words, edited 3/3 with >12 words").
- Do not extrapolate to engagement.
- Write in the dominant language of the candidate hooks.

OUTPUT
Return STRICT JSON:
{"hook_observations": [{"rule": "...", "evidence": "..."}, ...],
 "imagery_observations": [{"rule": "...", "evidence": "..."}, ...]}"""


def cmd_reflect(args: argparse.Namespace) -> None:
    from google import genai
    from google.genai import types

    api_key_g = os.getenv("GEMINI_API_KEY")
    if not api_key_g:
        raise SystemExit("GEMINI_API_KEY missing in .env")

    candidates = read_jsonl(CANDIDATE_HISTORY)
    posts = read_jsonl(POST_HISTORY)
    if not candidates:
        raise SystemExit("candidate-history.jsonl is empty — call log-candidate after planning at least once")
    if not posts:
        raise SystemExit("post-history.jsonl is empty — publish some carousels first")

    cutoff = datetime.now().timestamp() - args.window_days * 86400

    def in_window(rec: dict, key: str) -> bool:
        try:
            return datetime.fromisoformat(rec[key]).timestamp() >= cutoff
        except (KeyError, ValueError):
            return False

    recent_candidates = [c for c in candidates if in_window(c, "logged_at")]
    recent_posts = [p for p in posts if in_window(p, "published_at")]
    published_ids = {p.get("candidate_id") for p in recent_posts}

    approved = [c for c in recent_candidates if c.get("candidate_id") in published_ids]
    rejected = [c for c in recent_candidates if c.get("candidate_id") not in published_ids]

    log(f"[reflect] window {args.window_days}d: {len(approved)} approved-unchanged, "
        f"{len(rejected)} edited/rejected, {len(recent_posts)} total posts")

    if not recent_candidates:
        raise SystemExit(f"no candidates in last {args.window_days} days")
    if not (approved and rejected) and not recent_posts:
        raise SystemExit("need either approved+rejected candidates OR posts in window")

    def short(c: dict) -> dict:
        return {
            "product_name": c.get("product_name"),
            "category": c.get("category"),
            "hook_text": c.get("hook_text"),
            "image_prompts": c.get("image_prompts"),
            "slide_count": c.get("slide_count"),
        }

    full_prompt = (
        REFLECT_PROMPT
        + "\n\n## CANDIDATES (initial agent proposals)\n"
        + json.dumps([short(c) for c in recent_candidates], ensure_ascii=False, indent=2)
        + "\n\n## PUBLISHED (final carousels that shipped)\n"
        + json.dumps([short(p) for p in recent_posts], ensure_ascii=False, indent=2)
    )

    client = genai.Client([REDACTED_SECRET])
    log(f"  calling {GEMINI_TEXT_MODEL}…")
    resp = client.models.generate_content(
        model=GEMINI_TEXT_MODEL,
        contents=[full_prompt],
        config=types.GenerateContentConfig(response_mime_type="application/json"),
    )

    try:
        data = json.loads(resp.text)
    except (json.JSONDecodeError, TypeError):
        raise SystemExit(f"Gemini returned non-JSON: {(resp.text or '')[:300]}")

    now = datetime.now()
    run_path = RUNS_FOLDER / f"reflect-{now.strftime('%Y-%m-%d-%H%M')}.md"
    run_path.parent.mkdir(parents=True, exist_ok=True)
    lines = [
        f"# Reflect run — {now.isoformat(timespec='seconds')}",
        "",
        f"- window: last {args.window_days} days",
        f"- approved-unchanged: {len(approved)} / edited-or-rejected: {len(rejected)}",
        f"- posts in window: {len(recent_posts)}",
        "",
        "## Hook observations (NOT auto-promoted to HOT_HOOKS.md — read and curate)",
        "",
    ]
    for o in data.get("hook_observations") or []:
        lines.append(f"- **{o.get('rule')}** — {o.get('evidence')}")
    lines += ["", "## Imagery observations (NOT auto-promoted to HOT_IMAGERY.md — read and curate)", ""]
    for o in data.get("imagery_observations") or []:
        lines.append(f"- **{o.get('rule')}** — {o.get('evidence')}")
    run_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    log(f"[reflect] {len(data.get('hook_observations') or [])} hook + "
        f"{len(data.get('imagery_observations') or [])} imagery observations → {run_path}")
    print(json.dumps(data, indent=2, ensure_ascii=False))


# ========== CLI ==========

def build_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(prog="autoecom")
    sub = p.add_subparsers(dest="cmd", required=True)

    s = sub.add_parser("download", help="download a URL to a local path")
    s.add_argument("url")
    s.add_argument("out", help="output file path")
    s.set_defaults(func=cmd_download)

    s = sub.add_parser("palette", help="extract dominant hex colors from an image")
    s.add_argument("image", help="path to image file")
    s.add_argument("--n", type=int, default=5)
    s.set_defaults(func=cmd_palette)

    s = sub.add_parser("product", help="parse a product page's JSON-LD into a dict")
    s.add_argument("url")
    s.set_defaults(func=cmd_product)

    s = sub.add_parser("generate", help="nano-banana → raw stylized images per slide")
    s.add_argument("plan", help="path to plan.json")
    s.add_argument("--force", action="store_true", help="regenerate slides even if already cached")
    s.set_defaults(func=cmd_generate)

    s = sub.add_parser("compose", help="Pillow overlay → final 1080x1350 carousel slides")
    s.add_argument("plan", help="path to plan.json")
    s.set_defaults(func=cmd_compose)

    s = sub.add_parser("publish", help="Upload-Post carousel to IG + TikTok")
    s.add_argument("plan", help="path to plan.json")
    s.add_argument("--platforms", default="instagram,tiktok",
                   help="comma-separated: instagram, tiktok")
    s.add_argument("--tiktok-mode", default="draft", choices=["draft", "direct"],
                   help="TikTok upload mode (default: draft, never auto-publishes)")
    s.add_argument("--dry-run", action="store_true")
    s.set_defaults(func=cmd_publish)

    s = sub.add_parser("mark-processed", help="record that a product URL was just published")
    s.add_argument("url")
    s.add_argument("--slides", type=int, default=0)
    s.add_argument("--store", help="record/update the store URL on the state file")
    s.set_defaults(func=cmd_mark_processed)

    s = sub.add_parser("list-processed", help="dump processed.json to stdout")
    s.set_defaults(func=cmd_list_processed)

    s = sub.add_parser("new-cycle", help="manually start a new cycle (admin)")
    s.set_defaults(func=cmd_new_cycle)

    s = sub.add_parser("priors", help="dump current HOT_HOOKS.md + HOT_IMAGERY.md as JSON")
    s.set_defaults(func=cmd_priors)

    s = sub.add_parser("log-candidate",
                       help="append the agent's INITIAL plan proposal to candidate-history.jsonl")
    s.add_argument("plan", help="path to plan.json")
    s.set_defaults(func=cmd_log_candidate)

    s = sub.add_parser("learn",
                       help="weekly: pull Upload-Post analytics, refresh HOT_HOOKS.md + HOT_IMAGERY.md")
    s.add_argument("--soak-days", type=int, default=7,
                   help="ignore carousels younger than this (analytics not mature)")
    s.add_argument("--max-age-days", type=int, default=90,
                   help="ignore carousels older than this (stale)")
    s.add_argument("--top-pct", type=float, default=0.20)
    s.add_argument("--bottom-pct", type=float, default=0.20)
    s.add_argument("--weight-views", type=float, default=0.6)
    s.add_argument("--weight-engagement", type=float, default=0.4)
    s.add_argument("--min-cohort", type=int, default=7,
                   help="minimum total carousels with analytics to run synthesis")
    s.add_argument("--min-per-bucket", type=int, default=3,
                   help="minimum winners and minimum losers per bucket")
    s.set_defaults(func=cmd_learn)

    s = sub.add_parser("reflect",
                       help="qualitative pass: compare candidates vs published, emit observations")
    s.add_argument("--window-days", type=int, default=30)
    s.set_defaults(func=cmd_reflect)

    return p


def main() -> None:
    args = build_parser().parse_args()
    args.func(args)


if __name__ == "__main__":
    main()


path: requirements.txt
content: google-genai==1.75.0
requests==2.33.1
beautifulsoup4==4.14.3
lxml==6.1.0
python-dotenv==1.2.2
Pillow==12.2.0


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)