/* ===== 컴시간알리미 시간표 (선택) ===== 나이스 대신 컴시간에서 시간표를 받는다 — 컴시간을 쓰는 학교는 «이번 주 교체»까지 반영된다. TD2(teacherdesk2/main/comci.js, 2026-08 실측)에서 옮겨 왔고, 거기서 실제로 걸린 함정을 그대로 지킨다. ① 조회 주소의 숫자(경로 코드)는 주기적으로 바뀐다 — 박아 두지 않고 /st 페이지에서 뽑는다(30분 캐시). ② 4겹 배열이라고 다 시간표가 아니다 — 교실 배열(자료245, 칸이 "601_음악실")도 같은 모양이다. 칸이 «숫자 또는 '>숫자'»인 표만 시간표로 본다. ③ «'>'가 달린 표»가 이번 주(교체 반영), 깨끗한 표가 정규다. '>' 칸의 값이 «지금 하는 수업»이다. (2026-09-11 천안서여자중 실측: 1-1 금 6·7교시 이번주 '>17032'=동아리, 정규 16032=자치) ④ 문자열 배열 중 일과시간('1(09:10)')을 교사 명단으로 집지 않게, 시간표가 가리키는 번호만큼 긴 배열만 후보로 본다. ⑤ 학교 검색 결과 끝에 붙는 «알림» 행(학교코드 0)은 학교가 아니다. ⚠ 이 파일의 함수 이름은 전부 _로 끝난다 — GAS는 그래야만 익명 웹앱에서 부를 수 없다. ⑥ 🔴 2026-11 개편(TD2 v3.104·v3.106과 같은 내용) — 이걸 몰라 11-06부터 «컴시간 주소 형식이 바뀌었습니다»로 멈췄다. · /st 페이지 스크립트가 XOR로 감싸여 있고(11-09부터 두 겹 이상) 요청 주소·키는 안쪽 층에 있다 → 다 풀어 이어 붙여 찾는다 · 응답은 `enc5::<암호문>:<서명>` — AES-256-CBC + HMAC-SHA256. GAS에는 AES가 없어 아래에 직접 짰다(복호만) · 키(16진수 64자리)는 네 토막으로 섞여 있다 — 순서까지 페이지에서 읽는다 · 학교 검색: btoa('검색어' + encodeURIComponent(학교명) + '_enc5')를 다시 encodeURIComponent (학교명이 UTF-8) · 검색 결과의 학교코드는 미끼 둘 사이에 섞여 있다 — 칸 번호 = floor(행[0] / 나눌수) + 더할수 (나눌수도 페이지에서) · 요청에 X-Hour-Token 머리글(값은 /st의 __HourRequestToken) · 갱신 시각 키가 자료244 → 자료199로 바뀜 — 모양으로 찾는다 */ var COMCI_HOST = 'http://comci.net:4082'; function comciFetchText_(url, charset, headers) { var h = { 'User-Agent': 'Mozilla/5.0' }; if (headers) Object.keys(headers).forEach(function(k) { if (headers[k]) h[k] = headers[k]; }); var res = UrlFetchApp.fetch(url, { muteHttpExceptions: true, followRedirects: true, headers: h }); var code = res.getResponseCode(); if (code < 200 || code >= 300) throw new Error('컴시간 HTTP ' + code); return res.getContentText(charset || 'UTF-8'); } /* /st 스크립트 한 겹 풀기 — 덩어리마다 [평문여부, 값]. 평문이 아니면 16진수 4자리씩 (k + 순번*13)&255와 XOR */ function comciDeobOnce_(s) { var out = ''; var re = /\(function\(a,k\)\{[\s\S]*?\}\)\((\[\[[\s\S]*?\]\]),\s*(\d+)\s*\)/g; var m; while ((m = re.exec(s))) { var a; try { a = JSON.parse(m[1]); } catch (e) { continue; } var k = Number(m[2]); for (var i = 0; i < a.length; i++) { var x = a[i]; if (!Array.isArray(x)) continue; if (x[0]) { out += String(x[1] || ''); continue; } var b = String(x[1] || ''); for (var j = 0; j + 4 <= b.length; j += 4) out += String.fromCharCode(parseInt(b.substr(j, 4), 16) ^ ((k + (j / 4) * 13) & 255)); } out += '\n'; } return out; } // 더 풀 것이 없을 때까지(최대 6겹) 풀고 모든 층을 이어 붙인다 — 층마다 든 것이 다르다 function comciDeobfuscate_(html) { var cur = String(html || ''), all = ''; for (var n = 0; n < 6; n++) { var x = comciDeobOnce_(cur); if (!x.trim()) break; all += x; cur = x; } return all; } function comciParsePaths_(html) { var s = String(html || '') + '\n' + comciDeobfuscate_(html); var p = s.match(/sc_data\('(\d+)_/); var r = s.match(/url\s*:\s*'\.\/(\d+)\?/); if (!p || !r) return null; var v = { prefix: p[1], route: r[1] }; var b = s.match(/btoa\(\s*'(\w+)'\s*\+\s*encodeURIComponent\(/); var u = s.match(/url\s*:\s*'\.\/\d+\?(\w+)'/); if (b) { v.search = b[1]; v.searchB64 = true; } else if (u) v.search = u[1]; else return null; var key = s.match(/Hex\.parse\('([0-9a-fA-F]{64})'\)/); if (key) v.key = key[1].toLowerCase(); else { var kp = s.match(/Hex\.parse\(\s*\(function\(\w+\)\{return\s*([\w\[\]\s+]+?);?\s*\}\)\(\[([^\]]*)\]\)\s*\)/); if (kp) { var parts = (kp[2].match(/'[0-9a-fA-F]*'|"[0-9a-fA-F]*"/g) || []).map(function(x) { return x.slice(1, -1); }); var order = (kp[1].match(/\[(\d+)\]/g) || []).map(function(x) { return Number(x.slice(1, -1)); }); var kk = order.map(function(i) { return parts[i] || ''; }).join(''); if (/^[0-9a-fA-F]{64}$/.test(kk)) v.key = kk.toLowerCase(); } } var tok = s.match(/__HourRequestToken\s*=\s*'([0-9A-Za-z]+)'/); if (tok) v.token = tok[1]; var suf = s.match(/\+\s*'(_enc\d+)'/); if (suf) v.suffix = suf[1]; var pick = s.match(/\[0\]\s*\/\s*(\d+)\s*\)\s*\+\s*(\d+)/); if (pick) v.pick = { div: Number(pick[1]), add: Number(pick[2]) }; /* 11-12부터 학교명·교사·과목·주 시작일·갱신 시각·검색 칸까지 글자마다 한 겹 더 감싼다 (base64 → 12바이트 키 XOR → EUC-KR, 페이지의 QnmsbChange). 키는 String.fromCharCode(…)로 페이지에 있다. */ var mk = s.match(/QnmsbChange\s*\([^)]*\)\s*\{[\s\S]{0,300}?String\.fromCharCode\(([\d,\s]+)\)/) || s.match(/String\.fromCharCode\(([\d,\s]+)\)\s*;\s*var\s+[^=;]+=\s*atob\(/); if (mk) { var m = mk[1].split(',').map(function(x) { return Number(x.trim()); }).filter(function(x) { return x >= 0 && x < 256; }); if (m.length >= 4) v.mask = m; } return v; } /* 가려진 글자 하나 풀기 — base64 모양이 아니거나, 풀어서 깨진 글자·제어 문자가 나오면 원래 글자 그대로 둔다 (평문 '1(09:10)'·'>7008'·'2023.11.06'은 건드리지 않는다). */ function comciUnmask_(t, mask) { if (!mask || !mask.length || typeof t !== 'string' || !t || t.length % 4 || !/^[A-Za-z0-9+/]+={0,2}$/.test(t)) return t; try { var b = comciU8_(Utilities.base64Decode(t)); for (var i = 0; i < b.length; i++) b[i] = b[i] ^ mask[i % mask.length]; var out = Utilities.newBlob(comciS8_(b)).getDataAsString('EUC-KR'); return (/[\x00-\x1f\x7f\ufffd]/.test(out)) ? t : out; } catch (e) { return t; } } /* 받은 시간표 자료를 통째로 푼다. 주 시작일(예전 «시작일»)도 이름이 바뀌었다(11-12 자료156) → 모양으로 찾아 옛 이름에 채운다. */ function comciUnmaskRaw_(raw, mask) { if (!raw || typeof raw !== 'object') return raw; var walk = function(v) { if (Array.isArray(v)) return v.map(walk); if (v && typeof v === 'object') { var o = {}; Object.keys(v).forEach(function(k) { o[k] = walk(v[k]); }); return o; } return comciUnmask_(v, mask); }; var d = (mask && mask.length) ? walk(raw) : raw; if (!/^\d{4}-\d{2}-\d{2}$/.test(String(d['시작일'] || ''))) { var k = Object.keys(d).filter(function(k) { return /^자료\d+$/.test(k) && typeof d[k] === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(d[k]); })[0]; if (k) d['시작일'] = d[k]; } return d; } function comciPaths_(fresh) { var cache = CacheService.getScriptCache(); if (!fresh) { try { var c = cache.get('comci_paths3'); if (c) return JSON.parse(c); } catch (e) {} } var v = comciParsePaths_(comciFetchText_(COMCI_HOST + '/st', 'EUC-KR')); if (!v) throw new Error('컴시간 주소 형식이 바뀌었습니다'); try { cache.put('comci_paths3', JSON.stringify(v), 1800); } catch (e2) {} // 실패는 저장하지 않는다 return v; } /* 들고 있던 주소·키로 실패하면 한 번만 /st를 새로 받아 다시 한다 — 키·주소가 때마다 바뀔 수 있다 */ function comciWithPaths_(job) { try { return job(comciPaths_(false)); } catch (e) { return job(comciPaths_(true)); } } function comciHdr_(P) { return P && P.token ? { 'X-Hour-Token': P.token } : null; } // 응답 뒤에 쓰레기(NUL 등)가 붙어 온다 — 마지막 '}'까지만 쓴다 function comciCutJson_(text) { var t = String(text || '').split(String.fromCharCode(0)).join(''); var i = t.lastIndexOf('}'); return i >= 0 ? t.slice(0, i + 1) : t; } /* ── AES-256-CBC 복호(PKCS7) — GAS에 AES가 없어 직접 짠 것. FIPS-197 역암호 그대로(검사: Node crypto와 대조) ── */ var COMCI_AES_ = null; function comciGmul_(a, b) { var p = 0; while (b) { if (b & 1) p ^= a; a = (a << 1) ^ (a & 128 ? 0x11b : 0); b >>= 1; } return p & 255; } function comciAesTables_() { if (COMCI_AES_) return COMCI_AES_; var exp = [], log = [], x = 1, i; for (i = 0; i < 255; i++) { exp[i] = x; log[x] = i; x = comciGmul_(x, 3); } var rotl = function(v, n) { return ((v << n) | (v >>> (8 - n))) & 255; }; var sbox = [], inv = []; for (i = 0; i < 256; i++) { var q = i ? exp[(255 - log[i]) % 255] : 0; var s = q ^ rotl(q, 1) ^ rotl(q, 2) ^ rotl(q, 3) ^ rotl(q, 4) ^ 0x63; sbox[i] = s; inv[s] = i; } COMCI_AES_ = { sbox: sbox, inv: inv }; return COMCI_AES_; } function comciAesExpand_(key) { // 32바이트 키 → 240바이트(라운드 키 15개) var T = comciAesTables_(), w = key.slice(0, 32), rcon = 1; for (var i = 8; i < 60; i++) { var t = w.slice((i - 1) * 4, i * 4); if (i % 8 === 0) { t = [T.sbox[t[1]] ^ rcon, T.sbox[t[2]], T.sbox[t[3]], T.sbox[t[0]]]; rcon = comciGmul_(rcon, 2); } else if (i % 8 === 4) { t = [T.sbox[t[0]], T.sbox[t[1]], T.sbox[t[2]], T.sbox[t[3]]]; } for (var j = 0; j < 4; j++) w.push(w[(i - 8) * 4 + j] ^ t[j]); } return w; } function comciAesDecBlock_(inp, w) { // 16바이트 한 덩어리 var T = comciAesTables_(), s = [], r, c, k; for (k = 0; k < 16; k++) s[k] = inp[k] ^ w[224 + k]; for (var round = 13; round >= 0; round--) { var t = []; for (r = 0; r < 4; r++) for (c = 0; c < 4; c++) t[r + 4 * c] = T.inv[s[r + 4 * ((c - r + 4) % 4)]]; // 역 행 밀기 + 역 치환 for (k = 0; k < 16; k++) t[k] ^= w[round * 16 + k]; if (round > 0) { for (c = 0; c < 4; c++) { var a0 = t[4 * c], a1 = t[4 * c + 1], a2 = t[4 * c + 2], a3 = t[4 * c + 3]; t[4 * c] = comciGmul_(a0, 14) ^ comciGmul_(a1, 11) ^ comciGmul_(a2, 13) ^ comciGmul_(a3, 9); t[4 * c + 1] = comciGmul_(a0, 9) ^ comciGmul_(a1, 14) ^ comciGmul_(a2, 11) ^ comciGmul_(a3, 13); t[4 * c + 2] = comciGmul_(a0, 13) ^ comciGmul_(a1, 9) ^ comciGmul_(a2, 14) ^ comciGmul_(a3, 11); t[4 * c + 3] = comciGmul_(a0, 11) ^ comciGmul_(a1, 13) ^ comciGmul_(a2, 9) ^ comciGmul_(a3, 14); } } s = t; } return s; } function comciAesCbcDecrypt_(key, iv, data) { // 모두 0~255 숫자 배열 if (key.length !== 32 || iv.length !== 16 || !data.length || data.length % 16) throw new Error('컴시간 응답 길이가 맞지 않습니다'); var w = comciAesExpand_(key), out = [], prev = iv; for (var i = 0; i < data.length; i += 16) { var blk = data.slice(i, i + 16), dec = comciAesDecBlock_(blk, w); for (var j = 0; j < 16; j++) out.push(dec[j] ^ prev[j]); prev = blk; } var pad = out[out.length - 1]; if (!(pad >= 1 && pad <= 16)) throw new Error('컴시간 응답을 풀지 못했습니다'); for (var p = out.length - pad; p < out.length; p++) if (out[p] !== pad) throw new Error('컴시간 응답을 풀지 못했습니다'); return out.slice(0, out.length - pad); } function comciU8_(bytes) { return Array.prototype.map.call(bytes, function(b) { return b & 255; }); } function comciS8_(arr) { return arr.map(function(b) { return b > 127 ? b - 256 : b; }); } function comciHexBytes_(h) { var o = []; for (var i = 0; i + 2 <= h.length; i += 2) o.push(parseInt(h.substr(i, 2), 16)); return o; } function comciBytesHex_(a) { return a.map(function(b) { return (b < 16 ? '0' : '') + b.toString(16); }).join(''); } /* 새 응답 «enc숫자::<암호문>:<서명>» (enc1=16진수, 그 밖=base64)을 푼다. 서명은 HMAC-SHA256(키=SHA256(키), 글=앞 세 덩어리를 ':'로 이은 것). 안 맞으면 풀지 않고 오류 — 엉뚱한 시간표보다 실패가 낫다. 'enc'로 시작하지 않으면 예전 평문 응답이라 그대로 돌려준다. */ function comciDecrypt_(text, keyHex) { var t = String(text || '').replace(/\0+$/, ''); if (!/^enc\d+:/.test(t)) return t; if (!keyHex) throw new Error('컴시간 응답을 풀 키를 찾지 못했습니다'); var parts = t.split(':'); if (parts.length !== 4) throw new Error('컴시간 응답 형식이 바뀌었습니다'); var hex = parts[0] === 'enc1'; var dec = function(s) { return hex ? comciHexBytes_(s) : comciU8_(Utilities.base64Decode(s)); }; var key = comciHexBytes_(keyHex); var macKey = Utilities.computeDigest(Utilities.DigestAlgorithm.SHA_256, comciS8_(key)); var msg = Utilities.newBlob(parts.slice(0, 3).join(':')).getBytes(); var tag = comciU8_(Utilities.computeHmacSignature(Utilities.MacAlgorithm.HMAC_SHA_256, msg, macKey)); var want = hex ? comciBytesHex_(tag) : Utilities.base64Encode(comciS8_(tag)); if (want !== parts[3]) throw new Error('컴시간 응답 서명이 맞지 않습니다'); var plain = comciAesCbcDecrypt_(key, dec(parts[1]), dec(parts[2])); return Utilities.newBlob(comciS8_(plain)).getDataAsString('UTF-8').replace(/\0+$/, ''); } // 예전 방식(11-06 전) 학교명 검색은 EUC-KR 퍼센트 인코딩 — 컴시간이 되돌아가도 살게 남겨 둔다 function comciEucPercent_(s) { var bytes = Utilities.newBlob('').setDataFromString(String(s || ''), 'EUC-KR').getBytes(); var out = ''; for (var i = 0; i < bytes.length; i++) { var b = bytes[i] & 255; var ch = String.fromCharCode(b); if (b < 128 && /[A-Za-z0-9._~-]/.test(ch)) out += ch; else out += '%' + (b < 16 ? '0' : '') + b.toString(16).toUpperCase(); } return out; } /* 예전: [내부번호, 지역, 학교명, 학교코드] · 2026-11부터: [내부번호, 지역, 학교명, 숫자, 숫자, 숫자] — 진짜는 하나(pick). 코드 자체는 예전과 같다 — 설정 시트에 이미 적힌 컴시간 코드는 그대로 쓴다. */ function comciParseSearch_(text, pick, mask) { var j; try { j = JSON.parse(comciCutJson_(text)); } catch (e) { return []; } var list = j && j['학교검색']; if (!Array.isArray(list)) return []; var codeOf = function(x) { if (pick && pick.div > 0 && x.length > 4) return x[Math.floor((Number(x[0]) || 0) / pick.div) + pick.add]; return x[3]; }; return list .filter(function(x) { return Array.isArray(x) && x.length >= 4; }) // 11-12부터 지역·학교명·코드 칸이 가려져 온다(코드는 글자 '75883'). 내부번호[0]는 숫자 그대로 .map(function(x) { return x.map(function(c, i) { return i ? comciUnmask_(c, mask) : c; }); }) .map(function(x) { var c = Number(codeOf(x)); return { region: String(x[1] || ''), name: String(x[2] || ''), code: c > 0 ? c : 0 }; }) .filter(function(x) { return x.code > 0 && x.region !== '알림'; }); } function comciSearch_(name) { return comciWithPaths_(function(P) { var url = P.searchB64 ? COMCI_HOST + '/' + P.route + '?' + encodeURIComponent(Utilities.base64Encode(P.search + encodeURIComponent(String(name || '')) + (P.suffix || ''))) : COMCI_HOST + '/' + P.route + '?' + P.search + comciEucPercent_(name) + (P.suffix || ''); return comciParseSearch_(comciDecrypt_(comciFetchText_(url, 'UTF-8', comciHdr_(P)), P.key), P.pick, P.mask); }); } function comciFetchRaw_(code) { return comciWithPaths_(function(P) { var q = Utilities.base64Encode(P.prefix + '_' + code + '_0_1' + (P.suffix || '')); return comciUnmaskRaw_(JSON.parse(comciCutJson_(comciDecrypt_(comciFetchText_(COMCI_HOST + '/' + P.route + '?' + q, 'UTF-8', comciHdr_(P)), P.key))), P.mask); }); } // 갱신 시각 — 예전 자료244, 2026-11부터 자료199. 이름이 아니라 «2026-11-06 08:47:57» 모양으로 찾는다 function comciUpdated_(raw) { if (raw && typeof raw['자료244'] === 'string' && raw['자료244']) return raw['자료244']; var ks = Object.keys(raw || {}); for (var i = 0; i < ks.length; i++) { var v = raw[ks[i]]; if (/^자료\d+$/.test(ks[i]) && typeof v === 'string' && /^\d{4}-\d{2}-\d{2} \d{1,2}:\d{2}/.test(v)) return v; } return ''; } /* ── 응답에서 필요한 표 고르기 — 키 이름의 숫자(자료481 등)는 바뀔 수 있어 «모양»으로 찾는다 ── */ function comciIsTT_(v) { return Array.isArray(v) && v.length > 1 && Array.isArray(v[1]) && Array.isArray(v[1][1]) && Array.isArray(v[1][1][1]); } function comciScan_(v) { var num = 0, marked = 0, other = 0; for (var g = 1; g < v.length; g++) { var gv = v[g] || []; for (var c = 1; c < gv.length; c++) { var cv = gv[c] || []; for (var w = 1; w < cv.length; w++) { var row = cv[w]; if (!Array.isArray(row)) continue; for (var p = 1; p < row.length; p++) { var x = row[p]; if (typeof x === 'number') { if (x) num++; } else if (typeof x === 'string' && /^>\d+$/.test(x)) marked++; else if (typeof x === 'string' && /^\d+$/.test(x)) num++; else if (x) other++; } } } } return { num: num, marked: marked, other: other }; } function comciDecode_(v, sep) { if (typeof v === 'number' && v > 0) return { subj: Math.floor(v / sep), teach: v % sep, marked: false }; if (typeof v === 'string') { var m = v.match(/^(>?)(\d+)$/); if (m && Number(m[2]) > 0) { var n = Number(m[2]); return { subj: Math.floor(n / sep), teach: n % sep, marked: m[1] === '>' }; } } return null; } function comciPick_(raw) { var keys = Object.keys(raw || {}); var sep = Number(raw && raw['분리']) || 1000; var tts = []; keys.forEach(function(k) { if (!comciIsTT_(raw[k])) return; var s = comciScan_(raw[k]); // 칸 하나가 이상하다고 표 전체를 버리지 않는다 — 교실 배열처럼 숫자 칸이 거의 없는 것만 거른다 if ((s.num + s.marked) > 0 && s.other * 5 < (s.num + s.marked)) tts.push({ k: k, s: s }); }); var week = null, regular = null; var marked = tts.filter(function(t) { return t.s.marked > 0; }).sort(function(a, b) { return b.s.marked - a.s.marked; }); var clean = tts.filter(function(t) { return t.s.marked === 0; }); if (marked.length) { week = raw[marked[0].k]; regular = clean.length ? raw[clean[0].k] : null; } else if (tts.length) { week = raw[tts[0].k]; regular = null; } // 이번 주 교체가 하나도 없다 — 두 표가 같다 // 교사·과목 — 시간표가 실제로 가리키는 번호를 먼저 재고, 그만큼 긴 배열만 후보로 본다 var maxSubj = 0, maxTeach = 0; if (week) { for (var g = 1; g < week.length; g++) { var gv = week[g] || []; for (var c = 1; c < gv.length; c++) { var cv = gv[c] || []; for (var w = 1; w < cv.length; w++) { var row = cv[w]; if (!Array.isArray(row)) continue; for (var p = 1; p < row.length; p++) { var cell = comciDecode_(row[p], sep); if (!cell) continue; if (cell.subj > maxSubj) maxSubj = cell.subj; if (cell.teach > maxTeach) maxTeach = cell.teach; } } } } } var isStrArr = function(v) { return Array.isArray(v) && v.length > 2 && v.filter(function(x) { return typeof x === 'string'; }).length >= Math.floor(v.length / 2); }; var cand = keys.filter(function(k) { return isStrArr(raw[k]); }); var fits = function(n) { return cand.filter(function(k) { return raw[k].length > n; }); }; var subjects = fits(maxSubj).filter(function(k) { return typeof raw[k][0] === 'number'; })[0]; var teachers = fits(maxTeach).filter(function(k) { return k !== subjects && typeof raw[k][0] !== 'number'; })[0]; if (!subjects) subjects = fits(maxSubj)[0]; if (!teachers) teachers = fits(maxTeach).filter(function(k) { return k !== subjects; })[0]; return { week: week, regular: regular, sep: sep, subjects: (subjects && raw[subjects]) || [], teachers: (teachers && raw[teachers]) || [] }; } // 컴시간 «시작일»(이번 주 월요일, "2026-09-07") → 그날 0시(스크립트 시간대) function comciStartDate_(raw) { var m = String((raw && raw['시작일']) || '').match(/^(\d{4})-(\d{1,2})-(\d{1,2})/); if (!m) return null; return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])); } /* 한 반의 이번 주 시간표 — 나이스 결과와 같은 모양 { 'yyyyMMdd': [{period, subject}] }. 교체된 칸에는 changed/orig(원래 과목)/teacher/origTeacher가 더 붙는다 — 옛 칠판 앱은 모르는 칸이라 무시한다. */ function comciClassWeek_(raw, grade, cls) { var pk = comciPick_(raw); var sep = pk.sep; var days = {}, unread = 0; var room = pk.week && pk.week[grade] && pk.week[grade][cls]; var start = comciStartDate_(raw); if (!room || !start) return { days: days, unread: 0, found: false }; var reg = pk.regular && pk.regular[grade] && pk.regular[grade][cls]; for (var w = 1; w <= 5; w++) { var row = room[w]; if (!Array.isArray(row)) continue; var d = new Date(start.getTime()); d.setDate(d.getDate() + (w - 1)); var ymd = Utilities.formatDate(d, 'Asia/Seoul', 'yyyyMMdd'); var per = typeof row[0] === 'number' ? row[0] : row.length - 1; var list = []; for (var p = 1; p <= per; p++) { var v = row[p]; var cell = comciDecode_(v, sep); if (v && !cell) { unread++; continue; } // 해석 못 한 칸은 세어 둔다(조용히 버리지 않는다) if (!cell) continue; var subj = comciClean_(pk.subjects[cell.subj]); if (!subj) continue; var item = { period: p, subject: subj }; if (cell.marked) { var regRow = reg && reg[w]; var was = Array.isArray(regRow) ? comciDecode_(regRow[p], sep) : null; item.changed = true; item.orig = was ? comciClean_(pk.subjects[was.subj]) : ''; item.teacher = comciClean_(pk.teachers[cell.teach]); item.origTeacher = was ? comciClean_(pk.teachers[was.teach]) : ''; } list.push(item); } days[ymd] = list; } return { days: days, unread: unread, found: true }; } /* 과목·교사 이름 — 컴시간은 암호화 없는 http라 중간에서 바뀔 수 있고, 옛 칠판 앱(v1.1.9)은 과목명을 innerHTML로 그린다. 꺾쇠·따옴표·&를 전각으로 바꾸고 30자로 자른다(서버에서 막아야 옛 앱까지 안전하다). */ function comciClean_(s) { var map = { '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' }; return String(s == null ? '' : s).replace(/[<>&"']/g, function(ch) { return map[ch]; }).trim().slice(0, 30); } /* ── 캐시 조각 저장 — 컴시간 원본은 100KB(캐시 한 칸 한계)를 넘을 수 있다 ── */ function comciChunkGet_(cache, key) { try { var n = parseInt(cache.get(key + '_n'), 10); if (!(n > 0 && n < 30)) return ''; var names = []; for (var i = 0; i < n; i++) names.push(key + '_' + i); var got = cache.getAll(names), out = ''; for (var j = 0; j < n; j++) { if (got[names[j]] == null) return ''; out += got[names[j]]; } return out; } catch (e) { return ''; } } function comciChunkPut_(cache, key, text, ttl) { try { var size = 30000, n = Math.ceil(text.length / size); // 한도는 바이트 — 한글(3바이트)이 섞여도 100KB를 안 넘게 3만 자씩 if (!(n > 0 && n < 30)) return; var map = {}; for (var i = 0; i < n; i++) map[key + '_' + i] = text.substr(i * size, size); cache.putAll(map, ttl); cache.put(key + '_n', String(n), ttl); // 조각을 다 넣은 «뒤에» 개수를 적는다 } catch (e) {} } function comciRawCached_(code) { var cache = CacheService.getScriptCache(); var key = 'comci_raw2_' + code; var txt = comciChunkGet_(cache, key); if (txt) { try { return JSON.parse(txt); } catch (e) {} } var raw = comciFetchRaw_(code); comciChunkPut_(cache, key, JSON.stringify(raw), 600); // 10분 — 교체는 그날그날 바뀐다 return raw; } /* 부르는 쪽(시간표 조회)이 쓰는 입구. 못 받으면 null → 부르는 쪽이 나이스로 간다. 반환: { start:'2026-09-07', days:{ymd:[...]}, unread, updated } */ function comciTimetable_(code, grade, cls) { // parseInt는 '1&x=1'도 1로 읽는다 — 숫자로만 된 값만 받는다(익명 요청이 캐시 키·주소를 오염시키지 못하게) var digits = function(v) { return /^\d{1,9}$/.test(String(v == null ? '' : v).trim()); }; if (!digits(code) || !digits(grade) || !digits(cls)) return null; code = Number(String(code).trim()); grade = Number(String(grade).trim()); cls = Number(String(cls).trim()); if (!(code > 0) || !(grade > 0 && grade < 20) || !(cls > 0 && cls < 100)) return null; var cache = CacheService.getScriptCache(); var ck = 'ct2_' + code + '_' + grade + '_' + cls; try { var hit = cache.get(ck); if (hit) return JSON.parse(hit); } catch (e) {} var lgKey = 'lg_ct1_' + code + '_' + grade + '_' + cls; var failKey = 'comci_fail_' + code; try { if (cache.get(failKey)) return lgGet_(lgKey, null); } catch (e0) {} try { var raw = comciRawCached_(code); var r = comciClassWeek_(raw, grade, cls); if (!r.found) return null; // 그 반이 컴시간에 없다 var val = { start: String(raw['시작일'] || ''), days: r.days, unread: r.unread, updated: comciUpdated_(raw) }; try { cache.put(ck, JSON.stringify(val), 900); } catch (e1) {} lgSet_(lgKey, val); return val; } catch (e) { try { cache.put(failKey, '1', 60); } catch (e2) {} // 실패는 캐시하지 않고, 1분만 두드리지 않는다 return lgGet_(lgKey, null); } }