|
| 1 | +# Social Media Scheduling |
| 2 | + |
| 3 | +This document describes the end-to-end process for generating social media cards |
| 4 | +and scheduling posts for EuroPython 2026 speakers, sponsors, and community |
| 5 | +partners via Buffer. |
| 6 | + |
| 7 | +--- |
| 8 | + |
| 9 | +## Overview |
| 10 | + |
| 11 | +The pipeline has three stages: |
| 12 | + |
| 13 | +1. **Generate social card images** — render PNG cards for speakers and sponsors |
| 14 | + using Puppeteer |
| 15 | +2. **Generate the post queue** — hit the Astro API endpoint to produce |
| 16 | + `queue.json` |
| 17 | +3. **Schedule posts to Buffer** — run the Python script to push items from the |
| 18 | + queue |
| 19 | + |
| 20 | +--- |
| 21 | + |
| 22 | +## Prerequisites |
| 23 | + |
| 24 | +- Dev server running locally (`pnpm dev`, default port `4321`) |
| 25 | +- Node.js + pnpm installed |
| 26 | +- Python environment with dependencies: `requests`, `python-dotenv` |
| 27 | +- A `.env.local` file in the project root with your Buffer API key: |
| 28 | + |
| 29 | +``` |
| 30 | +BUFFER_API_KEY=your_buffer_api_key_here |
| 31 | +``` |
| 32 | + |
| 33 | +This file is gitignored and never committed. |
| 34 | + |
| 35 | +### Getting the Buffer API key |
| 36 | + |
| 37 | +1. Log in to [buffer.com](https://buffer.com) with the EuroPython account |
| 38 | +2. Go to **Account Settings** → **Apps & Integrations** (or navigate directly to |
| 39 | + https://account.buffer.com/apps) |
| 40 | +3. Under **Access Token**, copy your personal access token |
| 41 | +4. Paste it as the value of `BUFFER_API_KEY` in your `.env.local` |
| 42 | + |
| 43 | +--- |
| 44 | + |
| 45 | +## Step 1 — Generate Social Card Images |
| 46 | + |
| 47 | +Social cards are 900×900px PNG images rendered from Astro pages and |
| 48 | +screenshotted with Puppeteer. |
| 49 | + |
| 50 | +### Speaker cards |
| 51 | + |
| 52 | +```bash |
| 53 | +node scripts/download_social_speakers.cjs |
| 54 | +``` |
| 55 | + |
| 56 | +Screenshots are saved to the current directory. Move them to the right place: |
| 57 | + |
| 58 | +```bash |
| 59 | +mv social-*.png public/media/speakers/ |
| 60 | +``` |
| 61 | + |
| 62 | +### Sponsor & partner cards |
| 63 | + |
| 64 | +```bash |
| 65 | +node scripts/download_social_sponsors.cjs |
| 66 | +``` |
| 67 | + |
| 68 | +Move them: |
| 69 | + |
| 70 | +```bash |
| 71 | +mv social-*.png public/media/sponsors/ |
| 72 | +``` |
| 73 | + |
| 74 | +> The scripts read from `http://localhost:4321/media/speakers` and |
| 75 | +> `http://localhost:4321/media/sponsors` respectively, so the dev server must be |
| 76 | +> running. |
| 77 | +
|
| 78 | +### Checking for missing images |
| 79 | + |
| 80 | +Cross-reference the live site with what's in `public/media/sponsors/`. Every |
| 81 | +sponsor and partner listed on: |
| 82 | + |
| 83 | +- https://ep2026.europython.eu/sponsors/ |
| 84 | +- https://ep2026.europython.eu/community-partners/ |
| 85 | + |
| 86 | +should have a corresponding `social-<slug>.png` in `public/media/sponsors/`. |
| 87 | + |
| 88 | +--- |
| 89 | + |
| 90 | +## Step 2 — Generate the Post Queue |
| 91 | + |
| 92 | +The queue is a JSON file that interleaves speakers, sponsors, and community |
| 93 | +partners in a repeating pattern: |
| 94 | + |
| 95 | +``` |
| 96 | +speaker → speaker → sponsor → speaker → partner → (repeat) |
| 97 | +``` |
| 98 | + |
| 99 | +Regenerate it by hitting the API endpoint while the dev server is running: |
| 100 | + |
| 101 | +```bash |
| 102 | +curl -s http://localhost:4321/api/media/combined_socials_queue > src/pages/api/media/combined_socials_queue.json |
| 103 | +``` |
| 104 | + |
| 105 | +This overwrites `src/pages/api/media/combined_socials_queue.json` with all 150+ |
| 106 | +items sorted and interleaved. |
| 107 | + |
| 108 | +### Tier classification |
| 109 | + |
| 110 | +Sponsors are split into two buckets: |
| 111 | + |
| 112 | +- **Commercial** (go into the sponsor slot): Keystone, Diamond, Platinum, |
| 113 | + Platinum X, Gold, Silver, Bronze, Patron |
| 114 | +- **Community partners** (go into the partner slot): Partners, Supporters, |
| 115 | + Financial Aid |
| 116 | + |
| 117 | +If a new sponsor tier is added, update `commercialTiers` in |
| 118 | +`src/pages/api/media/combined_socials_queue.ts`. |
| 119 | + |
| 120 | +### Manual queue adjustments |
| 121 | + |
| 122 | +You can edit `src/pages/api/media/combined_socials_queue.json` directly to |
| 123 | +reorder entries. For example, to swap two sponsors: |
| 124 | + |
| 125 | +```python |
| 126 | +python3 -c " |
| 127 | +import json |
| 128 | +path = 'src/pages/api/media/combined_socials_queue.json' |
| 129 | +with open(path) as f: |
| 130 | + q = json.load(f) |
| 131 | +q[2], q[17] = q[17], q[2] # swap positions 3 and 18 (0-indexed) |
| 132 | +with open(path, 'w') as f: |
| 133 | + json.dump(q, f, indent=2, ensure_ascii=False) |
| 134 | +" |
| 135 | +``` |
| 136 | + |
| 137 | +`src/pages/api/media/combined_socials_queue.json` is committed to the repo. Any |
| 138 | +manual reordering should be committed so the intentional order is preserved and |
| 139 | +not lost when the queue is regenerated. |
| 140 | + |
| 141 | +--- |
| 142 | + |
| 143 | +## Step 3 — Commit and Merge Images |
| 144 | + |
| 145 | +Before scheduling, the social card images need to be live on the production |
| 146 | +site. Buffer fetches the image URL at scheduling time and will fail with |
| 147 | +`Failed to fetch image dimensions: Not Found` if the file isn't deployed yet. |
| 148 | + |
| 149 | +1. Stage the new images: |
| 150 | + |
| 151 | +```bash |
| 152 | +git add public/media/speakers/ public/media/sponsors/ |
| 153 | +``` |
| 154 | + |
| 155 | +2. Commit: |
| 156 | + |
| 157 | +```bash |
| 158 | +git commit -m "Add social media cards for speakers/sponsors" |
| 159 | +``` |
| 160 | + |
| 161 | +3. Push to a branch and open a PR: |
| 162 | + |
| 163 | +```bash |
| 164 | +git push -u origin your-branch-name |
| 165 | +gh pr create --title "Add social media cards" --body "New speaker and sponsor social card PNGs for Buffer scheduling." |
| 166 | +``` |
| 167 | + |
| 168 | +4. Wait for the PR to be merged and deployed before proceeding to Step 4. |
| 169 | + |
| 170 | +You can verify the images are live by checking a URL like: |
| 171 | +`https://ep2026.europython.eu/media/sponsors/social-arm.png` |
| 172 | + |
| 173 | +--- |
| 174 | + |
| 175 | +## Step 4 — Schedule Posts via Buffer |
| 176 | + |
| 177 | +> ⚠️ **Images must be live before scheduling.** Buffer fetches the image URL at |
| 178 | +> scheduling time. If the PNG hasn't been deployed yet (i.e. the PR adding it |
| 179 | +> hasn't been merged and deployed), Buffer will fail with |
| 180 | +> `Failed to fetch image dimensions: Not Found`. Always merge and confirm the |
| 181 | +> images are live at `https://ep2026.europython.eu/media/speakers/` or |
| 182 | +> `https://ep2026.europython.eu/media/sponsors/` before running the script. |
| 183 | +
|
| 184 | +### How Buffer scheduling works |
| 185 | + |
| 186 | +Buffer operates as a FIFO queue against pre-configured time slots: |
| 187 | + |
| 188 | +- Time slots are defined per channel inside Buffer (e.g. "Twitter, weekdays at |
| 189 | + 09:00 and 14:00"). |
| 190 | +- Incoming posts fill the next available slot in order — you don't pick a |
| 191 | + specific date/time. |
| 192 | +- Slots can be added or shifted directly in Buffer if you need to change the |
| 193 | + cadence. |
| 194 | + |
| 195 | +> ⚠️ **Check your Buffer slots before running the script.** If you push 120 |
| 196 | +> cards and there is only one slot per day per channel, you'll end up with posts |
| 197 | +> queued months out — and there is no bulk deletion in Buffer, so you'd have to |
| 198 | +> remove them one by one. Before each run, open Buffer and verify the number and |
| 199 | +> cadence of slots for each channel matches your intended rollout pace. |
| 200 | +
|
| 201 | +The scheduling script is at `src/pages/api/media/buffer-scheduling.py`. |
| 202 | + |
| 203 | +### Configuration |
| 204 | + |
| 205 | +Open the script and set the range of queue items to schedule: |
| 206 | + |
| 207 | +```python |
| 208 | +QUEUE_START = 1 # first item (1-based, inclusive) |
| 209 | +QUEUE_END = 5 # last item (1-based, inclusive) |
| 210 | +``` |
| 211 | + |
| 212 | +It's a good idea to start with a small batch (e.g. 1–5) to verify everything |
| 213 | +looks correct in Buffer before pushing the full queue. Once you're happy with |
| 214 | +the results, increment the range for subsequent runs. |
| 215 | + |
| 216 | +### Running the script |
| 217 | + |
| 218 | +```bash |
| 219 | +uv run python src/pages/api/media/buffer-scheduling.py |
| 220 | +``` |
| 221 | + |
| 222 | +Or with your venv activated: |
| 223 | + |
| 224 | +```bash |
| 225 | +python src/pages/api/media/buffer-scheduling.py |
| 226 | +``` |
| 227 | + |
| 228 | +### What it does |
| 229 | + |
| 230 | +1. Connects to Buffer and fetches your channel profile IDs |
| 231 | +2. Iterates over the selected queue items |
| 232 | +3. For each item, posts to every channel that has text defined (`instagram`, |
| 233 | + `x`, `linkedin`, `bsky`, `fosstodon`) |
| 234 | +4. Skips channels with empty text or no matching Buffer profile |
| 235 | +5. Adds Instagram-specific metadata (`postType: post`) automatically |
| 236 | +6. Waits 1.5 seconds between items to respect rate limits |
| 237 | + |
| 238 | +### Channel notes |
| 239 | + |
| 240 | +- **Instagram**: requires `postType: post` — handled automatically |
| 241 | +- **Sponsors/partners**: no Instagram channel (only x, linkedin, bsky, |
| 242 | + fosstodon) |
| 243 | +- **Speakers**: all 5 channels including Instagram |
| 244 | +- **Bluesky**: Buffer returns this as `"bluesky"` — normalized to `"bsky"` |
| 245 | + automatically |
| 246 | + |
| 247 | +### Tracking progress |
| 248 | + |
| 249 | +After each successful run, note the last `QUEUE_END` value. The next run should |
| 250 | +set `QUEUE_START = previous QUEUE_END + 1`. |
| 251 | + |
| 252 | +Already scheduled as of initial setup: |
| 253 | + |
| 254 | +- Positions 1–3: Abhik Sarkar, Abhimanyu Singh Shekhawat, Abigail Afi Gbadago |
| 255 | + |
| 256 | +Next batch (positions 4–8): Adam Gorgoń, Alejandro Cabello Jiménez, |
| 257 | +ActiveCampaign, Aleksander, 1Password |
| 258 | + |
| 259 | +--- |
| 260 | + |
| 261 | +## Troubleshooting |
| 262 | + |
| 263 | +| Error | Cause | Fix | |
| 264 | +| --------------------------------------------- | ------------------------------- | ---------------------------------------------------------- | |
| 265 | +| `BUFFER_API_KEY not set` | Missing `.env.local` | Create `.env.local` with the key | |
| 266 | +| `Failed to fetch image dimensions: Not Found` | Image not deployed yet | Generate and commit the missing PNG first | |
| 267 | +| `Field "postType" is not defined` | Wrong field placement | `postType` goes inside `metadata.instagram`, not top-level | |
| 268 | +| `Channel profile not connected` | Service name mismatch | Check the normalization block in the script | |
| 269 | +| `KeyError: 'channel'` | Queue item missing channel data | Re-run `curl .../queue > queue.json` to regenerate | |
0 commit comments