File size: 5,072 Bytes
70823b1 460a071 70823b1 460a071 70823b1 460a071 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 | ---
title: Personal Tenor Scraper API
emoji: π
colorFrom: yellow
colorTo: pink
sdk: docker
app_port: 7860
pinned: false
---
# Personal Tenor Scraper API
Unofficial personal replacement for the Tenor public API, which Google
shut down on **June 30, 2026**. Tenor.com itself is still online for
humans, so this scrapes the live site instead of calling the (now dead)
`api.tenor.com` endpoints.
**Heads up**: this depends on Tenor's page markup not changing. It uses
two independent extraction strategies (embedded JSON state blob, then a
raw-HTML regex fallback) so it should survive minor site tweaks, but a
big redesign on Tenor's end could break it. Check `scraper.py` if search
results suddenly come back empty.
## Deploying on Hugging Face Spaces
1. Create a new Space, SDK = **Docker**.
2. Push these files (`app.py`, `scraper.py`, `cache.py`, `requirements.txt`, `Dockerfile`, this `README.md`).
3. (Optional but recommended once it's public) Set a Space secret `API_KEY` to require auth β see below.
4. (Optional) Enable persistent storage on the Space so the SQLite cache/favorites survive restarts. Without it, everything resets when the Space sleeps/restarts.
5. Space builds and starts listening on port 7860 automatically.
## Auth
If you set the `API_KEY` environment variable/secret on the Space,
every endpoint requires it via either:
- Header: `X-API-Key: your-key-here`
- Query param: `?api_key=your-key-here`
Leave `API_KEY` unset for open access (fine if you're the only one who
knows the Space URL, less fine once people share it around).
## Endpoints
### `GET /search`
```
/search?q=cats&limit=10&format=full
```
Params:
- `q` (required) β search term
- `limit` β max results, default 20, capped at 50
- `page` β pagination, default 1
- `format` β `full` (default, full metadata), `urls` (flat list of direct URLs), or `discord` (list of `{url, embed_url, title}`)
### `GET /random`
```
/random?q=thumbs+up&limit=1&format=urls
```
Same params as `/search` minus `page`, returns shuffled picks.
### `GET /direct`
```
/direct?q=high+five
```
No JSON β just **302 redirects straight to a gif URL**. Great for
sticking directly into a Discord message as a link, or hitting from a
webhook/bot that just wants a URL back with zero parsing:
```
Just paste https://your-space.hf.space/direct?q=high+five directly in Discord chat and it unfurls as the gif.
```
### `POST /favorites`
Save a gif you like for quick reuse later (e.g. your most-used reaction gifs).
```json
{
"gif_url": "https://media.tenor.com/xyz.gif",
"mp4_url": "https://media.tenor.com/xyz.mp4",
"title": "thumbs up guy",
"tag": "reactions"
}
```
### `GET /favorites?tag=reactions`
List saved favorites, optionally filtered by tag.
### `DELETE /favorites/<id>`
Remove a saved favorite by its numeric id.
### `GET /favorites/random?tag=reactions`
Random pick from your saved favorites β good for a Discord bot's
"random reaction gif" command without re-scraping every time.
### `GET /health`
Basic liveness check, returns `{"status": "ok", "time": ...}`.
## Using it from a Discord bot later
Since you said the bot wiring comes later β the API is shaped so a
future bot command can just do:
```python
import requests
resp = requests.get(
"https://your-space.hf.space/random",
params={"q": query, "format": "discord", "limit": 1},
)
gif = resp.json()["results"][0]
await ctx.send(gif["embed_url"]) # Discord auto-embeds the URL
```
## Rate limiting
In-memory sliding window, default 60 requests / 60 seconds per API
key (or per IP if no key is set). Adjust via `RATE_LIMIT_MAX` and
`RATE_LIMIT_WINDOW` env vars. This is per-instance, not distributed β
fine for a single free-tier Space.
## Caching
Search results are cached in SQLite for `CACHE_TTL_SECONDS` (default
3600 = 1 hour) so repeated searches for the same term don't re-scrape
Tenor every time. Cache path defaults to `/data/tenor_cache.db` if
persistent storage is enabled on the Space, otherwise falls back to a
local file that resets on restart.
## Env vars summary
| Var | Default | Purpose |
|---|---|---|
| `API_KEY` | unset (auth disabled) | require `X-API-Key` header/`api_key` param |
| `RATE_LIMIT_MAX` | 60 | max requests per window |
| `RATE_LIMIT_WINDOW` | 60 | window length in seconds |
| `CACHE_TTL_SECONDS` | 3600 | how long cached search results stay fresh |
| `TENOR_CACHE_PATH` | `/data/tenor_cache.db` | SQLite path when persistent storage is enabled |
| `PORT` | 7860 | server port (HF Spaces expects 7860) |
## Known limitations
- Scraping HTML is inherently more fragile than a real API β expect occasional breakage if Tenor changes their frontend.
- No official support/guarantee from Tenor; this is purely a personal workaround now that the API is gone.
- Rate limiting is per-instance/in-memory, resets on restart.
- If Tenor ever moves to fully client-rendered results with nothing in the initial HTML, you'd need to add a headless-browser fallback (see the `render_with_browser()` stub in `scraper.py` for notes on wiring up Playwright). |