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."
    )