PageSourceSearch

https://aya-club.pages.dev/js/api.js

js aya-club.pages.dev collected 2026-10-03 06:32:44 UTC 17,758 bytes, 526 lines download raw bytes

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.