# Sito bg_perspective, piano di lavoro

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** mettere online `bg-perspective.vercel.app`, un sito SvelteKit su Turso che ripubblica ogni post del canale come scheda, con home a scatole (concept D), pagina gioco (concept E), pagine autore/editore, sitemap, feed, cron per i permalink, e il comando Python `sito.py pubblica` che lo alimenta.

**Architecture:** SvelteKit 2 con adapter Vercel legge Turso a ogni richiesta e mostra solo le righe con `pubblicato_il <= now`. Le immagini stanno su `bgp-immagini` (Vercel statico già esistente). La pipeline Python scrive su Turso via HTTP (`/v2/pipeline`) quando un post viene messo in coda su Buffer; un cron Vercel alle 19:10 completa i permalink chiedendoli a Buffer.

**Tech Stack:** SvelteKit 2 / Svelte 5, `@sveltejs/adapter-vercel`, `@libsql/client`, `@vercel/analytics`, Vitest; Python 3 con Pillow e `urllib` (già usati in `codice/`); Turso CLI (già loggato come `paoloalby`); Vercel CLI (scope `paoloalbys-projects`).

Specifica: `docs/superpowers/specs/2026-09-14-sito-bg-perspective-design.md`. Concept: `_instagram/sito-concept/D-tavolo-mobile.html` e `E-post.html` (il CSS si copia da lì).

## Global Constraints

- Progetto Vercel `bg-perspective`, database Turso `bg-perspective`, cartella `_instagram/sito/`, comando in `_instagram/codice/sito.py`.
- Nel sito la parola è "scheda", mai "recensione". Testi in italiano. Font Fraunces + Instrument Sans da Google Fonts. Token colore: `--legno:#2a1c13 --legno-luce:#4a3424 --carta:#f1e7d4 --ambra:#E0A340`.
- Nessun cookie, nessun Google Analytics: solo `@vercel/analytics`.
- Ogni query pubblica filtra `pubblicato_il <= now`. Bozze = righe con data futura, invisibili ovunque, 404 sull'URL diretto.
- Chiavi solo in `_instagram/sito/.env` (ignorato da git) e nelle variabili del progetto Vercel: `TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`, `BUFFER_API_KEY`, `CRON_SECRET`, `PUBLIC_BASE_URL`.
- Immagini: `https://bgp-immagini.vercel.app/sito/<slug>/<file>`. Foto 1600 px lato lungo JPG q80, scatola piatta 600 px JPG, scatola 3D 700 px PNG.
- Non si fa commit se Paolo non lo chiede. I passi "Commit" sotto vanno eseguiti solo dopo il suo via libera; in mancanza, si salta.
- Ordine dei post: `pubblicato_il` decrescente. Apostrofi dritti resi tipografici in rendering.

---

### Task 0: verifica dell'endpoint HTTP di Buffer

Il cron e il comando Python parlano con Buffer fuori dall'MCP. L'MCP è a `https://mcp.buffer.com/mcp` con `Authorization: Bearer <chiave>`; l'endpoint GraphQL diretto va verificato prima di scrivere codice che lo usa.

**Files:** nessuno.

- [x] **Step 1: estrai la chiave** da `~/.claude.json` (voce `mcpServers.buffer.headers.Authorization` sotto il progetto "Giochi da Tavolo") e mettila in `_instagram/sito/.env` come `BUFFER_API_KEY=...` (crea la cartella se non c'è). Non stamparla a terminale.

- [x] **Step 2: prova l'endpoint**

Run (Paolo, a mano, perché il classifier blocca le chiamate con chiave):
```bash
set -a; source _instagram/sito/.env; set +a
curl -s -X POST https://api.buffer.com/graphql -H "Authorization: Bearer $BUFFER_API_KEY" -H "Content-Type: application/json" \
  -d '{"query":"{ post(input:{id:\"6a95b8a8459a28441dbac02a\"}) { externalLink text } }"}'
```
Expected: JSON con `externalLink` `https://www.threads.com/@bg_perspective/post/DdJ51qelutR`.

- [x] **Step 3: se risponde 404 o HTML**, prova `https://api.buffer.com/` e la pagina `publish.buffer.com/settings/api` per l'URL documentato. Se nessun endpoint HTTP diretto funziona: il Task 8 (cron) si sostituisce con `python3 codice/sito.py permalink <slug> --ig <url> --th <url>` e la didascalia nel Task 9 si passa con `--didascalia file.txt`. Scrivi la risposta in cima a questo file, sotto "Esito Task 0", così i task successivi sanno quale strada seguire.

**Esito Task 0:** verificato il 2026-09-14: `POST https://api.buffer.com/graphql` con `Authorization: Bearer <chiave MCP>` risponde 200 con `externalLink` e `status`. Task 8 e 9 seguono la strada automatica; `--didascalia` e `permalink` restano come riserva.

---

### Task 1: progetto, database, variabili

**Files:**
- Create: `_instagram/sito/` (scaffold), `_instagram/sito/.env`, `_instagram/sito/.gitignore` (aggiunte), `_instagram/sito/schema.sql`, `_instagram/sito/vercel.json`, `_instagram/sito/svelte.config.js`

**Interfaces:**
- Produces: database `bg-perspective` con tabella `giochi`; variabili d'ambiente su Vercel; progetto Vercel `bg-perspective` collegato.

- [ ] **Step 1: scaffold**

Run:
```bash
cd "/Users/paolo/Documents/Giochi da Tavolo/_instagram"
npx sv@latest create sito --template minimal --types jsdoc --no-add-ons --install npm
cd sito && npm i -D @sveltejs/adapter-vercel vitest && npm i @libsql/client @vercel/analytics
```
Expected: cartella `sito/` con `src/routes/+page.svelte`, `npm run dev` parte.

- [ ] **Step 2: adapter e config**

`_instagram/sito/svelte.config.js`:
```js
import adapter from '@sveltejs/adapter-vercel';
export default { kit: { adapter: adapter({ runtime: 'nodejs22.x' }) } };
```

`_instagram/sito/vercel.json`:
```json
{ "crons": [ { "path": "/api/permalink", "schedule": "10 17 * * *" }, { "path": "/api/permalink", "schedule": "10 18 * * *" } ] }
```
(19:10 di Roma è 17:10 UTC con ora legale e 18:10 senza; due giri al giorno, innocui.)

Aggiungi a `_instagram/sito/.gitignore`: `.env`, `.vercel`.

- [ ] **Step 3: schema**

`_instagram/sito/schema.sql`:
```sql
create table if not exists giochi (
  slug text primary key, post text not null, bgg_id integer not null, titolo text not null,
  autori text not null, anno integer, editore_it text not null, editore_slug text not null, editore_handle text,
  giocatori_min integer, giocatori_max integer, durata_min integer, durata_max integer,
  peso real, peso_etichetta text, categorie text, meccaniche text, testo_nel_gioco text,
  cos_e text not null, come_gira text not null, apertura text, chicca text, domanda text not null, domanda_sotto text,
  partite integer, durate text, foto text not null, alt text, scatola_piatta text, scatola_3d text,
  buffer_ig text, buffer_th text, instagram_url text, threads_url text,
  pubblicato_il text not null, aggiornato_il text not null
);
create index if not exists giochi_pubblicato on giochi(pubblicato_il);
```

Run:
```bash
turso db create bg-perspective --group default
turso db shell bg-perspective < schema.sql
turso db show bg-perspective --url
turso db tokens create bg-perspective
```
Expected: URL `libsql://bg-perspective-paoloalby.aws-eu-west-1.turso.io` e un token. Scrivili in `.env` come `TURSO_DATABASE_URL` e `TURSO_AUTH_TOKEN`. Aggiungi `CRON_SECRET=$(openssl rand -hex 24)` e `PUBLIC_BASE_URL=https://bg-perspective.vercel.app`.

- [ ] **Step 4: progetto Vercel**

Run:
```bash
cd _instagram/sito && vercel link --yes --scope paoloalbys-projects --project bg-perspective
for k in TURSO_DATABASE_URL TURSO_AUTH_TOKEN BUFFER_API_KEY CRON_SECRET PUBLIC_BASE_URL; do
  grep "^$k=" .env | cut -d= -f2- | vercel env add $k production --scope paoloalbys-projects --force; done
```
Expected: `.vercel/project.json` con `projectName: bg-perspective`; `vercel env ls` mostra le cinque variabili. Se `vercel link` non crea il progetto, `vercel deploy --yes --scope paoloalbys-projects` alla prima esecuzione lo crea con il nome della cartella: in quel caso rinominare la cartella temporaneamente o passare `--name bg-perspective`.

- [ ] **Step 5: primo deploy a vuoto**

Run: `vercel deploy --prod --yes --scope paoloalbys-projects`
Expected: `https://bg-perspective.vercel.app` risponde 200 con la pagina di default di SvelteKit.

- [ ] **Step 6: Commit** (solo con via libera): `git add _instagram/sito docs && git commit -m "sito: scaffold SvelteKit, schema Turso, progetto Vercel"`

---

### Task 2: accesso al database e funzioni di testo

**Files:**
- Create: `_instagram/sito/src/lib/db.js`, `_instagram/sito/src/lib/testo.js`, `_instagram/sito/src/lib/testo.test.js`, `_instagram/sito/src/lib/immagini.js`

**Interfaces:**
- Produces: `db` (client libsql), `visibili()`, `perSlug(slug)`, `perAutore(slug)`, `perEditore(slug)`, `ultimo()`, `adesso()`; `tipografico(s)`, `slugifica(s)`, `dataItaliana(iso)`; `img(slug, file)`.

- [ ] **Step 1: test di testo (fallisce)**

`src/lib/testo.test.js`:
```js
import { describe, it, expect } from 'vitest';
import { tipografico, slugifica, dataItaliana } from './testo.js';
describe('testo', () => {
  it('apostrofi tipografici', () => { expect(tipografico("Cos'è l'eredità")).toBe('Cos’è l’eredità'); });
  it('slug', () => { expect(slugifica("L'eredità di Yu")).toBe('l-eredita-di-yu'); expect(slugifica('5×5 City')).toBe('5x5-city'); });
  it('data', () => { expect(dataItaliana('2026-09-11T19:00:00+02:00')).toBe('venerdì 11 settembre 2026'); });
});
```
Run: `npx vitest run` → FAIL (modulo mancante).

- [ ] **Step 2: implementazione**

`src/lib/testo.js`:
```js
export const tipografico = (s) => (s ?? '').replace(/(\w)'/g, '$1’').replace(/'/g, '‘');
export const slugifica = (s) => s.normalize('NFKD').replace(/[̀-ͯ]/g, '').replace(/×/g, 'x')
  .toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
export const dataItaliana = (iso) => new Date(iso).toLocaleDateString('it-IT',
  { weekday: 'long', day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Europe/Rome' });
```

`src/lib/immagini.js`:
```js
export const IMMAGINI = 'https://bgp-immagini.vercel.app/sito';
export const img = (slug, file) => `${IMMAGINI}/${slug}/${file}`;
```

`src/lib/db.js`:
```js
import { createClient } from '@libsql/client';
import { env } from '$env/dynamic/private';
export const db = createClient({ url: env.TURSO_DATABASE_URL, authToken: env.TURSO_AUTH_TOKEN });
export const adesso = () => new Date().toISOString();
const J = ['autori', 'categorie', 'meccaniche', 'durate', 'foto', 'alt'];
const apri = (r) => { const g = { ...r }; for (const k of J) g[k] = r[k] ? JSON.parse(r[k]) : []; return g; };
const q = async (sql, args = []) => (await db.execute({ sql, args })).rows.map(apri);
export const visibili = () => q('select slug,titolo,autori,editore_it,editore_slug,scatola_piatta,pubblicato_il,categorie from giochi where pubblicato_il <= ? order by pubblicato_il desc', [adesso()]);
export const ultimo = async () => (await q('select * from giochi where pubblicato_il <= ? order by pubblicato_il desc limit 1', [adesso()]))[0];
export const perSlug = async (slug) => (await q('select * from giochi where slug = ? and pubblicato_il <= ?', [slug, adesso()]))[0];
export const perEditore = (slug) => q('select slug,titolo,autori,editore_it,editore_slug,editore_handle,scatola_piatta,pubblicato_il from giochi where editore_slug = ? and pubblicato_il <= ? order by pubblicato_il desc', [slug, adesso()]);
export const perAutore = async (slug) => (await visibili()).filter((g) => g.autori.some((a) => a.slug === slug));
```
`perAutore` filtra in memoria: poche decine di righe, e `autori` è JSON. Da rivedere solo se le righe superano qualche centinaio.

- [ ] **Step 3: test passa**: `npx vitest run` → 3 PASS.

- [ ] **Step 4: Commit** (con via libera): `git commit -am "sito: db, testo, immagini"`

---

### Task 3: layout, testata, icone, manifest

**Files:**
- Create: `src/routes/+layout.svelte`, `src/lib/Testata.svelte`, `src/lib/Icona.svelte`, `src/app.css`, `static/manifest.webmanifest`, `static/favicon.ico`, `static/favicon-32.png`, `static/apple-touch-icon.png`, `static/icon-192.png`, `static/icon-512.png`, `static/robots.txt`, `src/routes/+error.svelte`
- Modify: `src/app.html`

**Interfaces:**
- Produces: `<Testata indietro={bool} cerca={bool} bind:query />` e `<Icona nome="instagram|threads|cerca|freccia" />`.

- [ ] **Step 1: foto profilo e icone**

Scarica l'immagine del profilo Instagram (i dadi di Sagrada): aprire `https://www.instagram.com/bg_perspective/` in Chrome, click destro sull'avatar, "Apri immagine in un'altra scheda", salvare in `_instagram/foto/profilo.jpg`. Poi:
```bash
cd _instagram/sito/static && python3 - <<'PY'
from PIL import Image
im=Image.open('../../foto/profilo.jpg').convert('RGB'); w,h=im.size; s=min(w,h); im=im.crop(((w-s)//2,(h-s)//2,(w-s)//2+s,(h-s)//2+s))
for n,p in [(512,'icon-512.png'),(192,'icon-192.png'),(180,'apple-touch-icon.png'),(32,'favicon-32.png')]: im.resize((n,n),Image.LANCZOS).save(p)
im.resize((32,32),Image.LANCZOS).save('favicon.ico')
PY
```
`static/manifest.webmanifest`:
```json
{ "name": "bg_perspective", "short_name": "bg_perspective", "start_url": "/", "display": "standalone",
  "background_color": "#2a1c13", "theme_color": "#2a1c13",
  "icons": [ { "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" } ] }
```
`static/robots.txt`:
```
User-agent: *
Allow: /
Sitemap: https://bg-perspective.vercel.app/sitemap.xml
```

- [ ] **Step 2: app.html**

Nel `<head>` di `src/app.html` aggiungi:
```html
<meta name="theme-color" content="#2a1c13">
<link rel="icon" href="%sveltekit.assets%/favicon.ico" sizes="32x32">
<link rel="icon" href="%sveltekit.assets%/favicon-32.png" type="image/png">
<link rel="apple-touch-icon" href="%sveltekit.assets%/apple-touch-icon.png">
<link rel="manifest" href="%sveltekit.assets%/manifest.webmanifest">
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,600&family=Instrument+Sans:wght@400;500;600&display=swap">
```
e `<html lang="it">`.

- [ ] **Step 3: CSS globale**

`src/app.css`: i token e le regole di base prese dal blocco `<style>` di `D-tavolo-mobile.html` (`:root`, `*`, `body`, `body:before`, `a`, `:focus-visible`, `header`, `.riga`, `.brand`, `.social`, `.cerca`) più `footer`. Copiarle tali e quali.

- [ ] **Step 4: componenti**

`src/lib/Icona.svelte` con i quattro SVG inline già presenti nei concept (instagram, threads, cerca, freccia) scelti da `nome`.

`src/lib/Testata.svelte`:
```svelte
<script>
  import Icona from './Icona.svelte';
  let { indietro = false, cerca = false, query = $bindable('') } = $props();
</script>
<header>
  <div class="riga">
    {#if indietro}<a class="indietro" href="/"><Icona nome="freccia" />Sul tavolo</a>{/if}
    <a class="brand" href="/">bg<b>_perspective</b></a>
    <nav class="social">
      <a href="https://www.instagram.com/bg_perspective/" aria-label="Instagram"><Icona nome="instagram" /></a>
      <a href="https://www.threads.net/@bg_perspective" aria-label="Threads"><Icona nome="threads" /></a>
    </nav>
  </div>
  {#if cerca}<div class="cerca"><Icona nome="cerca" /><input id="q" type="search" bind:value={query} placeholder="Cerca un gioco, un autore, un editore" autocomplete="off" aria-label="Cerca"></div>{/if}
</header>
```

`src/routes/+layout.svelte`:
```svelte
<script>
  import '../app.css';
  import { injectAnalytics } from '@vercel/analytics/sveltekit';
  let { children } = $props();
  injectAnalytics();
</script>
{@render children()}
<footer>Foto e testi di bg_perspective. Tempi di gioco misurati alle nostre partite.</footer>
```

`src/routes/+error.svelte`:
```svelte
<script>import Testata from '$lib/Testata.svelte'; import { page } from '$app/state';</script>
<Testata indietro />
<main><h1>{page.status === 404 ? 'Questa scheda non c’è' : 'Qualcosa non ha funzionato'}</h1><p><a href="/">Torna sul tavolo</a></p></main>
```

- [ ] **Step 5: verifica**: `npm run dev`, aprire `/` e `/x`: testata con icone, 404 con testata. `curl -s localhost:5173/manifest.webmanifest | head -3` → JSON.

- [ ] **Step 6: Commit** (con via libera): `git commit -am "sito: layout, testata, icone, manifest"`

---

### Task 4: home

**Files:**
- Create: `src/routes/+page.server.js`, `src/routes/+page.svelte`, `src/lib/Tavolo.svelte`, `src/lib/Ordina.svelte`

**Interfaces:**
- Consumes: `visibili()`, `ultimo()`, `img`, `tipografico`, `dataItaliana`.
- Produces: `<Tavolo giochi={[]} query="" ordine="recenti" />` riusato da autore/editore.

- [ ] **Step 1: dati**

`src/routes/+page.server.js`:
```js
import { visibili, ultimo } from '$lib/db.js';
export const load = async () => ({ giochi: await visibili(), ultimo: await ultimo() });
```

- [ ] **Step 2: Tavolo**

`src/lib/Tavolo.svelte`: la griglia `#tavolo` del concept D (CSS di `.tit`, `#tavolo`, `.sc`, `.sc .g`, `.sc img`, `.sc .lab`, `.vuoto`, keyframes `galleggia` e `posa`, media query). Logica:
```svelte
<script>
  import { img } from '$lib/immagini.js';
  let { giochi, query = '', ordine = 'recenti' } = $props();
  let seed = 11; const rnd = () => (seed = (seed * 9301 + 49297) % 233280) / 233280;
  const stile = giochi.map(() => `--rot:${((rnd()-.5)*7).toFixed(1)}deg;--bob:${(5+rnd()*3).toFixed(1)}s;--del:${(-rnd()*6).toFixed(1)}s`);
  const chiave = (g) => (g.titolo + ' ' + g.autori.map(a => a.nome).join(' ') + ' ' + g.editore_it).toLowerCase();
  let lista = $derived.by(() => {
    const v = query.trim().toLowerCase();
    const f = giochi.map((g, i) => ({ g, i })).filter(({ g }) => !v || chiave(g).includes(v));
    return ordine === 'alfa' ? f.sort((a, b) => a.g.titolo.localeCompare(b.g.titolo, 'it')) : f;
  });
</script>
<div id="tavolo">
  {#each lista as { g, i }, n (g.slug)}
    <a class="sc" href="/gioco/{g.slug}" style="{stile[i]};--pos:{Math.min(n,14)*45}ms">
      <div class="g"><img src={img(g.slug, g.scatola_piatta)} alt={g.titolo} loading="lazy"><div class="lab">{g.titolo}<small>{g.editore_it}</small></div></div>
    </a>
  {/each}
</div>
{#if !lista.length}<p class="vuoto si">Nessun gioco con questo nome. Prova con l’autore o l’editore.</p>{/if}
```

- [ ] **Step 3: Ordina**

`src/lib/Ordina.svelte`: la tendina del concept (CSS `.ordina*`), con `let { ordine = $bindable('recenti') } = $props()`, apertura con attributo `open`, chiusura su click fuori e `Escape`, due `li role="menuitemradio"`.

- [ ] **Step 4: pagina**

`src/routes/+page.svelte`:
```svelte
<script>
  import Testata from '$lib/Testata.svelte'; import Tavolo from '$lib/Tavolo.svelte'; import Ordina from '$lib/Ordina.svelte';
  import { img } from '$lib/immagini.js'; import { tipografico, dataItaliana } from '$lib/testo.js';
  let { data } = $props(); let query = $state(''); let ordine = $state('recenti');
  const u = data.ultimo;
</script>
<svelte:head><title>bg_perspective, giochi da tavolo visti dal tavolo</title>
<meta name="description" content="Una scatola alla volta: foto nostre, come funziona, come gira e quanto dura davvero alle nostre partite.">
<link rel="canonical" href={import.meta.env.PUBLIC_BASE_URL ?? 'https://bg-perspective.vercel.app'}>
{@html `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'WebSite', name: 'bg_perspective', url: 'https://bg-perspective.vercel.app', potentialAction: { '@type': 'SearchAction', target: 'https://bg-perspective.vercel.app/?q={q}', 'query-input': 'required name=q' } })}<\/script>`}
</svelte:head>
<Testata cerca bind:query />
<main>
  <section class="hero"><h1>Giochi da tavolo,<br><b>visti dal tavolo.</b></h1><p>Una scatola alla volta, con le nostre foto e i tempi veri delle partite.</p></section>
  {#if u}<a class="ultimo" href="/gioco/{u.slug}">
    <img class="f" src={img(u.slug, u.foto[0])} alt={u.alt?.[0] ?? u.titolo}><img class="b" src={img(u.slug, u.scatola_piatta)} alt="">
    <div class="t"><div class="et">Ultimo uscito · {dataItaliana(u.pubblicato_il)}</div><h2>{u.titolo}</h2><p>{tipografico(u.apertura)}</p></div>
  </a>{/if}
  <div class="tit"><h2>Sul <b>tavolo</b></h2><div class="dx"><small>{data.giochi.length} giochi</small><Ordina bind:ordine /></div></div>
  <Tavolo giochi={data.giochi} {query} {ordine} />
</main>
```
CSS di `.hero`, `.ultimo*`, `main` dal concept D, in `<style>` della pagina. Per `PUBLIC_BASE_URL` usare `import { env } from '$env/dynamic/public'` (`env.PUBLIC_BASE_URL`) al posto di `import.meta.env`.

- [ ] **Step 5: prova con una riga finta**

Run:
```bash
turso db shell bg-perspective "insert into giochi (slug,post,bgg_id,titolo,autori,editore_it,editore_slug,cos_e,come_gira,domanda,foto,scatola_piatta,pubblicato_il,aggiornato_il,apertura) values ('prova','post0',1,'Prova','[{\"nome\":\"A\",\"slug\":\"a\"}]','Ed','ed','x','y','?','[\"cop.jpg\"]','scatola-bgg.jpg','2026-01-01T19:00:00+01:00','2026-01-01T19:00:00+01:00','Apertura di prova')"
```
`npm run dev`: home con una scatola (immagine rotta, è attesa), tendina e ricerca funzionanti. Poi `turso db shell bg-perspective "delete from giochi where slug='prova'"`.

- [ ] **Step 6: Commit** (con via libera): `git commit -am "sito: home con tavolo, ricerca e ordinamento"`

---

### Task 5: pagina gioco

**Files:**
- Create: `src/routes/gioco/[slug]/+page.server.js`, `src/routes/gioco/[slug]/+page.svelte`, `src/lib/Durate.svelte`

**Interfaces:**
- Consumes: `perSlug`, `visibili`, `img`, `tipografico`, `dataItaliana`.

- [ ] **Step 1: dati**

```js
import { error } from '@sveltejs/kit';
import { perSlug, visibili } from '$lib/db.js';
export const load = async ({ params }) => {
  const g = await perSlug(params.slug);
  if (!g) error(404);
  const altri = (await visibili()).filter((x) => x.slug !== g.slug);
  const stessa = altri.filter((x) => x.categorie.some((c) => g.categorie.includes(c)));
  return { g, vicini: (stessa.length >= 2 ? stessa : altri).slice(0, 2) };
};
```

- [ ] **Step 2: grafico**

`src/lib/Durate.svelte` (CSS `.pann .graf .assi .nota` dal concept E):
```svelte
<script>
  let { durate, min, max } = $props();
  const numeri = ['', '', 'in due', 'in tre', 'in quattro', 'in cinque', 'in sei', 'in sette', 'in otto'];
  const tetto = Math.max(max, ...durate.map((d) => d.mediana)) * 1.15;
  const top = Math.min(...durate.map((d) => d.mediana));
  const tot = durate.reduce((s, d) => s + d.partite, 0);
</script>
<div class="pann">
  <div class="graf" style="grid-template-columns:repeat({durate.length},1fr)">
    <div class="fascia" style="bottom:{min/tetto*100}%;height:{(max-min)/tetto*100}%"></div>
    {#each durate as d}<div class="col" class:top={d.mediana === top}><div class="num">{d.mediana}′</div><div class="barra" style="height:{d.mediana/tetto*100}%"></div></div>{/each}
  </div>
  <div class="assi" style="grid-template-columns:repeat({durate.length},1fr)">{#each durate as d}<span>{numeri[d.giocatori] ?? `in ${d.giocatori}`}</span>{/each}</div>
</div>
<div class="nota">Mediana delle {tot} partite con durata registrata. La fascia tratteggiata è {min}–{max}′, quello che dice la scatola.</div>
```

- [ ] **Step 3: pagina**

`src/routes/gioco/[slug]/+page.svelte`: la struttura di `E-post.html` con i dati al posto del testo fisso. Punti fissi:
- `<title>{g.titolo} · bg_perspective</title>`, `meta description` = `tipografico(g.cos_e)` tagliato a 155 caratteri, `og:image` = `img(g.slug, g.foto[0])`, `canonical` = `${base}/gioco/${g.slug}`.
- JSON-LD `Game` (`name`, `author` = autori, `publisher` = editore_it, `datePublished`, `numberOfPlayers` con `minValue/maxValue`, `timeRequired` `PT{durata_min}M`, `image`, `url`) e `BreadcrumbList` (Sul tavolo → titolo).
- Crediti: autori uniti con "e" (tutti link a `/autore/{slug}`), `· {anno} · in Italia <a href="/editore/{editore_slug}">`.
- Tre numeri: `giocatori_min–giocatori_max`; `durata_min–durata_max min`; al nostro tavolo = min e max delle `mediana` in `durate`, "—" se `durate` è vuoto.
- Tag: `categorie` con classe `a`, poi `meccaniche`. Riga2: `peso_etichetta · peso` con `.sep`, `testo_nel_gioco`.
- Sezioni `Cos’è` (+ foto[1]), `Come gira` (+ foto[2]), `La chicca` (solo se `chicca`), `Al nostro tavolo` (paragrafo `apertura` + `<Durate>` solo se `durate.length`), `La scatola` (scatola_3d, editore, testo, link `https://boardgamegeek.com/boardgame/{bgg_id}`), `Diccelo tu` (`instagram_url ?? 'https://www.instagram.com/bg_perspective/'`, `threads_url ?? 'https://www.threads.net/@bg_perspective'`), figura finale foto[3] con `figcaption` "Foto nostre, dal tavolo.", `Sul tavolo c’è anche` con `data.vicini`.
- Tutti i testi passano da `tipografico()`.
- CSS: quello di `E-post.html` nel `<style>` della pagina, con `.dati > div` (non `.dati div`).

- [ ] **Step 4: verifica** con la riga `prova` reinserita (Step 5 del Task 4) e poi cancellata: `/gioco/prova` 200 con tutte le sezioni, `/gioco/nonesiste` 404. Controllare che `durate` vuoto nasconda il grafico e mostri "—".

- [ ] **Step 5: Commit** (con via libera): `git commit -am "sito: pagina gioco"`

---

### Task 6: pagine autore ed editore

**Files:**
- Create: `src/routes/autore/[slug]/+page.server.js`, `src/routes/autore/[slug]/+page.svelte`, `src/routes/editore/[slug]/+page.server.js`, `src/routes/editore/[slug]/+page.svelte`

- [ ] **Step 1: server**

Autore:
```js
import { error } from '@sveltejs/kit'; import { perAutore } from '$lib/db.js';
export const load = async ({ params }) => {
  const giochi = await perAutore(params.slug); if (!giochi.length) error(404);
  const a = giochi[0].autori.find((x) => x.slug === params.slug);
  return { nome: a.nome, handle: a.handle, giochi };
};
```
Editore:
```js
import { error } from '@sveltejs/kit'; import { perEditore } from '$lib/db.js';
export const load = async ({ params }) => {
  const giochi = await perEditore(params.slug); if (!giochi.length) error(404);
  return { nome: giochi[0].editore_it, handle: giochi[0].editore_handle, giochi };
};
```

- [ ] **Step 2: pagine** (identiche a meno del titolo):
```svelte
<script>import Testata from '$lib/Testata.svelte'; import Tavolo from '$lib/Tavolo.svelte'; let { data } = $props();</script>
<svelte:head><title>{data.nome} · bg_perspective</title></svelte:head>
<Testata indietro />
<main><section class="hero"><div class="et">Editore</div><h1>{data.nome}</h1>
{#if data.handle}<p><a href="https://www.instagram.com/{data.handle.replace('@','')}/">{data.handle}</a> su Instagram</p>{/if}</section>
<div class="tit"><h2>Sul <b>tavolo</b></h2><small>{data.giochi.length} giochi</small></div>
<Tavolo giochi={data.giochi} /></main>
```

- [ ] **Step 3: verifica**: `/editore/ed` e `/autore/a` con la riga di prova → 200; `/editore/zzz` → 404.

- [ ] **Step 4: Commit** (con via libera): `git commit -am "sito: pagine autore ed editore"`

---

### Task 7: sitemap, feed

**Files:**
- Create: `src/routes/sitemap.xml/+server.js`, `src/routes/feed.xml/+server.js`

- [ ] **Step 1: sitemap**
```js
import { visibili } from '$lib/db.js'; import { env } from '$env/dynamic/public';
export const GET = async () => {
  const B = env.PUBLIC_BASE_URL, g = await visibili();
  const url = new Set([`${B}/`, ...g.map((x) => `${B}/gioco/${x.slug}`), ...g.map((x) => `${B}/editore/${x.editore_slug}`), ...g.flatMap((x) => x.autori.map((a) => `${B}/autore/${a.slug}`))]);
  const xml = `<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${[...url].map((u) => `<url><loc>${u}</loc></url>`).join('')}</urlset>`;
  return new Response(xml, { headers: { 'Content-Type': 'application/xml' } });
};
```

- [ ] **Step 2: feed**
```js
import { db, adesso } from '$lib/db.js'; import { env } from '$env/dynamic/public';
const esc = (s) => (s ?? '').replace(/[<>&]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;' }[c]));
export const GET = async () => {
  const B = env.PUBLIC_BASE_URL;
  const { rows } = await db.execute({ sql: 'select slug,titolo,cos_e,pubblicato_il from giochi where pubblicato_il <= ? order by pubblicato_il desc limit 20', args: [adesso()] });
  const items = rows.map((r) => `<item><title>${esc(r.titolo)}</title><link>${B}/gioco/${r.slug}</link><guid>${B}/gioco/${r.slug}</guid><pubDate>${new Date(r.pubblicato_il).toUTCString()}</pubDate><description>${esc(r.cos_e)}</description></item>`).join('');
  return new Response(`<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"><channel><title>bg_perspective</title><link>${B}/</link><description>Giochi da tavolo visti dal tavolo</description>${items}</channel></rss>`, { headers: { 'Content-Type': 'application/rss+xml' } });
};
```

- [ ] **Step 3: verifica**: `curl -s localhost:5173/sitemap.xml | head -c 300` e `curl -s localhost:5173/feed.xml | head -c 300` → XML valido.

- [ ] **Step 4: Commit** (con via libera): `git commit -am "sito: sitemap e feed"`

---

### Task 8: cron dei permalink

Dipende dall'esito del Task 0. Se l'endpoint HTTP di Buffer funziona:

**Files:**
- Create: `src/routes/api/permalink/+server.js`, `src/lib/buffer.js`

- [ ] **Step 1: client Buffer**

`src/lib/buffer.js`:
```js
import { env } from '$env/dynamic/private';
export const permalink = async (id) => {
  const r = await fetch('https://api.buffer.com/graphql', { method: 'POST',
    headers: { Authorization: `Bearer ${env.BUFFER_API_KEY}`, 'Content-Type': 'application/json' },
    body: JSON.stringify({ query: `{ post(input:{id:"${id}"}) { externalLink status } }` }) });
  const j = await r.json(); return j.data?.post?.status === 'sent' ? j.data.post.externalLink : null;
};
```
(Sostituire l'URL con quello verificato nel Task 0.)

- [ ] **Step 2: endpoint**

```js
import { json } from '@sveltejs/kit'; import { env } from '$env/dynamic/private';
import { db, adesso } from '$lib/db.js'; import { permalink } from '$lib/buffer.js';
export const GET = async ({ request }) => {
  if (request.headers.get('authorization') !== `Bearer ${env.CRON_SECRET}`) return new Response('no', { status: 401 });
  const { rows } = await db.execute({ sql: 'select slug,buffer_ig,buffer_th,instagram_url,threads_url from giochi where pubblicato_il <= ? and ((buffer_ig is not null and instagram_url is null) or (buffer_th is not null and threads_url is null))', args: [adesso()] });
  const fatti = [];
  for (const r of rows) {
    const ig = r.instagram_url ?? (r.buffer_ig && await permalink(r.buffer_ig));
    const th = r.threads_url ?? (r.buffer_th && await permalink(r.buffer_th));
    if (ig || th) { await db.execute({ sql: 'update giochi set instagram_url = coalesce(?, instagram_url), threads_url = coalesce(?, threads_url), aggiornato_il = ? where slug = ?', args: [ig, th, adesso(), r.slug] }); fatti.push(r.slug); }
  }
  return json({ fatti });
};
```
Vercel chiama i cron con `Authorization: Bearer $CRON_SECRET` quando la variabile esiste nel progetto.

- [ ] **Step 3: verifica**: dopo il deploy, `curl -H "Authorization: Bearer $CRON_SECRET" https://bg-perspective.vercel.app/api/permalink` → `{"fatti":[]}` (nessuna riga ancora). Senza header → 401.

- [ ] **Step 4: Commit** (con via libera): `git commit -am "sito: cron permalink da Buffer"`

---

### Task 9: `sito.py`, il comando che alimenta il sito

**Files:**
- Create: `_instagram/codice/sito.py`
- Read: `_instagram/codice/slide.py` (`POST`), `portale.py` (`dati_gioco`, `scatola_bgg`, `credits`), `dati/handle_autori.csv`, `dati/bgp.sqlite` (tabella `editori`: handle degli editori)

**Interfaces:**
- Produces: CLI `pubblica <postN> --data ISO --ig ID --th ID [--gia-uscito] [--didascalia file]`, `verifica <slug>`, `permalink <slug> --ig URL --th URL`; funzioni `estrai_post(chiave) -> dict`, `chicca_da_didascalia(testo) -> (apertura, chicca)`, `turso(sql, args)`.

- [ ] **Step 1: demo che fallisce**

In fondo a `sito.py`:
```python
def demo():
    d = estrai_post("post4")
    assert d["titolo"] == "Cryptid" and d["giocatori"] == (3, 5) and d["durata"] == (30, 50)
    assert d["cos_e"].startswith("Da qualche parte") and d["domanda"] == "Qual è il deduttivo che non reggi?"
    assert d["meccaniche"] == ["Deduzione", "Griglia esagonale", "Plancia modulare"] and d["categorie"] == ["Deduttivo"]
    assert d["durate"] == [{"giocatori": 3, "partite": 9, "mediana": 11}, {"giocatori": 4, "partite": 5, "mediana": 30}, {"giocatori": 5, "partite": 5, "mediana": 25}]
    ap, ch = chicca_da_didascalia("Trentaquattro partite in casa nostra.\n\n🎲 Da qualche parte...\n\nCome in ogni deduttivo...\n\nGli indizi sono frasi corte, quindi non lo consiglio.\n\nQual è il deduttivo che non reggi? Scrivi il titolo qui sotto.\n\n#cryptid #gdt")
    assert ap == "Trentaquattro partite in casa nostra." and ch.startswith("Gli indizi")
    assert slug("L’EREDITÀ DI YU") == "l-eredita-di-yu"
    print("demo ok")

if __name__ == "__main__":
    import sys
    if len(sys.argv) == 1 or sys.argv[1] == "demo": demo()
    else: main(sys.argv[1:])
```
Run: `cd _instagram && .venv/bin/python codice/sito.py demo` → NameError.

- [ ] **Step 2: estrazione**

```python
import os, re, json, html, sqlite3, csv, subprocess, unicodedata, urllib.request
from datetime import datetime, timezone
import slide, portale
from PIL import Image
QUI = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
IMM = os.path.join(QUI, "bgp-immagini", "sito")
ENV = {}
for riga in open(os.path.join(QUI, "sito", ".env")):
    if "=" in riga and not riga.startswith("#"): k, v = riga.strip().split("=", 1); ENV[k] = v

def slug(s):
    s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode().replace("×", "x").lower()
    return re.sub(r"^-|-$", "", re.sub(r"[^a-z0-9]+", "-", s))

def _t(h):  # testo pulito da un frammento html
    return html.unescape(re.sub(r"<[^>]+>", " ", h.replace("<br>", " "))).replace(" ", " ").strip()
def _t1(h): return re.sub(r"\s+", " ", _t(h))
def _tra(s, a, b=None):
    i = s.find(a); assert i >= 0, a
    j = s.find(b, i) if b else len(s); return s[i + len(a):j]

def estrai_post(chiave):
    v = slide.POST[chiave]; cop = v[0]
    titolo = _t1(_tra(cop, 'class="tit', "</div>").split(">", 1)[1]).title().replace("'S", "'s").replace("’S", "’s")
    cred = _t1(_tra(cop, 'class="cred">', "</div>"))
    autori, resto = cred.split("·", 1); anno = int(re.search(r"\d{4}", resto).group())
    editore = _t1(_tra(cop, "<b>", "</b>"))
    dati = lambda s: [_t1(x) for x in re.findall(r'class="v">(.*?)</span></span>', s)]
    g, d = dati(cop)[:2]
    gm = [int(x) for x in re.findall(r"\d+", g)]; dm = [int(x) for x in re.findall(r"\d+", d)]
    fs = next(s for s in v if 'class="post fs"' in s); ts = next(s for s in v if 'class="post ts"' in s)
    sc = next((s for s in v if 'class="post sc"' in s), ""); ch = next(s for s in v if 'class="post ch"' in s)
    tag = lambda cls: [_t1(x) for x in re.findall(rf'<span class="{cls}">(.*?)</span>', sc)]
    ed_it = _t1(_tra(sc, '<div class="ed">', "</div>")) if sc else editore
    testo_gioco = re.findall(r'<div class="ed">(.*?)</div>', sc); testo_gioco = _t1(testo_gioco[1]) if len(testo_gioco) > 1 else None
    foto = [re.search(r'<img src="([^"]+)"', s).group(1) for s in (cop, fs, ts, ch)]
    scatola3d = re.search(r'class="box"><img src="([^"]+)"', sc); scatola3d = scatola3d.group(1) if scatola3d else None
    return dict(post=chiave, titolo=titolo, autori_testo=autori.strip(), anno=anno, editore=editore, editore_it=ed_it,
        giocatori=(gm[0], gm[-1]), durata=(dm[0], dm[-1]), peso_etichetta=dati(cop)[2] if len(dati(cop)) > 2 else None,
        cos_e=_t1(_tra(fs, 'class="corpo">', "</div>")), come_gira=_t1(_tra(ts, 'class="corpo">', "</div>")),
        domanda=_t1(_tra(ch, 'class="dom">', "</div>")), domanda_sotto=_t1(_tra(ch, 'class="grigia">', "</div>")) if 'class="grigia"' in ch else None,
        meccaniche=tag("tag"), categorie=tag("tag tagA"), testo_nel_gioco=testo_gioco, foto=foto, scatola3d=scatola3d)
```
Poi i dati del portale, aggiunti in `arricchisci(d, bgg_id)`:
```python
def arricchisci(d, bgg_id):
    p = portale.dati_gioco(bgg_id, usa_cache=False)
    st = p.get("partiteStats") or {}
    d.update(bgg_id=int(bgg_id), peso=float(p["peso"]) if p.get("peso") else None, partite=p.get("partiteTavoli") or p.get("partite"),
        durate=[{"giocatori": x["giocatori"], "partite": x["partite"], "mediana": x["durataMediana"]} for x in st.get("perGiocatori", []) if x.get("durataMediana")],
        autori=[{"nome": a["nome"], "slug": slug(a["nome"]), "handle": handle_autore(a["nome"])} for a in p.get("autori", [])],
        immagine_bgg=p.get("image"))
    return d

def handle_autore(nome):
    with open(os.path.join(QUI, "dati", "handle_autori.csv"), encoding="utf-8") as f:
        for r in csv.DictReader(f):
            if r.get("autore", "").strip().lower() == nome.lower() and r.get("handle"): return r["handle"]
def handle_editore(nome):
    db = sqlite3.connect(os.path.join(QUI, "dati", "bgp.sqlite"))
    r = db.execute("select handle from editori where lower(nome) = lower(?)", [nome]).fetchone()
    return r[0] if r and r[0] else None
```
Controllare i nomi reali delle colonne di `handle_autori.csv` e della tabella `editori` (`head -1 dati/handle_autori.csv`, `sqlite3 dati/bgp.sqlite ".schema editori"`) e adattare i due accessi.

Didascalia:
```python
def chicca_da_didascalia(testo):
    par = [p.strip() for p in re.split(r"\n\s*\n", testo.strip()) if p.strip()]
    par = [p for p in par if not p.startswith("#")]
    apertura = par[0]
    centrali = [p for p in par[1:] if "?" not in p.split("\n")[-1] or len(p) > 200]  # via la CTA
    centrali = [p for p in centrali if not p.startswith("🎲")]  # via il cos'è
    chicca = centrali[-1] if centrali else None
    return apertura, chicca
```
Se `chicca` è `None` o `centrali` ha più di due voci, `pubblica` stampa i paragrafi numerati e chiede quale usare (`input()`).

- [ ] **Step 3: immagini, Turso, Buffer, comando**

```python
def prepara_immagini(d, s):
    out = os.path.join(IMM, s); os.makedirs(out, exist_ok=True); nomi = ["cop.jpg", "cose.jpg", "gira.jpg", "fine.jpg"]
    for src, nome in zip(d["foto"], nomi):
        im = Image.open(src).convert("RGB"); im.thumbnail((1600, 1600)); im.save(os.path.join(out, nome), quality=80)
    piatta = portale.scatola_bgg(d["bgg_id"], s)
    im = Image.open(piatta).convert("RGB"); im.thumbnail((600, 600)); im.save(os.path.join(out, "scatola-bgg.jpg"), quality=82)
    if d["scatola3d"]:
        im = Image.open(d["scatola3d"]); im.thumbnail((700, 700)); im.save(os.path.join(out, "scatola.png"))
    subprocess.run(["vercel", "deploy", "--prod", "--yes", "--scope", "paoloalbys-projects"], cwd=os.path.join(QUI, "bgp-immagini"), check=True)
    return nomi, "scatola-bgg.jpg", "scatola.png" if d["scatola3d"] else None

def turso(sql, args=()):
    url = ENV["TURSO_DATABASE_URL"].replace("libsql://", "https://") + "/v2/pipeline"
    conv = lambda a: {"type": "null"} if a is None else {"type": "integer", "value": str(a)} if isinstance(a, int) else {"type": "float", "value": a} if isinstance(a, float) else {"type": "text", "value": a}
    corpo = json.dumps({"requests": [{"type": "execute", "stmt": {"sql": sql, "args": [conv(a) for a in args]}}, {"type": "close"}]}).encode()
    req = urllib.request.Request(url, data=corpo, headers={"Authorization": f"Bearer {ENV['TURSO_AUTH_TOKEN']}", "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=30) as r: j = json.load(r)
    res = j["results"][0]; assert res["type"] == "ok", res
    return res["response"]["result"]

def buffer(query):
    req = urllib.request.Request("https://api.buffer.com/graphql", data=json.dumps({"query": query}).encode(),
        headers={"Authorization": f"Bearer {ENV['BUFFER_API_KEY']}", "Content-Type": "application/json"})
    with urllib.request.urlopen(req, timeout=30) as r: return json.load(r)["data"]
```
(URL Buffer da sostituire con quello del Task 0; se non esiste, `buffer()` solleva `SystemExit("serve --didascalia")`.)

```python
def pubblica(chiave, data, ig, th, gia_uscito=False, didascalia=None):
    d = estrai_post(chiave)
    bgg_id = bgg_id_di(chiave)  # da dati/indice_nomi.json per titolo, altrimenti chiede input()
    arricchisci(d, bgg_id); s = slug(d["titolo"])
    testo = open(didascalia, encoding="utf-8").read() if didascalia else buffer(f'{{ post(input:{{id:"{ig}"}}) {{ text }} }}')["post"]["text"]
    apertura, chicca = chicca_da_didascalia(testo)
    foto, piatta, tre_d = prepara_immagini(d, s)
    ig_url = th_url = None
    if gia_uscito and not didascalia:
        ig_url = buffer(f'{{ post(input:{{id:"{ig}"}}) {{ externalLink }} }}')["post"]["externalLink"]
        th_url = buffer(f'{{ post(input:{{id:"{th}"}}) {{ externalLink }} }}')["post"]["externalLink"]
    ora = datetime.now(timezone.utc).isoformat()
    cols = dict(slug=s, post=chiave, bgg_id=d["bgg_id"], titolo=d["titolo"], autori=json.dumps(d["autori"], ensure_ascii=False), anno=d["anno"],
        editore_it=d["editore_it"], editore_slug=slug(d["editore_it"]), editore_handle=handle_editore(d["editore_it"]),
        giocatori_min=d["giocatori"][0], giocatori_max=d["giocatori"][1], durata_min=d["durata"][0], durata_max=d["durata"][1],
        peso=d["peso"], peso_etichetta=d["peso_etichetta"], categorie=json.dumps(d["categorie"], ensure_ascii=False), meccaniche=json.dumps(d["meccaniche"], ensure_ascii=False),
        testo_nel_gioco=d["testo_nel_gioco"], cos_e=d["cos_e"], come_gira=d["come_gira"], apertura=apertura, chicca=chicca,
        domanda=d["domanda"], domanda_sotto=d["domanda_sotto"], partite=d["partite"], durate=json.dumps(d["durate"]),
        foto=json.dumps(foto), alt=json.dumps([f"{d['titolo']} sul tavolo"] * 4, ensure_ascii=False), scatola_piatta=piatta, scatola_3d=tre_d,
        buffer_ig=ig, buffer_th=th, instagram_url=ig_url, threads_url=th_url, pubblicato_il=data, aggiornato_il=ora)
    k = list(cols); sql = f"insert into giochi ({','.join(k)}) values ({','.join('?'*len(k))}) on conflict(slug) do update set " + ",".join(f"{c}=excluded.{c}" for c in k if c != "slug")
    turso(sql, [cols[c] for c in k]); print("ok", s, data)
```
`main(argv)` con `argparse`: sottocomandi `pubblica` (`chiave`, `--data`, `--ig`, `--th`, `--gia-uscito`, `--didascalia`), `verifica` (`slug` → stampa `turso("select * from giochi where slug=?",[slug])`), `permalink` (`slug`, `--ig`, `--th` → update).

`--data` accetta `2026-10-02T19:00` e la completa con `+02:00` o `+01:00` secondo l'ora legale di Roma (`zoneinfo.ZoneInfo("Europe/Rome")`).

- [ ] **Step 4: demo passa**: `.venv/bin/python codice/sito.py demo` → `demo ok`. Se un assert sul post4 fallisce perché il markup di `POST` è diverso da come lo legge `_tra`, correggere l'estrattore, non l'assert.

- [ ] **Step 5: Commit** (con via libera): `git commit -m "sito.py: pubblica, verifica, permalink"`

---

### Task 10: storico e messa online

**Files:** nessuno nuovo.

- [ ] **Step 1: elenco da Buffer**

Con l'MCP di Buffer (`list_posts` sul canale Instagram `6a95918b065799be465e1f0d`, stati `sent` e `scheduled`) ricava per ogni post: id IG, `dueAt`/`sentAt`, primo asset (`.../postN_1.png` → chiave `postN`). Stessa lista sul canale Threads `6a95974a065799be465e4480` per l'id TH, abbinato per orario. Escludere le storie (post senza asset `postN_`).

- [ ] **Step 2: pubblica tutti**

Per ogni post uscito: `python3 codice/sito.py pubblica postN --data <sentAt in ora di Roma> --ig <id> --th <id> --gia-uscito`. Per ogni post in coda: lo stesso senza `--gia-uscito` e con `--data <dueAt>`. Il deploy di `bgp-immagini` avviene a ogni chiamata: per lo storico, spostare temporaneamente `subprocess.run(["vercel"...])` dietro un flag `--senza-deploy` e fare un deploy solo alla fine.

- [ ] **Step 3: deploy del sito**: `cd _instagram/sito && vercel deploy --prod --yes --scope paoloalbys-projects`.

- [ ] **Step 4: controlli finali**
- `https://bg-perspective.vercel.app/` mostra Cryptid (o l'ultimo uscito) in cima e solo i post già usciti nella griglia; i dieci in coda non compaiono.
- `https://bg-perspective.vercel.app/gioco/cryptid` completa, con i due permalink veri nei bottoni.
- `https://bg-perspective.vercel.app/gioco/quadropolis` → 404 fino al 2 ottobre alle 19:00.
- `sitemap.xml` e `feed.xml` elencano solo i visibili.
- Su iPhone: "Aggiungi alla schermata Home" mostra i dadi di Sagrada e apre senza barra del browser.
- Vercel → Analytics attivo sul progetto (va abilitato una volta dal pannello).

- [ ] **Step 5: memoria**: aggiornare `stato-scaletta-e-coda.md` e `pipeline-pubblicazione.md` (copie in `memoria/` e nella cartella `.claude`): il passo "messa in coda su Buffer" ora finisce con `sito.py pubblica`; il sito è il link in bio.

- [ ] **Step 6: Commit** (con via libera): `git commit -am "sito online con lo storico"`.

---

## Auto-revisione

Copertura della specifica: stack e progetto (T1), scrittura solo da Python (T9), schema (T1), visibilità per data (T2 query), home con ultimo uscito, griglia, tendina, ricerca (T4), pagina gioco con tutte le sezioni e i casi vuoti (T5), autore/editore (T6), sitemap e feed (T7), cron permalink e sua alternativa (T0, T8), meta, JSON-LD, robots (T3, T4, T5), icone e manifest (T3), variabili (T1), storico (T10), analytics (T3 layout, T10 pannello), errori (T3).

Coerenza dei nomi: `visibili/ultimo/perSlug/perAutore/perEditore/adesso` in T2 e usati in T4 a T8; `img` in T2/T4/T5; `tipografico/dataItaliana/slugifica` in T2/T4/T5; `estrai_post/chicca_da_didascalia/slug/turso/buffer/pubblica` in T9; colonne della tabella identiche tra T1 e T9.
