GeoGuessr PlonkIt Button

Adds a button that links to the Plonk It page for that respective country, after the round ends

  1. // ==UserScript==
  2. // @name GeoGuessr PlonkIt Button
  3. // @description Adds a button that links to the Plonk It page for that respective country, after the round ends
  4. // @version 1.0
  5. // @author ArunSomasundaram
  6. // @match *://*.geoguessr.com/*
  7. // @icon https://www.google.com/s2/favicons?domain=geoguessr.com
  8. // @grant GM_openInTab
  9. // @run-at document-start
  10. // @namespace https://gf.qytechs.cn/users/1484321
  11. // ==/UserScript==
  12.  
  13. (function() {
  14. 'use strict';
  15.  
  16. // ============================================================================
  17. // CONSTANTS AND CONFIGURATION
  18. // ============================================================================
  19.  
  20. /**
  21. * Mapping of ISO 3166-1 alpha-2 country codes to PlonkIt URL slugs
  22. */
  23. const COUNTRY_DICT = {
  24. 'ad': 'andorra', 'ae': 'united-arab-emirates', 'af': 'afghanistan', 'ag': 'antigua-and-barbuda',
  25. 'ai': 'anguilla', 'al': 'albania', 'am': 'armenia', 'ao': 'angola', 'aq': 'antarctica',
  26. 'ar': 'argentina', 'as': 'american-samoa', 'at': 'austria', 'au': 'australia', 'aw': 'aruba',
  27. 'ax': 'aland-islands', 'az': 'azerbaijan', 'ba': 'bosnia-and-herzegovina', 'bb': 'barbados',
  28. 'bd': 'bangladesh', 'be': 'belgium', 'bf': 'burkina-faso', 'bg': 'bulgaria', 'bh': 'bahrain',
  29. 'bi': 'burundi', 'bj': 'benin', 'bl': 'saint-barthelemy', 'bm': 'bermuda', 'bn': 'brunei',
  30. 'bo': 'bolivia', 'bq': 'caribbean-netherlands', 'br': 'brazil', 'bs': 'bahamas', 'bt': 'bhutan',
  31. 'bv': 'bouvet-island', 'bw': 'botswana', 'by': 'belarus', 'bz': 'belize', 'ca': 'canada',
  32. 'cc': 'cocos-keeling-islands', 'cd': 'democratic-republic-of-the-congo', 'cf': 'central-african-republic',
  33. 'cg': 'republic-of-the-congo', 'ch': 'switzerland', 'ci': 'ivory-coast', 'ck': 'cook-islands',
  34. 'cl': 'chile', 'cm': 'cameroon', 'cn': 'china', 'co': 'colombia', 'cr': 'costa-rica',
  35. 'cu': 'cuba', 'cv': 'cape-verde', 'cw': 'curacao', 'cx': 'christmas-island', 'cy': 'cyprus',
  36. 'cz': 'czech-republic', 'de': 'germany', 'dj': 'djibouti', 'dk': 'denmark', 'dm': 'dominica',
  37. 'do': 'dominican-republic', 'dz': 'algeria', 'ec': 'ecuador', 'ee': 'estonia', 'eg': 'egypt',
  38. 'eh': 'western-sahara', 'er': 'eritrea', 'es': 'spain', 'et': 'ethiopia', 'fi': 'finland',
  39. 'fj': 'fiji', 'fk': 'falkland-islands', 'fm': 'micronesia', 'fo': 'faroe-islands',
  40. 'fr': 'france', 'ga': 'gabon', 'gb': 'united-kingdom', 'gd': 'grenada', 'ge': 'georgia',
  41. 'gf': 'french-guiana', 'gg': 'guernsey', 'gh': 'ghana', 'gi': 'gibraltar', 'gl': 'greenland',
  42. 'gm': 'gambia', 'gn': 'guinea', 'gp': 'guadeloupe', 'gq': 'equatorial-guinea', 'gr': 'greece',
  43. 'gs': 'south-georgia-and-south-sandwich-islands', 'gt': 'guatemala', 'gu': 'guam', 'gw': 'guinea-bissau',
  44. 'gy': 'guyana', 'hk': 'hong-kong', 'hm': 'heard-island-and-mcdonald-islands', 'hn': 'honduras',
  45. 'hr': 'croatia', 'ht': 'haiti', 'hu': 'hungary', 'id': 'indonesia', 'ie': 'ireland',
  46. 'il': 'israel', 'im': 'isle-of-man', 'in': 'india', 'io': 'british-indian-ocean-territory',
  47. 'iq': 'iraq', 'ir': 'iran', 'is': 'iceland', 'it': 'italy', 'je': 'jersey', 'jm': 'jamaica',
  48. 'jo': 'jordan', 'jp': 'japan', 'ke': 'kenya', 'kg': 'kyrgyzstan', 'kh': 'cambodia',
  49. 'ki': 'kiribati', 'km': 'comoros', 'kn': 'saint-kitts-and-nevis', 'kp': 'north-korea',
  50. 'kr': 'south-korea', 'kw': 'kuwait', 'ky': 'cayman-islands', 'kz': 'kazakhstan', 'la': 'laos',
  51. 'lb': 'lebanon', 'lc': 'saint-lucia', 'li': 'liechtenstein', 'lk': 'sri-lanka', 'lr': 'liberia',
  52. 'ls': 'lesotho', 'lt': 'lithuania', 'lu': 'luxembourg', 'lv': 'latvia', 'ly': 'libya',
  53. 'ma': 'morocco', 'mc': 'monaco', 'md': 'moldova', 'me': 'montenegro', 'mf': 'saint-martin',
  54. 'mg': 'madagascar', 'mh': 'marshall-islands', 'mk': 'north-macedonia', 'ml': 'mali', 'mm': 'myanmar',
  55. 'mn': 'mongolia', 'mo': 'macau', 'mp': 'northern-mariana-islands', 'mq': 'martinique',
  56. 'mr': 'mauritania', 'ms': 'montserrat', 'mt': 'malta', 'mu': 'mauritius', 'mv': 'maldives',
  57. 'mw': 'malawi', 'mx': 'mexico', 'my': 'malaysia', 'mz': 'mozambique', 'na': 'namibia',
  58. 'nc': 'new-caledonia', 'ne': 'niger', 'nf': 'norfolk-island', 'ng': 'nigeria', 'ni': 'nicaragua',
  59. 'nl': 'netherlands', 'no': 'norway', 'np': 'nepal', 'nr': 'nauru', 'nu': 'niue', 'nz': 'new-zealand',
  60. 'om': 'oman', 'pa': 'panama', 'pe': 'peru', 'pf': 'french-polynesia', 'pg': 'papua-new-guinea',
  61. 'ph': 'philippines', 'pk': 'pakistan', 'pl': 'poland', 'pm': 'saint-pierre-and-miquelon',
  62. 'pn': 'pitcairn-islands', 'pr': 'puerto-rico', 'ps': 'palestine', 'pt': 'portugal', 'pw': 'palau',
  63. 'py': 'paraguay', 'qa': 'qatar', 're': 'reunion', 'ro': 'romania', 'rs': 'serbia', 'ru': 'russia',
  64. 'rw': 'rwanda', 'sa': 'saudi-arabia', 'sb': 'solomon-islands', 'sc': 'seychelles', 'sd': 'sudan',
  65. 'se': 'sweden', 'sg': 'singapore', 'sh': 'saint-helena', 'si': 'slovenia', 'sj': 'svalbard-and-jan-mayen',
  66. 'sk': 'slovakia', 'sl': 'sierra-leone', 'sm': 'san-marino', 'sn': 'senegal', 'so': 'somalia',
  67. 'sr': 'suriname', 'ss': 'south-sudan', 'st': 'sao-tome-and-principe', 'sv': 'el-salvador',
  68. 'sx': 'sint-maarten', 'sy': 'syria', 'sz': 'eswatini', 'tc': 'turks-and-caicos-islands',
  69. 'td': 'chad', 'tf': 'french-southern-and-antarctic-lands', 'tg': 'togo', 'th': 'thailand',
  70. 'tj': 'tajikistan', 'tk': 'tokelau', 'tl': 'east-timor', 'tm': 'turkmenistan', 'tn': 'tunisia',
  71. 'to': 'tonga', 'tr': 'turkey', 'tt': 'trinidad-and-tobago', 'tv': 'tuvalu', 'tw': 'taiwan',
  72. 'tz': 'tanzania', 'ua': 'ukraine', 'ug': 'uganda', 'um': 'united-states-minor-outlying-islands',
  73. 'us': 'united-states', 'uy': 'uruguay', 'uz': 'uzbekistan', 'va': 'vatican-city', 'vc': 'saint-vincent-and-the-grenadines',
  74. 've': 'venezuela', 'vg': 'british-virgin-islands', 'vi': 'united-states-virgin-islands', 'vn': 'vietnam',
  75. 'vu': 'vanuatu', 'wf': 'wallis-and-futuna', 'ws': 'samoa', 'ye': 'yemen', 'yt': 'mayotte',
  76. 'za': 'south-africa', 'zm': 'zambia', 'zw': 'zimbabwe'
  77. };
  78.  
  79. /**
  80. * Configuration constants
  81. */
  82. const CONFIG = {
  83. BUTTON_ID: 'plonkit-button',
  84. POLLING_INTERVAL: 1500,
  85. PLONKIT_BASE_URL: 'https://plonkit.net',
  86. FLAG_CDN_URL: 'https://flagcdn.com/24x18'
  87. };
  88.  
  89. /**
  90. * CSS selectors for DOM elements
  91. */
  92. const SELECTORS = {
  93. RESULT_LAYOUT: '[class*="result-layout_root"]',
  94. PLAY_AGAIN_BUTTON: 'button[data-qa="play-again-button"]',
  95. GAME_FINISHED: 'div[class*="game-finished_root"]',
  96. GAME_SUMMARY: 'div[class*="game-summary_root"]'
  97. };
  98.  
  99. // ============================================================================
  100. // STATE MANAGEMENT
  101. // ============================================================================
  102.  
  103. /**
  104. * Set to track processed rounds to prevent duplicate button creation
  105. */
  106. const processedRounds = new Set();
  107.  
  108. /**
  109. * Track the last URL to detect navigation changes
  110. */
  111. let lastUrl = location.href;
  112.  
  113. // ============================================================================
  114. // UTILITY FUNCTIONS
  115. // ============================================================================
  116.  
  117. /**
  118. * Generates a flag image URL for the given country code
  119. * @param {string} countryCode - Two-letter ISO country code
  120. * @returns {string|null} Flag image URL or null if invalid code
  121. */
  122. function getFlagImageUrl(countryCode) {
  123. if (!countryCode || countryCode.length !== 2) {
  124. return null;
  125. }
  126. return `${CONFIG.FLAG_CDN_URL}/${countryCode.toLowerCase()}.png`;
  127. }
  128.  
  129. /**
  130. * Checks if the current page is in game or challenge mode
  131. * @returns {boolean} True if in game/challenge mode
  132. */
  133. function isGameMode() {
  134. return /\/(game|challenge)\//.test(location.pathname);
  135. }
  136.  
  137. /**
  138. * Extracts country code from game round data
  139. * @param {Object} roundData - Round data from API response
  140. * @returns {string|null} Country code or null if not found
  141. */
  142. function extractCountryCode(roundData) {
  143. if (!roundData) return null;
  144.  
  145. const code = (
  146. roundData.streakLocationCode ||
  147. roundData.locationCode ||
  148. roundData.countryCode ||
  149. roundData.country
  150. )?.toLowerCase();
  151.  
  152. return code;
  153. }
  154.  
  155. /**
  156. * Generates PlonkIt URL for a given country code
  157. * @param {string} countryCode - Two-letter ISO country code
  158. * @returns {string|null} PlonkIt URL or null if country not supported
  159. */
  160. function getPlonkItUrl(countryCode) {
  161. const slug = COUNTRY_DICT[countryCode.toLowerCase()];
  162. return slug ? `${CONFIG.PLONKIT_BASE_URL}/${slug}` : null;
  163. }
  164.  
  165. // ============================================================================
  166. // BUTTON MANAGEMENT
  167. // ============================================================================
  168.  
  169. /**
  170. * Creates and displays the PlonkIt button with country flag
  171. * @param {string} url - PlonkIt URL to open
  172. * @param {string} countryCode - Two-letter ISO country code
  173. */
  174. function createPlonkItButton(url, countryCode) {
  175. removeButton();
  176.  
  177. const button = document.createElement('button');
  178. button.id = CONFIG.BUTTON_ID;
  179.  
  180. const flagImageUrl = getFlagImageUrl(countryCode);
  181. console.log('Creating PlonkIt button for country:', countryCode, 'with flag URL:', flagImageUrl);
  182.  
  183. // Set button content with flag image or fallback emoji
  184. if (flagImageUrl) {
  185. button.innerHTML = `
  186. <img src="${flagImageUrl}"
  187. alt="${countryCode.toUpperCase()} flag"
  188. style="width: 20px; height: 15px; margin-right: 8px; border-radius: 2px; object-fit: cover;"
  189. onerror="this.style.display='none'">
  190. <span>PlonkIt</span>
  191. `;
  192. } else {
  193. button.innerHTML = '<span>🌍 PlonkIt</span>';
  194. }
  195.  
  196. button.classList.add('plonkit-btn');
  197. button.onclick = () => window.open(url, '_blank');
  198.  
  199. // Apply styles
  200. applyButtonStyles(button);
  201.  
  202. document.body.appendChild(button);
  203. }
  204.  
  205. /**
  206. * Applies CSS styles to the PlonkIt button
  207. * @param {HTMLElement} button - Button element to style
  208. */
  209. function applyButtonStyles(button) {
  210. // Inject CSS styles if not already present
  211. if (!document.getElementById('plonkit-styles')) {
  212. const style = document.createElement('style');
  213. style.id = 'plonkit-styles';
  214. style.textContent = `
  215. .plonkit-btn {
  216. position: relative;
  217. transition: transform 0.2s ease, box-shadow 0.2s ease;
  218. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  219. display: flex;
  220. align-items: center;
  221. justify-content: center;
  222. }
  223.  
  224. .plonkit-btn img {
  225. flex-shrink: 0;
  226. vertical-align: middle;
  227. }
  228.  
  229. .plonkit-btn:hover {
  230. transform: scale(1.05);
  231. box-shadow: 0 4px 10px rgba(0, 255, 123, 0.4);
  232. }
  233.  
  234. .plonkit-btn::after {
  235. position: absolute;
  236. bottom: 120%;
  237. left: 50%;
  238. transform: translateX(-50%);
  239. background: #333;
  240. color: white;
  241. padding: 6px 10px;
  242. border-radius: 4px;
  243. white-space: nowrap;
  244. font-size: 12px;
  245. opacity: 0;
  246. pointer-events: none;
  247. transition: opacity 0.2s ease;
  248. z-index: 9999;
  249. }
  250.  
  251. .plonkit-btn:hover::after {
  252. opacity: 1;
  253. }
  254. `;
  255. document.head.appendChild(style);
  256. }
  257.  
  258. // Apply inline styles
  259. button.style.cssText = `
  260. position: fixed;
  261. bottom: 20px;
  262. left: 20px;
  263. z-index: 9999;
  264. background-color: #4caf50;
  265. color: white;
  266. border: none;
  267. border-radius: 8px;
  268. padding: 10px 16px;
  269. font-size: 14px;
  270. font-weight: bold;
  271. cursor: pointer;
  272. box-shadow: 0 4px 8px rgba(0,0,0,0.3);
  273. transition: all 0.2s ease;
  274. font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
  275. `;
  276.  
  277. // Add hover effects
  278. button.onmouseover = () => button.style.transform = 'scale(1.05)';
  279. button.onmouseout = () => button.style.transform = 'scale(1)';
  280. }
  281.  
  282. /**
  283. * Removes the PlonkIt button from the page
  284. */
  285. function removeButton() {
  286. const button = document.getElementById(CONFIG.BUTTON_ID);
  287. if (button) {
  288. button.remove();
  289. }
  290. }
  291.  
  292. // ============================================================================
  293. // GAME DATA HANDLING
  294. // ============================================================================
  295.  
  296. /**
  297. * Fetches game data from GeoGuessr API and creates button if appropriate
  298. */
  299. async function fetchGameData() {
  300. if (!isGameMode()) return;
  301.  
  302. try {
  303. const token = location.pathname.split('/').pop().split('?')[0];
  304. const isChallenge = location.pathname.includes('/challenge/');
  305. const apiUrl = isChallenge
  306. ? `https://www.geoguessr.com/api/v3/challenges/${token}/game`
  307. : `https://www.geoguessr.com/api/v3/games/${token}`;
  308.  
  309. const response = await fetch(apiUrl);
  310. if (!response.ok) return;
  311.  
  312. const data = await response.json();
  313. const round = data.player?.guesses?.length || 0;
  314. const roundData = data.rounds?.[round - 1];
  315.  
  316. if (!roundData) return;
  317.  
  318. const countryCode = extractCountryCode(roundData);
  319. if (!countryCode) return;
  320.  
  321. // Check if this round has already been processed
  322. const gameId = data.token || token;
  323. const roundKey = `${gameId}-${round}`;
  324. if (processedRounds.has(roundKey)) return;
  325.  
  326. // Create button if country is supported
  327. const plonkItUrl = getPlonkItUrl(countryCode);
  328. if (plonkItUrl) {
  329. createPlonkItButton(plonkItUrl, countryCode);
  330. processedRounds.add(roundKey);
  331. }
  332.  
  333. } catch (error) {
  334. console.error('Error fetching GeoGuessr game data:', error);
  335. }
  336. }
  337.  
  338. // ============================================================================
  339. // EVENT HANDLING AND INITIALIZATION
  340. // ============================================================================
  341.  
  342. /**
  343. * Handles URL changes and page state updates
  344. */
  345. function handlePageUpdate() {
  346. if (!isGameMode()) {
  347. removeButton();
  348. return;
  349. }
  350.  
  351. // Handle URL changes
  352. if (location.href !== lastUrl) {
  353. lastUrl = location.href;
  354. processedRounds.clear();
  355. removeButton();
  356. }
  357.  
  358. // Remove button if result overlay disappears (new round or screen change)
  359. const resultElement = document.querySelector(SELECTORS.RESULT_LAYOUT);
  360. if (!resultElement) {
  361. removeButton();
  362. }
  363.  
  364. // Remove button on final screen after all rounds completed
  365. const finalScreenSelectors = [
  366. SELECTORS.PLAY_AGAIN_BUTTON,
  367. SELECTORS.GAME_FINISHED,
  368. SELECTORS.GAME_SUMMARY
  369. ];
  370.  
  371. const finalSummary = finalScreenSelectors.some(selector =>
  372. document.querySelector(selector)
  373. );
  374.  
  375. if (finalSummary) {
  376. removeButton();
  377. return;
  378. }
  379.  
  380. // Fetch game data and potentially create button
  381. fetchGameData();
  382. }
  383.  
  384. /**
  385. * Initialize the script
  386. */
  387. function initialize() {
  388. console.log('✅ GeoGuessr PlonkIt Button script loaded');
  389.  
  390. // Set up DOM mutation observer
  391. const observer = new MutationObserver(handlePageUpdate);
  392. observer.observe(document.body, {
  393. childList: true,
  394. subtree: true
  395. });
  396.  
  397. // Set up fallback polling mechanism
  398. setInterval(handlePageUpdate, CONFIG.POLLING_INTERVAL);
  399. }
  400.  
  401. // ============================================================================
  402. // SCRIPT EXECUTION
  403. // ============================================================================
  404.  
  405. // Initialize the script when DOM is ready
  406. if (document.readyState === 'loading') {
  407. document.addEventListener('DOMContentLoaded', initialize);
  408. } else {
  409. initialize();
  410. }
  411.  
  412. })();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址