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
- Create a new Space, SDK = Docker.
- Push these files (
app.py,scraper.py,cache.py,requirements.txt,Dockerfile, thisREADME.md). - (Optional but recommended once it's public) Set a Space secret
API_KEYto require auth β see below. - (Optional) Enable persistent storage on the Space so the SQLite cache/favorites survive restarts. Without it, everything resets when the Space sleeps/restarts.
- 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 termlimitβ max results, default 20, capped at 50pageβ pagination, default 1formatβfull(default, full metadata),urls(flat list of direct URLs), ordiscord(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).
{
"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:
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 inscraper.pyfor notes on wiring up Playwright).