Subzero Labs

Free code · Subzero Labs

Etsy seasonal listing refresher

From the Subzero Labs video "Your AI Has Amnesia. This Free Code Makes It Remember".

Free, no sign-up. Copy it, use it, share it.

For: Etsy sellers

The job: Seasonal shoppers search early, last year's year is still in your titles, and rewriting 13 tags per listing is slow.

You give it: Etsy listings export and the visits / orders / search terms you copy from Stats.

It is one agent of the free AI OS starter (https://subzero.bodymindbeing.me/free/ai-os-starter/) - download the whole starter, it runs on sample data out of the box. Nothing is sent anywhere: replies come out as drafts.

Download the AI OS starter (zip) 65 KB

MIT licence. Python 3.10+, no other installs. Unzip, then follow README.md.

Run this one

  1. Set up the starter (the steps on the starter page).
  2. python -m aios run listing_refresher
  3. Edit config.json: point "listing_refresher" at your own files (paths relative to config.json).

Check it yourself

  1. Check data/listing_suggestions.csv: titles at most 140 characters, at most 13 tags of at most 20 characters.
  2. A '2026 planner' title in late 2026 is flagged by plain code; no watchlist name (e.g. a brand or character) survives in a suggested tag.
  3. Season dates are computed in events() - Mother's Day (US) is the 2nd Sunday of May, Black Friday the day after the 4th Thursday of November.

agents/listing_refresher.py

"""ETSY - seasonal listing refresher. Uses your listings export (Shop Manager > Settings > Options > Download Data >
Currently for sale listings), the visits / orders / search terms you copy from your shop Stats, and a seasonal
calendar. When a season is less than `lead_days` away (default 90 days - seasonal shoppers search early, so change
it to suit your shop) it drafts new titles and tags for your weakest listings.
Plain code checks too: a year in a title that buyers have moved past (a "2026 planner" in late 2026), Etsy's
limits on every suggestion (title up to 140 characters, up to 13 tags of up to 20 characters), and no name from your
trademark watchlist in a suggestion (tags with one are dropped, a title with one is marked).
Saves data/listing_suggestions.csv - you paste what you like into Etsy yourself."""
import csv
import re
from datetime import date, timedelta

from aios import llm

from ._common import col, in_text, money, table, today
from .trademark_check import watchlist

TITLE = "Listing refresher"


def _nth_weekday(year, month, weekday, n):
    d = date(year, month, 1)
    d += timedelta(days=(weekday - d.weekday()) % 7)
    return d + timedelta(weeks=n - 1)


def events(year):
    thanksgiving = _nth_weekday(year, 11, 3, 4)
    return {"Valentine's Day": date(year, 2, 14), "Mother's Day (US)": _nth_weekday(year, 5, 6, 2),
            "Father's Day (US)": _nth_weekday(year, 6, 6, 3), "Halloween": date(year, 10, 31),
            "Black Friday": thanksgiving + timedelta(days=1), "Christmas": date(year, 12, 25)}


def upcoming(now, lead):
    """Seasons starting within `lead` days -> [(name, date, days left)]."""
    out = [(n, d, (d - now).days) for y in (now.year, now.year + 1) for n, d in events(y).items()]
    return sorted((e for e in out if 0 <= e[2] <= lead), key=lambda e: e[2])


def clean_tags(tags):
    out = []
    for t in tags:
        t = " ".join(str(t).lower().split())
        if 0 < len(t) <= 20 and t not in out:
            out.append(t)
    return out[:13]


def stale_years(text, now):
    """Years in a title that look out of date: before this year, or this year once October starts (buyers already
    search for next year's planners and calendars)."""
    limit = now.year + (1 if now.month >= 10 else 0)
    return sorted({int(y) for y in re.findall(r"\b(20\d\d)\b", text) if int(y) < limit})


def run(cfg, data, s):
    now = today(cfg)
    lead = s.get("lead_days", 90)
    season = upcoming(now, lead)
    stats = {col(r, "listing", "title").lower(): r for r in table(cfg, s["stats"])} if s.get("stats") else {}
    listings = []
    for r in table(cfg, s["listings"]):
        title = col(r, "title")
        st = stats.get(title.lower(), {})
        visits, orders = money(col(st, "visits", "views")), money(col(st, "orders", "sales"))
        listings.append({"title": title, "tags": col(r, "tags"), "visits": visits, "orders": orders,
                         "terms": col(st, "search terms", "top search terms")})
    attention = []
    for li in listings:
        old = stale_years(li["title"] + " " + li["tags"], now)
        if old:
            attention.append(f"'{li['title'][:60]}' still says {', '.join(map(str, old))} - buyers now search "
                             f"{now.year + (1 if now.month >= 10 else 0)}")
    weakest = sorted(listings, key=lambda x: (x["orders"] / x["visits"] if x["visits"] else 0, x["visits"]))[:s.get("how_many", 3)]
    angle = ("Coming up: " + ", ".join(f"{n} in {left} days ({d})" for n, d, left in season) + "."
             if season else "No big season in the next weeks - just make the listings easier to find.")
    out_rows, items = [], []
    names = watchlist(cfg, s["watchlist"]) if s.get("watchlist") else []
    for li in weakest:
        r = llm.ask_json(f"{angle}\nEtsy listing title: {li['title']}\nCurrent tags: {li['tags']}\n"
                         f"Search terms buyers used to find it: {li['terms'] or 'unknown'}\n"
                         f"Stats: {li['visits']:.0f} visits, {li['orders']:.0f} orders.\n\nSuggest a better title "
                         "(max 140 characters, most important words first, honest - only what the product is; "
                         "match a season only if the product fits it; never add brand, character or celebrity names) "
                         'and 13 tags (max 20 characters each). JSON: {"title": "...", "tags": ["..."]}')
        title, tags = str(r.get("title", ""))[:140], clean_tags(r.get("tags", []))
        risky = [w for w in names if in_text(w, title + " " + " ".join(tags))]  # never suggest someone else's name
        tags = [t for t in tags if not any(in_text(w, t) for w in names)]
        out_rows.append({"listing": li["title"], "new_title": title, "new_tags": ", ".join(tags),
                         "check": ("title uses " + ", ".join(risky)) if risky else ""})
        items.append(f"{li['title']} ({li['visits']:.0f} visits, {li['orders']:.0f} orders) -> {title} | {len(tags)} tags"
                     + (f" | WARNING: the suggested title still uses {', '.join(risky)} (watchlist)" if risky else ""))
    with open(data / "listing_suggestions.csv", "w", newline="", encoding="utf-8") as f:
        w = csv.DictWriter(f, ["listing", "new_title", "new_tags", "check"])
        w.writeheader()
        w.writerows(out_rows)
    attention += [f"{n} in {left} days - new titles/tags ready in data/listing_suggestions.csv" for n, _d, left in season[:2]]
    return {"summary": angle + f" {len(out_rows)} listings refreshed.", "items": items, "attention": attention}

Shared as shown in the video. Replace any <PLACEHOLDERS> with your own values.