WoW Item Search

Type an item name to instantly filter results. Shows name, icon, and sell price (if available).
To connect real Blizzard data, point API_BASE to your proxy endpoint (see Worker snippet below).

Mode: Local demo
0 results
Type at least 2 characters
Show Cloudflare Worker proxy (recommended)
// Cloudflare Worker (JavaScript)
// - Stores BLIZZARD_CLIENT_ID/SECRET as Worker Secrets
// - Exposes: GET /search?q=hearth
// - Returns: [{ id, name, icon, sellPrice }] (sellPrice in copper or null)
//
// IMPORTANT: Blizzard API params can vary by game version/region.
// This uses the modern Game Data API item search + item media endpoints.
//
// wrangler.toml should set name + compatibility_date
// then add secrets:
//   wrangler secret put BLIZZARD_CLIENT_ID
//   wrangler secret put BLIZZARD_CLIENT_SECRET
//
// Usage from the HTML page:
//   const API_BASE = "https://YOUR-WORKER.your-domain.workers.dev";

export default {
  async fetch(req, env) {
    const url = new URL(req.url);
    const q = (url.searchParams.get("q") || "").trim();

    // CORS preflight
    if (req.method === "OPTIONS") {
      return new Response(null, {
        headers: corsHeaders(req.headers.get("Origin"))
      });
    }

    if (url.pathname === "/search") {
      if (q.length < 2) return json([], req);

      const region = "us";                 // change if needed: us/eu/kr/tw
      const locale = "en_US";              // en_US, etc.
      const namespace = "static-us";       // static-us, static-eu, etc.

      const token = await getToken(env);

      // 1) Item search
      // Modern WoW Game Data API has /data/wow/search/item
      // Common query style: name.en_US=... and _pageSize=...
      const searchUrl = new URL(`https://${region}.api.blizzard.com/data/wow/search/item`);
      searchUrl.searchParams.set("namespace", namespace);
      searchUrl.searchParams.set("locale", locale);
      searchUrl.searchParams.set("_pageSize", "20");
      // search operators vary; this is a common one:
      searchUrl.searchParams.set(`name.${locale}`, q);

      const searchRes = await fetch(searchUrl.toString(), {
        headers: { Authorization: `Bearer ${token}` }
      });

      if (!searchRes.ok) {
        const text = await searchRes.text();
        return json({ error: "Search failed", status: searchRes.status, detail: text }, req, 502);
      }

      const searchJson = await searchRes.json();
      const results = (searchJson.results || [])
        .map(r => r.data)
        .filter(Boolean)
        .slice(0, 20);

      // 2) For each item, fetch item details + media (icons)
      // Keep it fast: do a small fan-out in parallel, but cap it.
      const hydrated = await Promise.all(results.map(async (item) => {
        const id = item.id;
        const itemUrl = `https://${region}.api.blizzard.com/data/wow/item/${id}?namespace=${namespace}&locale=${locale}`;
        const mediaUrl = `https://${region}.api.blizzard.com/data/wow/media/item/${id}?namespace=${namespace}&locale=${locale}`;

        const [itemRes, mediaRes] = await Promise.all([
          fetch(itemUrl, { headers: { Authorization: `Bearer ${token}` } }),
          fetch(mediaUrl, { headers: { Authorization: `Bearer ${token}` } })
        ]);

        if (!itemRes.ok) return null;

        const itemData = await itemRes.json();
        const mediaData = mediaRes.ok ? await mediaRes.json() : null;

        // Media typically includes assets array; icon is often type "icon"
        const icon =
          (mediaData?.assets || []).find(a => a.key === "icon")?.value
          || (mediaData?.assets || [])[0]?.value
          || null;

        // Sell price (in copper) is often present as "sell_price" on item
        const sellPrice = Number.isFinite(itemData?.sell_price) ? itemData.sell_price : null;

        return {
          id,
          name: itemData?.name || item?.name || `Item ${id}`,
          icon,
          sellPrice
        };
      }));

      const cleaned = hydrated.filter(Boolean);
      return json(cleaned, req);
    }

    return json({ ok: true, routes: ["/search?q="] }, req);
  }
};

function corsHeaders(origin) {
  return {
    "Access-Control-Allow-Origin": origin || "*",
    "Access-Control-Allow-Methods": "GET,OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type,Authorization",
    "Access-Control-Max-Age": "86400"
  };
}

function json(data, req, status = 200) {
  const origin = req.headers.get("Origin");
  return new Response(JSON.stringify(data), {
    status,
    headers: {
      "content-type": "application/json; charset=utf-8",
      ...corsHeaders(origin)
    }
  });
}

let cachedToken = null;
let cachedTokenExp = 0;

async function getToken(env) {
  const now = Date.now();
  if (cachedToken && now < cachedTokenExp - 30_000) return cachedToken;

  const body = new URLSearchParams();
  body.set("grant_type", "client_credentials");

  const res = await fetch("https://oauth.battle.net/token", {
    method: "POST",
    headers: {
      "Authorization": "Basic " + btoa(`${env.BLIZZARD_CLIENT_ID}:${env.BLIZZARD_CLIENT_SECRET}`),
      "Content-Type": "application/x-www-form-urlencoded"
    },
    body
  });

  if (!res.ok) throw new Error("Failed to fetch Blizzard OAuth token");

  const j = await res.json();
  cachedToken = j.access_token;
  cachedTokenExp = now + (j.expires_in * 1000);
  return cachedToken;
}