File size: 11,007 Bytes
1fb43f8 | 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 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 | """
scraper.py — Tenor.com HTML scraper (post Tenor-API-shutdown workaround)
Tenor's public API shut down on 2026-06-30. Tenor.com itself is still
online and still works for humans, so this module scrapes the rendered
site instead of calling the (now-dead) api.tenor.com endpoints.
Tenor's frontend is a React/Next.js app. On first page load it embeds a
JSON payload in the HTML (typically inside a <script id="__NEXT_DATA__">
tag, or a similar `window.__PRELOADED_STATE__`-style blob) that contains
the actual GIF objects used to hydrate the page. We try, in order:
1. Parse embedded JSON state blobs (__NEXT_DATA__ / __PRELOADED_STATE__ /
any <script type="application/json"> blob that looks GIF-shaped).
2. Regex-scan the raw HTML for tenor.com/media / c.tenor.com URLs
(.gif / .mp4 / .webp) as a structural fallback if the JSON blob
approach fails or Tenor changes their bundler/markup.
Because this depends on Tenor's page markup, IT WILL BREAK if Tenor
changes their frontend. Both strategies are kept independent so a
change to one doesn't necessarily kill the other. If Tenor moves to a
fully client-side-only render with no SSR JSON, only the regex fallback
will keep working, and if they also start lazy-loading images via
IntersectionObserver with no URLs in the initial HTML at all, this
approach stops working entirely and would need a headless browser
(Playwright/Selenium) — not included by default here to keep the HF
Space lightweight, but see `render_with_browser()` stub at the bottom.
"""
import re
import json
import html
import logging
import random
from urllib.parse import quote
import requests
logger = logging.getLogger("tenor_scraper")
USER_AGENTS = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36",
]
BASE_URL = "https://tenor.com"
# Matches direct tenor media CDN links for gif/webp/mp4 assets.
MEDIA_URL_RE = re.compile(
r'https?://(?:media|c)\.tenor\.com/[A-Za-z0-9_\-./]+\.(?:gif|mp4|webp)',
re.IGNORECASE,
)
# Matches /view/<slug>-gif-<id> style permalinks, used to dedupe / get ids.
VIEW_URL_RE = re.compile(r'/view/[a-z0-9\-]+-gif-(\d+)', re.IGNORECASE)
NEXT_DATA_RE = re.compile(
r'<script id="__NEXT_DATA__"[^>]*>(.*?)</script>', re.DOTALL
)
GENERIC_JSON_SCRIPT_RE = re.compile(
r'<script type="application/json"[^>]*>(.*?)</script>', re.DOTALL
)
class TenorScraperError(Exception):
pass
def _headers():
return {
"User-Agent": random.choice(USER_AGENTS),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
}
def _fetch(url, timeout=15):
resp = requests.get(url, headers=_headers(), timeout=timeout)
resp.raise_for_status()
return resp.text
def _search_url(query, pos_page=1):
slug = quote(query.strip().lower().replace(" ", "-"))
if not slug.endswith("-gifs"):
slug = f"{slug}-gifs"
url = f"{BASE_URL}/search/{slug}"
if pos_page > 1:
url += f"?page={pos_page}"
return url
def _walk_json_for_gif_objects(obj, found):
"""
Recursively walk a parsed JSON structure looking for dict entries
that look like Tenor GIF result objects, i.e. anything with a
media/gif URL and an id/title. Tenor's exact schema may vary by
build, so this is intentionally loose/duck-typed rather than
matching one fixed schema.
"""
if isinstance(obj, dict):
# Heuristic: object has some url-ish field pointing at tenor media
url_fields = {}
for key in ("url", "gif_url", "mp4", "webp", "src"):
val = obj.get(key)
if isinstance(val, str) and MEDIA_URL_RE.search(val):
url_fields[key] = val
# Nested media dict pattern: {"media": {"gif": {"url": ...}}}
media = obj.get("media") or obj.get("media_formats") or obj.get("formats")
if isinstance(media, dict):
for fmt_name, fmt_val in media.items():
if isinstance(fmt_val, dict):
u = fmt_val.get("url")
if isinstance(u, str) and MEDIA_URL_RE.search(u):
url_fields[fmt_name] = u
elif isinstance(fmt_val, str) and MEDIA_URL_RE.search(fmt_val):
url_fields[fmt_name] = fmt_val
if url_fields:
gif_id = obj.get("id") or obj.get("gif_id")
title = obj.get("title") or obj.get("h1_title") or obj.get("name") or ""
best_gif = url_fields.get("gif") or url_fields.get("url") or next(iter(url_fields.values()))
best_mp4 = url_fields.get("mp4")
found.append({
"id": str(gif_id) if gif_id else None,
"title": html.unescape(str(title)),
"gif_url": best_gif,
"mp4_url": best_mp4,
"raw_fields": list(url_fields.keys()),
})
# Don't recurse into the "media"/"formats" dict we already
# consumed above -- walking into it produces spurious
# duplicate/partial "results" for each format entry (e.g. a
# standalone {"id": None, "gif_url": "...mp4"} from the mp4
# sub-dict). Recurse into everything else on this object instead.
skip_keys = {"media", "media_formats", "formats"}
for k, v in obj.items():
if k in skip_keys:
continue
_walk_json_for_gif_objects(v, found)
else:
for v in obj.values():
_walk_json_for_gif_objects(v, found)
elif isinstance(obj, list):
for item in obj:
_walk_json_for_gif_objects(item, found)
def _extract_via_json_blob(html_text):
results = []
m = NEXT_DATA_RE.search(html_text)
candidates = []
if m:
candidates.append(m.group(1))
candidates.extend(GENERIC_JSON_SCRIPT_RE.findall(html_text))
for blob in candidates:
try:
data = json.loads(blob)
except (json.JSONDecodeError, ValueError):
continue
found = []
_walk_json_for_gif_objects(data, found)
results.extend(found)
# de-dupe by gif_url
seen = set()
deduped = []
for r in results:
if r["gif_url"] in seen:
continue
seen.add(r["gif_url"])
deduped.append(r)
return deduped
def _extract_via_regex(html_text):
"""
Fallback: just pull every distinct tenor media URL out of the raw
HTML. No titles/ids available this way (unless we can pair with a
nearby /view/ URL), so this produces weaker metadata than the JSON
path but is much more resistant to markup/schema changes.
"""
urls = MEDIA_URL_RE.findall(html_text)
# prefer .gif entries as the canonical url, keep others as alt formats
seen = set()
results = []
for u in urls:
# normalize AA/AAAAd sizing suffixes are part of the path, keep as-is
if u in seen:
continue
seen.add(u)
results.append({
"id": None,
"title": "",
"gif_url": u if u.lower().endswith((".gif", ".webp")) else None,
"mp4_url": u if u.lower().endswith(".mp4") else None,
"raw_fields": ["regex_fallback"],
})
# merge entries that only differ by extension but share the same
# base path (common on tenor's CDN: name.gif vs name.mp4)
merged = {}
for r in results:
base = None
for ext in (".gif", ".mp4", ".webp"):
src = r["gif_url"] or r["mp4_url"]
if src and src.lower().endswith(ext):
base = src.rsplit(".", 1)[0]
break
if base is None:
continue
entry = merged.setdefault(base, {"id": None, "title": "", "gif_url": None, "mp4_url": None, "raw_fields": ["regex_fallback"]})
if r["gif_url"]:
entry["gif_url"] = r["gif_url"]
if r["mp4_url"]:
entry["mp4_url"] = r["mp4_url"]
return [v for v in merged.values() if v["gif_url"] or v["mp4_url"]]
def scrape_search(query, limit=20, page=1):
"""
Scrape Tenor search results for `query`. Returns a list of dicts:
{ id, title, gif_url, mp4_url, raw_fields }
Tries the JSON-blob strategy first, falls back to regex scraping.
Raises TenorScraperError if both strategies find nothing (which
usually means Tenor changed their markup or is blocking the
request — check status codes / consider adding a browser fallback).
"""
url = _search_url(query, pos_page=page)
try:
html_text = _fetch(url)
except requests.RequestException as e:
raise TenorScraperError(f"Failed to fetch Tenor search page: {e}") from e
results = _extract_via_json_blob(html_text)
strategy = "json_blob"
if not results:
results = _extract_via_regex(html_text)
strategy = "regex_fallback"
if not results:
raise TenorScraperError(
"No GIF results extracted — Tenor's page markup may have changed, "
"or the request was blocked/rate-limited. Try again or inspect "
"the raw HTML."
)
for r in results:
r["source_strategy"] = strategy
return results[:limit]
def scrape_random(query, limit=1):
"""Convenience wrapper: scrape then shuffle-pick `limit` results."""
results = scrape_search(query, limit=50)
random.shuffle(results)
return results[:limit]
def render_with_browser(query): # pragma: no cover
"""
Optional heavy fallback stub. If Tenor ever moves to a render path
where NO gif URLs appear anywhere in the initial HTML (fully
client-fetched after JS execution with no SSR at all), the
strategies above will stop finding anything and you'd need a real
headless browser to load the page, wait for network idle, and read
the DOM/network requests for media URLs.
Not implemented by default because:
- Playwright/Selenium + a real browser binary is heavy for a
free-tier HF Space (slow cold starts, larger image, more RAM
pressure under concurrent requests).
- It's meaningfully slower per-request than the HTML strategies.
If you need it: `pip install playwright && playwright install chromium`,
launch headless, page.goto(search_url, wait_until="networkidle"),
then either page.content() into _extract_via_json_blob/_extract_via_regex,
or intercept network responses matching MEDIA_URL_RE directly.
"""
raise NotImplementedError(
"Browser rendering fallback not wired up. See docstring for how to "
"add Playwright if the HTML-based strategies stop working."
) |