(function (Scratch) { "use strict"; // Scratch-friendly APIs const GEO_API = "https://ipapi.co/"; const IP4_API = "https://api.ipify.org?format=json"; const IP6_API = "https://api64.ipify.org?format=json"; class BPixelCockatiel { constructor() { this.cache = null; this.cacheTime = 0; this.CACHE_LIFETIME = 60000; // 1 minute } getInfo() { return { id: "BPixelCockatiel", name: "Cockatiel Location+", color1: "#FFA500", color2: "#FFD700", blocks: [ { opcode: "myIP", blockType: Scratch.BlockType.REPORTER, text: "my IP address [version]", arguments: { version: { type: Scratch.ArgumentType.STRING, menu: "ipVersionMenu" } } }, { opcode: "latitude", blockType: Scratch.BlockType.REPORTER, text: "latitude" }, { opcode: "longitude", blockType: Scratch.BlockType.REPORTER, text: "longitude" }, { opcode: "myInfo", blockType: Scratch.BlockType.REPORTER, text: "my [info]", arguments: { info: { type: Scratch.ArgumentType.STRING, menu: "infoMenu" } } }, { opcode: "isUsingVPN", blockType: Scratch.BlockType.BOOLEAN, text: "is using VPN?" }, { opcode: "myTimezone", blockType: Scratch.BlockType.REPORTER, text: "my timezone" }, { opcode: "distanceBetweenIPs", blockType: Scratch.BlockType.REPORTER, text: "distance between IP [ip1] and IP [ip2] in [unit]", arguments: { ip1: { type: Scratch.ArgumentType.STRING }, ip2: { type: Scratch.ArgumentType.STRING }, unit: { type: Scratch.ArgumentType.STRING, menu: "unitMenu" } } } ], menus: { ipVersionMenu: { acceptReporters: true, items: ["Auto", "IPv4", "IPv6"] }, infoMenu: { acceptReporters: true, items: [ "country", "region", "city", "continent", "ISP", "org", "ASN", "currency", "calling code" ] }, unitMenu: { acceptReporters: true, items: ["km", "miles"] } } }; } /* ---------- Helpers ---------- */ async fetchJSON(url) { if (!navigator.onLine) throw new Error("Offline"); const res = await Scratch.fetch(url); if (!res.ok) throw new Error("Network error"); return res.json(); } async getGeoData() { const now = Date.now(); if (this.cache && now - this.cacheTime < this.CACHE_LIFETIME) { return this.cache; } const data = await this.fetchJSON(`${GEO_API}json/`); this.cache = data; this.cacheTime = now; return data; } degreesToRadians(deg) { return deg * (Math.PI / 180); } haversine(lat1, lon1, lat2, lon2, unit) { const R = unit === "miles" ? 3958.8 : 6371; const dLat = this.degreesToRadians(lat2 - lat1); const dLon = this.degreesToRadians(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(this.degreesToRadians(lat1)) * Math.cos(this.degreesToRadians(lat2)) * Math.sin(dLon / 2) ** 2; return R * (2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))); } /* ---------- Blocks ---------- */ async myIP(args) { try { if (args.version === "IPv6") return (await this.fetchJSON(IP6_API)).ip; if (args.version === "IPv4") return (await this.fetchJSON(IP4_API)).ip; try { return (await this.fetchJSON(IP6_API)).ip; } catch { return (await this.fetchJSON(IP4_API)).ip; } } catch { return "Unavailable"; } } async latitude() { return (await this.getGeoData()).latitude ?? "Unknown"; } async longitude() { return (await this.getGeoData()).longitude ?? "Unknown"; } async myInfo(args) { const d = await this.getGeoData(); switch (args.info) { case "country": return d.country_name; case "region": return d.region; case "city": return d.city; case "continent": return d.continent_code; case "ISP": return d.org; case "org": return d.org; case "ASN": return d.asn; case "currency": return d.currency; case "calling code": return d.country_calling_code; default: return "Unknown"; } } async isUsingVPN() { return false; // ipapi does not support VPN detection } async myTimezone() { return (await this.getGeoData()).timezone ?? "Unknown"; } async distanceBetweenIPs(args) { try { const a = await this.fetchJSON(`${GEO_API}${args.ip1}/json/`); const b = await this.fetchJSON(`${GEO_API}${args.ip2}/json/`); if (!a.latitude || !b.latitude) return "Unknown"; return this.haversine( a.latitude, a.longitude, b.latitude, b.longitude, args.unit ).toFixed(2); } catch { return "Error"; } } } Scratch.extensions.register(new BPixelCockatiel()); })(Scratch);