1/** 2 * GAS API Wrapper v2.0 3 * 4 * Google Apps Script Web Appê³¼ íµì íë ëí¼. 5 * 6 * GAS Web Appì CORS í¹ì±: 7 * - GASë script.google.com â script.googleusercontent.comì¼ë¡ 302 리ë¤ì´ë í¸ 8 * - POST fetch()ë 리ë¤ì´ë í¸ ì CORS ì°¨ë¨ë¨ 9 * - GET ìì²ì 리ë¤ì´ë í¸ í ì ì ìëµ (CORS í¤ë í¬í¨) 10 * - í° íì´ë¡ë(GET URL íê³ ì´ê³¼)ë ëì <form> POST + iframeì¼ë¡ ì²ë¦¬ 11 * 12 * ì¬ì©ë²: 13 * 기존: google.script.run.withSuccessHandler(cb).functionName(params) 14 * ì ê·: gasApi.functionName(params).then(cb) 15 */ 16 17(function(window) { 18 'use strict'; 19 20 // ========================================== 21 // API Configuration 22 // ========================================== 23 24 const API_URL = window.GAS_API_URL || window.GAS_SCRIPT_URL || ''; 25 const DEFAULT_TIMEOUT = 60000; // 60 seconds (GAS can be slow) 26 const URL_MAX_LENGTH = 4000; // GET URL length limit (GAS 302 리ë¤ì´ë í¸ ì user_content_key ~3000ì ì¶ê°ë¨) 27 28 // ========================================== 29 // Core: API Call via GET (primary method) 30 // ========================================== 31 32 /** 33 * GAS API í¸ì¶ (GET ë°©ì - CORS ìì ) 34 * GAS Web Appì GET ìì² ì 302 리ë¤ì´ë í¸ í CORS í¤ë를 í¬í¨í ìëµ ë°í 35 */ 36 async function callGasApiGet(action, params) { 37 const requestData = { action, ...params }; 38 const payload = JSON.stringify(requestData); 39 40 // URL íë¼ë¯¸í°ë¡ ì ë¬ 41 const url = API_URL + '?payload=' + encodeURIComponent(payload); 42 43 const controller = new AbortController(); 44 const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT); 45 46 try { 47 const response = await fetch(url, { 48 method: 'GET', 49 redirect: 'follow', 50 signal: controller.signal 51 }); 52 53 clearTimeout(timeoutId); 54 55 if (!response.ok) { 56 throw new Error('HTTP ' + response.status + ': ' + response.statusText); 57 } 58 59 const text = await response.text(); 60 61 // GASê° HTML ìë¬ íì´ì§ë¥¼ ë°ííë ê²½ì° ì²ë¦¬ 62 if (text.startsWith('<!DOCTYPE') || text.startsWith('<html')) { 63 throw new Error('GAS returned HTML instead of JSON. Check deployment.'); 64 } 65 66 const result = JSON.parse(text); 67 68 if (!result.success) { 69 throw new Error(result.error || 'Unknown API error'); 70 } 71 72 return result.data; 73 74 } catch (error) { 75 clearTimeout(timeoutId); 76 if (error.name === 'AbortError') { 77 throw new Error('ìì² ìê°ì´ ì´ê³¼ëììµëë¤ (60ì´). ë¤ì ìëí´ì£¼ì¸ì.'); 78 } 79 throw error; 80 } 81 } 82 83 // ========================================== 84 // Core: API Call via POST (large payload fallback) 85 // Uses dynamic iframe + form submission 86 // ========================================== 87 88 let _iframeCallbackId = 0; 89 90 /** 91 * GAS API í¸ì¶ (POST via iframe - í° ë°ì´í°ì©) 92 * fetch POSTë CORSë¡ ì°¨ë¨ëë¯ë¡ iframe form submit ì¬ì© 93 */ 94 function callGasApiPost(action, params) { 95 return new Promise((resolve, reject) => { 96 const callbackName = '__gasCallback_' + (++_iframeCallbackId) + '_' + Date.now(); 97 const timeoutId = setTimeout(() => { 98 cleanup(); 99 reject(new Error('ìì² ìê°ì´ ì´ê³¼ëììµëë¤ (60ì´). ë¤ì ìëí´ì£¼ì¸ì.')); 100 }, DEFAULT_TIMEOUT); 101 102 // iframe ìì± 103 const iframe = document.createElement('iframe'); 104 iframe.name = 'gasFrame_' + _iframeCallbackId; 105 iframe.style.display = 'none'; 106 document.body.appendChild(iframe); 107 108 // callback ë±ë¡ 109 window[callbackName] = function(result) { 110 clearTimeout(timeoutId); 111 cleanup(); 112 try { 113 if (typeof result === 'string') { 114 result = JSON.parse(result); 115 } 116 if (result && result.success) { 117 resolve(result.data); 118 } else { 119 reject(new Error((result && result.error) || 'Unknown API error')); 120 } 121 } catch (e) { 122 reject(e); 123 } 124 }; 125 126 function cleanup() { 127 delete window[callbackName]; 128 setTimeout(() => { 129 if (iframe.parentNode) iframe.parentNode.removeChild(iframe); 130 const form = document.getElementById('gasForm_' + _iframeCallbackId); 131 if (form) form.parentNode.removeChild(form); 132 }, 100); 133 } 134 135 // form ìì± 136 const form = document.createElement('form'); 137 form.id = 'gasForm_' + _iframeCallbackId; 138 form.method = 'POST'; 139 form.action = API_URL; 140 form.target = iframe.name; 141 form.style.display = 'none'; 142 143 // payload를 hidden inputì¼ë¡ ì ë¬ 144 const requestData = { action, ...params, _callback: callbackName }; 145 const input = document.createElement('input'); 146 input.type = 'hidden'; 147 input.name = 'payload'; 148 input.value = JSON.stringify(requestData); 149 form.appendChild(input); 150 151 document.body.appendChild(form); 152 153 // iframe load ì´ë²¤í¸ (callbackì´ í¸ì¶ëì§ ìë ê²½ì° ëë¹) 154 iframe.onload = function() { 155 // iframeìì ìëµì ì½ì´ë³´ê¸° ìë 156 try { 157 const iframeDoc = iframe.contentDocument || iframe.contentWindow.document; 158 const body = iframeDoc.body; 159 if (body) { 160 const text = body.innerText || body.textContent; 161 if (text && text.trim().startsWith('{')) { 162 // JSON ìëµì´ iframeì ì§ì ë ëë§ë ê²½ì° 163 clearTimeout(timeoutId); 164 cleanup(); 165 try { 166 const result = JSON.parse(text.trim()); 167 if (result.success) { 168 resolve(result.data); 169 } else { 170 reject(new Error(result.error || 'Unknown API error')); 171 } 172 } catch (e) { 173 reject(new Error('JSON parse error: ' + e.message)); 174 } 175 return; 176 } 177 } 178 } catch (e) { 179 // cross-origin iframe ì ê·¼ ë¶ê° - ì ì, callback ë기 180 } 181 }; 182 183 form.submit(); 184 });
185 } 186 187 // ========================================== 188 // Main Entry Point: Smart routing + Concurrency Control 189 // ========================================== 190 191 // â ëì ìì² ì í (GAS ê³¼ë¶í ë°©ì§) 192 const MAX_CONCURRENT = 3; 193 let _activeRequests = 0; 194 const _requestQueue = []; 195 196 function _enqueue(fn) { 197 return new Promise((resolve, reject) => { 198 const run = () => { 199 _activeRequests++; 200 fn().then(resolve).catch(reject).finally(() => { 201 _activeRequests--; 202 if (_requestQueue.length > 0) { 203 const next = _requestQueue.shift(); 204 next(); 205 } 206 }); 207 }; 208 if (_activeRequests < MAX_CONCURRENT) { 209 run(); 210 } else { 211 _requestQueue.push(run); 212 } 213 }); 214 } 215 216 // â ëì¼ action ì¤ë³µ í¸ì¶ ë°©ì§ (ì§í ì¤ì¸ ê°ì action ì¬ì¬ì©) 217 const _inflightRequests = {}; 218 219 /** 220 * API í¸ì¶ ë©ì¸ í¨ì - payload í¬ê¸°ì ë°ë¼ GET/POST ìë ì í 221 * + ëì ìì² ì í & ëì¼ action ì¤ë³µ ë°©ì§ 222 */ 223 async function callGasApi(action, params = {}) { 224 if (!API_URL || API_URL === 'YOUR_GAS_WEB_APP_URL_HERE') { 225 throw new Error( 226 'GAS API URLì´ ì¤ì ëì§ ìììµëë¤. ' + 227 'config/config.js íì¼ìì GAS_API_URLì ì¤ì í´ì£¼ì¸ì.' 228 ); 229 } 230 231 // â ì½ê¸° ì ì© actionì ê²½ì° ëì¼ ìì² ì§í ì¤ì´ë©´ ì¬ì¬ì© 232 const READ_ACTIONS = ['getWeatherData', 'getWeatherWithLocation', 'getHomeFeed', 'getSchedules', 'getLocationByIP']; 233 const dedupeKey = READ_ACTIONS.indexOf(action) !== -1 ? action : null; 234 235 if (dedupeKey && _inflightRequests[dedupeKey]) { 236 console.log('[API] ëì¼ ìì² ì¬ì¬ì©:', dedupeKey); 237 return _inflightRequests[dedupeKey]; 238 } 239 240 const requestData = { action, ...params }; 241 const payload = JSON.stringify(requestData); 242 const estimatedUrlLength = API_URL.length + 10 + encodeURIComponent(payload).length; 243 244 const promise = _enqueue(async () => { 245 try { 246 if (estimatedUrlLength < URL_MAX_LENGTH) { 247 return await callGasApiGet(action, params); 248 } else { 249 return await callGasApiPost(action, params); 250 } 251 } catch (error) { 252 // íì´ì§ ì í/fetch ì¤ë¨ì warnì¼ë¡ ì²ë¦¬ (ì ìì ì¸ ëì) 253 if (error.name === 'AbortError' || (error.message && error.message.includes('Failed to fetch'))) { 254 console.warn('GAS API [' + action + '] ë¤í¸ìí¬ ì¤ë¥ (ì¬ìë ê°ë¥):', error.message); 255 // Offline queue: queue write actions for later when offline 256 if (!navigator.onLine && window.offlineQueue && READ_ACTIONS.indexOf(action) === -1) { 257 window.offlineQueue.queue(action, params); 258 } 259 } else { 260 console.error('GAS API Error [' + action + ']:', error); 261 } 262 throw error; 263 } 264 }); 265 266 if (dedupeKey) { 267 _inflightRequests[dedupeKey] = promise; 268 promise.finally(() => { delete _inflightRequests[dedupeKey]; }); 269 } 270 271 return promise; 272 } 273 274 // ========================================== 275 // localStorage-backed TTL Cache for read-only endpoints 276 // ========================================== 277 278 const CACHE_PREFIX = 'gas_cache_'; 279 const CACHE_TTL = { 280 getHomeFeed: 2 * 60 * 1000, // 2ë¶ 281 getSchedules: 5 * 60 * 1000, // 5ë¶ 282 getWeatherData: 10 * 60 * 1000, // 10ë¶ 283 getWeatherWithLocation: 10 * 60 * 1000, // 10ë¶ 284 getLocationByIP: 30 * 60 * 1000, // 30ë¶ 285 getNoticeList: 3 * 60 * 1000, // 3ë¶ 286 getBoardList: 2 * 60 * 1000, // 2ë¶ 287 getArchiveList: 5 * 60 * 1000, // 5ë¶ 288 getGalleryList: 3 * 60 * 1000, // 3ë¶ 289 getMemberList: 5 * 60 * 1000, // 5ë¶ 290 getPrayerList: 3 * 60 * 1000, // 3ë¶ 291 getQTComicList: 5 * 60 * 1000, // 5ë¶ 292 getEventList: 5 * 60 * 1000, // 5ë¶ 293 getPointRules: 30 * 60 * 1000, // 30ë¶ 294 getTalentRanking: 3 * 60 * 1000, // 3ë¶ 295 }; 296 297 /** 298 * Generate a stable cache key from action + params 299 */ 300 function _cacheKey(action, params) { 301 const paramStr = params && Object.keys(params).length > 0 ? JSON.stringify(params) : ''; 302 return CACHE_PREFIX + action + (paramStr ? '_' + paramStr : ''); 303 } 304 305 /** 306 * Read from localStorage TTL cache. Returns null if miss or expired. 307 */ 308 function _cacheGet(key) { 309 try { 310 const raw = localStorage.getItem(key); 311 if (!raw) return null; 312 const entry = JSON.parse(raw); 313 if (Date.now() > entry.exp) { 314 localStorage.removeItem(key); 315 return null; 316 } 317 return entry.data; 318 } catch (e) { 319 return null; 320 } 321 } 322 323 /** 324 * Write to localStorage TTL cache. 325 */ 326 function _cacheSet(key, data, ttl) { 327 try { 328 localStorage.setItem(key, JSON.stringify({ data: data, exp: Date.now() + ttl })); 329 } catch (e) { 330 // localStorage full â evict oldest gas_cache entries and retry once 331 _cacheEvict();
332 try { 333 localStorage.setItem(key, JSON.stringify({ data: data, exp: Date.now() + ttl })); 334 } catch (e2) { /* give up silently */ } 335 } 336 } 337 338 /** 339 * Evict all expired gas_cache entries (or oldest 10 if none expired) 340 */ 341 function _cacheEvict() { 342 const keys = []; 343 for (let i = 0; i < localStorage.length; i++) { 344 const k = localStorage.key(i); 345 if (k && k.startsWith(CACHE_PREFIX)) keys.push(k); 346 } 347 // Remove expired first 348 let removed = 0; 349 keys.forEach(function(k) { 350 try { 351 const raw = localStorage.getItem(k); 352 if (raw) { 353 const entry = JSON.parse(raw); 354 if (Date.now() > entry.exp) { localStorage.removeItem(k); removed++; } 355 } 356 } catch (e) { localStorage.removeItem(k); removed++; } 357 }); 358 // If none expired, remove oldest 10 359 if (removed === 0) { 360 keys.slice(0, 10).forEach(function(k) { localStorage.removeItem(k); }); 361 } 362 } 363 364 /** 365 * Wrap callGasApi with caching layer for cacheable read-only endpoints. 366 * Non-cacheable actions pass through directly. 367 * Uses stale-while-revalidate: returns cached data immediately, 368 * then refreshes in the background. 369 */ 370 const _origCallGasApi = callGasApi; 371 372 callGasApi = async function(action, params) { 373 if (params === undefined) params = {}; 374 const ttl = CACHE_TTL[action]; 375 376 // Not a cacheable action â pass through 377 if (!ttl) return _origCallGasApi(action, params); 378 379 const key = _cacheKey(action, params); 380 const cached = _cacheGet(key); 381 382 if (cached !== null) { 383 // Stale-while-revalidate: return cached immediately, refresh in background 384 _origCallGasApi(action, params).then(function(freshData) { 385 _cacheSet(key, freshData, ttl); 386 }).catch(function() { /* background refresh failed, keep stale cache */ }); 387 console.log('[API Cache] HIT:', action); 388 return cached; 389 } 390 391 // Cache miss â fetch, cache, and return 392 const data = await _origCallGasApi(action, params); 393 _cacheSet(key, data, ttl); 394 console.log('[API Cache] MISS, cached:', action); 395 return data; 396 }; 397 398 /** 399 * Invalidate cache for a specific action (call after writes) 400 * Usage: window.gasApiCacheInvalidate('getHomeFeed'); 401 */ 402 function gasApiCacheInvalidate(action, params) { 403 if (action) { 404 const key = _cacheKey(action, params || {}); 405 localStorage.removeItem(key); 406 console.log('[API Cache] Invalidated:', action); 407 } 408 } 409 410 /** 411 * Clear all GAS API cache entries 412 */ 413 function gasApiCacheClearAll() { 414 for (let i = localStorage.length - 1; i >= 0; i--) { 415 const k = localStorage.key(i); 416 if (k && k.startsWith(CACHE_PREFIX)) localStorage.removeItem(k); 417 } 418 console.log('[API Cache] All cleared'); 419 } 420 421 // ========================================== 422 // API Proxy (Dynamic Function Creation) 423 // ========================================== 424 425 const gasApi = new Proxy({}, { 426 get: function(target, action) { 427 if (typeof action !== 'string') return undefined; 428 return function(...args) { 429 const params = convertArgsToParams(action, args); 430 return callGasApi(action, params); 431 }; 432 } 433 }); 434 435 /** 436 * í¨ì ì¸ì를 íë¼ë¯¸í° ê°ì²´ë¡ ë³í 437 */ 438 function convertArgsToParams(action, args) { 439 if (args.length === 0) return {}; 440 441 // ë¨ì¼ ê°ì²´ ì¸ìì¸ ê²½ì° ê·¸ëë¡ ì ë¬ 442 if (args.length === 1 && typeof args[0] === 'object' && args[0] !== null && !Array.isArray(args[0])) { 443 return args[0]; 444 } 445 446 // ë³µì ì¸ìì¸ ê²½ì° indexed paramsë¡ ë³í 447 const params = {}; 448 args.forEach((arg, index) => { 449 params['arg' + index] = arg; 450 }); 451 params._argsCount = args.length; 452 453 return params; 454 } 455 456 // ========================================== 457 // Backward Compatibility Layer 458 // ========================================== 459 460 /** 461 * google.script.run í¸í ë ì´ì´ 462 * .withSuccessHandler() / .withFailureHandler() ì²´ì´ë ì§ì 463 */ 464 function createCompatibilityLayer() { 465 // 매 í¸ì¶ë§ë¤ ìë¡ì´ í¸ë¤ë¬ ì¸í¸ë¥¼ ì¬ì©í기 ìí´ í©í 리 í¨í´ ì ì© 466 function createChainableProxy(sHandler, fHandler) { 467 return new Proxy({}, { 468 get: function(target, prop) { 469 if (prop === 'withSuccessHandler') { 470 return function(handler) { 471 return createChainableProxy(handler, fHandler); 472 }; 473 } 474 if (prop === 'withFailureHandler') { 475 return function(handler) { 476 return createChainableProxy(sHandler, handler); 477 }; 478 } 479 480 // ì¤ì API í¨ì í¸ì¶ 481 return function(...args) { 482 const currentSuccess = sHandler; 483 const currentFailure = fHandler; 484 485 return gasApi[prop](...args) 486 .then(result => { 487 if (currentSuccess) currentSuccess(result); 488 return result; 489 }) 490 .catch(error => { 491 if (currentFailure) { 492 currentFailure(error); 493 } else { 494 console.error('GAS API Error [' + prop + ']:', error); 495 } 496 }); 497 }; 498 } 499 }); 500 } 501 502 return createChainableProxy(null, null); 503 } 504 505 // ========================================== 506 // Export API 507 // ========================================== 508 509 window.gasApi = gasApi; 510 window.gasApiCacheInvalidate = gasApiCacheInvalidate; 511 window.gasApiCacheClearAll = gasApiCacheClearAll; 512 513 if (!window.google) window.google = {}; 514 if (!window.google.script) window.google.script = {}; 515 516 const originalGoogleScriptRun = window.google.script.run; 517 window.google.script.run = createCompatibilityLayer(); 518 window.google.script._originalRun = originalGoogleScriptRun; 519 520 console.log('â GAS API Wrapper loaded (with TTL cache)'); 521 522 window.isGasEnvironment = function() { 523 return !!window.google.script._originalRun; 524 }; 525 526})(window);
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.