UNPKG

samga

Version:

CLI для samga.nis: профиль, журнал, табель с авторизацией

594 lines (546 loc) 24.2 kB
#!/usr/bin/env node import fs from 'fs' import path from 'path' import readline from 'readline' import axios from 'axios' import https from 'https' import { fileURLToPath } from 'url' import { spawn } from 'child_process' const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const STORE = path.join(process.cwd(), '.samga.nis.json') // Colors const BLUE = '\x1b[38;2;37;99;235m' const CYAN = '\x1b[38;2;56;189;248m' const LIGHT = '\x1b[38;2;165;243;252m' const GREEN = '\x1b[38;2;34;197;94m' const DIM = '\x1b[2m' const RESET = '\x1b[0m' const agent = new https.Agent({ rejectUnauthorized: false }) const client = axios.create({ httpsAgent: agent, timeout: 30000 }) client.interceptors.request.use((config) => { config.headers['user-agent'] = 'Dart/3.1 (dart:io)' config.headers.cookie = 'Culture=ru-RU;' return config }) const LOGIN = 'https://identity.micros.nis.edu.kz/v1/Users/Authenticate' const USER_INFO = 'https://contingent.micros.nis.edu.kz/Api/AdditionalUserInfo' const REPORT_CARD = 'https://reportcard.micros.nis.edu.kz/v1/ReportCard/GetAllReportCardsAsync' const GET_JOURNAL = (city) => `https://sms.${city}.nis.edu.kz/jce/Api/Api/GetSubjectsAndPeriods` const GET_RUBRIC = (city) => `https://sms.${city}.nis.edu.kz/jce/Api/Api/GetDataBySectionAndByPeriod` function prompt(query) { const rl = readline.createInterface({ input: process.stdin, output: process.stdout }) return new Promise((resolve) => rl.question(query, (ans) => { rl.close(); resolve(ans) })) } function banner() { console.clear() const art = [ `${BLUE} ██████ ▄▄▄ ███▄ ▄███▓ ▄████ ▄▄▄ ${CYAN}v2.3${RESET}`, `${BLUE}▒██ ▒ ▒████▄ ▓██▒▀█▀ ██▒ ██▒ ▀█▒▒████▄ `, `${BLUE}░ ▓██▄ ▒██ ▀█▄ ▓██ ▓██░▒██░▄▄▄░▒██ ▀█▄ ${CYAN}SAMGA${RESET}`, `${BLUE} ▒ ██▒░██▄▄▄▄██ ▒██ ▒██ ░▓█ ██▓░██▄▄▄▄██ `, `${BLUE}▒██████▒▒ ▓█ ▓██▒▒██▒ ░██▒░▒▓███▀▒ ▓█ ▓██▒`, `${BLUE}▒ ▒▓▒ ▒ ░ ▒▒ ▓▒█░░ ▒░ ░ ░ ░▒ ▒ ▒▒ ▓▒█░`, `${BLUE}░ ░▒ ░ ░ ▒ ▒▒ ░░ ░ ░ ░ ░ ▒ ▒▒ ░`, `${BLUE}░ ░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ `, `${BLUE} ░ ░ ░ ░ ░ ░ ░${RESET}`, ].join('\n') console.log(art) console.log(`${DIM}CLI v2.3 • samga.top — вход, профиль, журнал, табель, настройки${RESET}\n`) } function footer() { console.log(`\n${DIM}Создано qynon — нажмите K, чтобы открыть Telegram${RESET}`) } function openUrl(url) { const platform = process.platform if (platform === 'win32') spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' }) else if (platform === 'darwin') spawn('open', [url], { detached: true, stdio: 'ignore' }) else spawn('xdg-open', [url], { detached: true, stdio: 'ignore' }) } async function ensureProfileCache(context, store) { if (store?.profile?.fio && store?.profile?.klass && store?.profile?.school && store?.profile?.city && store?.profile?.studentId) return store const add = await getAdditionalUserInfo(context.accessToken) const { userInfo } = decodeJWT(context.accessToken) const schoolGid = add?.data?.School?.Gid || null const city = deriveCity(context.applications, schoolGid) const fio = userInfo?.FullName || `${userInfo?.LastName || ''} ${userInfo?.FirstName || ''}`.trim() const klass = add?.data?.Klass || userInfo?.Klass || '—' const school = add?.data?.School?.Name?.ru || '—' store.profile = { fio, klass, school, city, studentId: userInfo?.PersonGid } store.city = store.profile.city store.studentId = store.profile.studentId saveStore(store) return store } function printProfile(store) { const p = store?.profile || {} console.log(`${CYAN}Профиль${RESET}`) console.log(`ФИО: ${LIGHT}${p.fio || '—'}${RESET}`) console.log(`Класс: ${LIGHT}${p.klass || '—'}${RESET}`) console.log(`Школа/Город: ${LIGHT}${p.school || '—'}${RESET} ${DIM}(city: ${p.city || '—'})${RESET}`) } async function renderHeader(context, store) { banner() await ensureProfileCache(context, store) printProfile(store) } function inlineStatus(msg) { process.stdout.write(`\r${msg}${' '.repeat(60)}`) } async function authenticate(iin, password) { const { data } = await client.request({ method: 'post', url: LOGIN, data: { action: 'v1/Users/Authenticate', operationId: cryptoRandomId(), username: iin, password, deviceInfo: 'SM-G950F', }, }) return data // { accessToken, refreshToken, applications } } async function getAdditionalUserInfo(accessToken) { const { data } = await client.request({ method: 'post', url: USER_INFO, headers: { Authorization: `Bearer ${accessToken}` }, data: { applicationType: 'ContingentAPI', action: 'Api/AdditionalUserInfo', operationId: cryptoRandomId() }, }) return data } async function getReports(accessToken, studentId) { const { data } = await client.request({ method: 'post', url: REPORT_CARD, headers: { Authorization: `Bearer ${accessToken}` }, data: { action: 'v1/ReportCard/GetAllReportCardsAsync', operationId: cryptoRandomId(), studentId }, }) return data } async function getJournal(accessToken, city, studentId) { const { data } = await client.request({ method: 'post', url: GET_JOURNAL(city), headers: { Authorization: `Bearer ${accessToken}` }, data: { action: 'Api/GetSubjectsAndPeriods', operationId: cryptoRandomId(), studentId }, }) return data } async function getRubric(accessToken, city, studentId, subjectId, quarter) { const { data } = await client.request({ method: 'post', url: GET_RUBRIC(city), headers: { Authorization: `Bearer ${accessToken}` }, data: { action: 'Api/GetDataBySectionAndByPeriod', operationId: cryptoRandomId(), studentId, subjectId, periodIndex: quarter, }, }) return data } function cryptoRandomId() { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => { const r = (Math.random() * 16) | 0 const v = c === 'x' ? r : (r & 0x3) | 0x8 return v.toString(16) }) } function saveStore(obj) { fs.writeFileSync(STORE, JSON.stringify(obj, null, 2)) } function loadStore() { if (!fs.existsSync(STORE)) return null try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch { return null } } function b64urlDecode(str) { str = str.replace(/-/g, '+').replace(/_/g, '/') const pad = str.length % 4 === 2 ? '==' : str.length % 4 === 3 ? '=' : '' return Buffer.from(str + pad, 'base64').toString('utf8') } function decodeJWT(token) { try { const [, payload] = token.split('.') const json = JSON.parse(b64urlDecode(payload)) if (json.UserInfo) { try { const ui = JSON.parse(json.UserInfo) return { payload: json, userInfo: ui } } catch { return { payload: json, userInfo: null } } } return { payload: json, userInfo: null } } catch { return { payload: null, userInfo: null } } } function deriveCity(applications, schoolGid) { if (!Array.isArray(applications)) return null const candidate = applications.find((a) => a.type === 52 && a.organizationGid === schoolGid) || applications.find((a) => a.type === 52) if (!candidate?.url) return null // url: https://sms.(city).nis.edu.kz/... const parts = candidate.url.split('.') return parts[1] || null } function gradeColor(v) { if (v == null) return RESET if (v >= 85) return GREEN if (v >= 65) return CYAN return RESET } function quarterLabel(n) { const roman = ['I', 'II', 'III', 'IV'][n - 1] || String(n) return `${roman} четверть` } function enableRaw(onData) { const stdin = process.stdin stdin.setRawMode && stdin.setRawMode(true) stdin.resume() stdin.on('data', onData) return () => { stdin.off('data', onData) stdin.setRawMode && stdin.setRawMode(false) stdin.pause() } } async function selectIndexArrows(items, startIdx, title) { let idx = Math.max(0, Math.min(items.length - 1, startIdx || 0)) return await new Promise((resolve) => { const render = () => { console.clear(); banner() if (title) console.log(title) items.forEach((it, i) => { console.log(`${i === idx ? '>' : ' '} ${it}`) }) console.log(`\nСтрелки ←/→/↑/↓, Enter — выбрать, B — назад`) } const onData = (buf) => { const s = buf.toString() if (s === '\r' || s === '\n') { cleanup(); resolve({ idx, back: false }) } else if (s === '\u0003') { cleanup(); resolve({ idx, back: true }) } else if (s.toLowerCase() === 'b') { cleanup(); resolve({ idx, back: true }) } else if (s === '\u001b[A' || s === '\u001b[D') { idx = (idx - 1 + items.length) % items.length; render() } else if (s === '\u001b[B' || s === '\u001b[C') { idx = (idx + 1) % items.length; render() } } const cleanup = enableRaw(onData) render() }) } async function selectQuarterArrows(startQuarter) { let q = Math.max(1, Math.min(4, startQuarter || 1)) return await new Promise((resolve) => { const render = () => { console.clear(); banner() console.log(`${CYAN}Журнал — ${quarterLabel(q)}${RESET}`) console.log(`\nВыбор четверти: [${q === 1 ? '>' : ' '} I] [${q === 2 ? '>' : ' '} II] [${q === 3 ? '>' : ' '} III] [${q === 4 ? '>' : ' '} IV]`) console.log(`\nСтрелки ←/→, Enter — выбрать, B — назад`) } const onData = (buf) => { const s = buf.toString() if (s === '\r' || s === '\n') { cleanup(); resolve({ quarter: q, back: false }) } else if (s.toLowerCase() === 'b' || s === '\u0003') { cleanup(); resolve({ quarter: q, back: true }) } else if (s === '\u001b[D') { q = q === 1 ? 4 : q - 1; render() } else if (s === '\u001b[C') { q = q === 4 ? 1 : q + 1; render() } } const cleanup = enableRaw(onData) render() }) } async function showProfile(context) { const { accessToken, applications } = context const add = await getAdditionalUserInfo(accessToken) const { userInfo } = decodeJWT(accessToken) const schoolGid = add?.data?.School?.Gid || null const city = deriveCity(applications, schoolGid) const fio = userInfo?.FullName || `${userInfo?.LastName || ''} ${userInfo?.FirstName || ''}`.trim() const klass = add?.data?.Klass || userInfo?.Klass || '—' const school = add?.data?.School?.Name?.ru || '—' console.log(`${CYAN}Профиль${RESET}`) console.log(`ФИО: ${LIGHT}${fio || '—'}${RESET}`) console.log(`Класс: ${LIGHT}${klass}${RESET}`) console.log(`Школа/Город: ${LIGHT}${school}${RESET} ${DIM}(city: ${city || '—'})${RESET}`) return { city, studentId: userInfo?.PersonGid } } async function showJournalTerm(context, city, studentId, termNumber) { if (!city || !studentId) { console.log('Нет данных для журнала'); return } const data = await getJournal(context.accessToken, city, studentId) const term = (Array.isArray(data) ? data : []).find((t) => t.number === termNumber) || (Array.isArray(data) ? data : [])[0] console.log(`\n${CYAN}Журнал — ${quarterLabel(term?.number || termNumber)}${RESET}`) if (!term) { console.log('Нет данных'); return } term.subjects.forEach((s) => { const color = gradeColor(s.currScore) const subj = (s.name?.ru || '—').slice(0, 24).padEnd(24, ' ') console.log(` ${subj} ${color}${(s.currScore ?? 0).toString().padStart(3, ' ')}${RESET}${DIM}${s.mark ? ` итог: ${s.mark}` : ''}${RESET}`) }) return term } async function showJournalFlow(context, city, studentId, startQuarter) { let { quarter, back } = await selectQuarterArrows(startQuarter || 1) if (back) return null while (true) { console.clear(); await renderHeader(context, { ...global.storeRef }) const term = await showJournalTerm(context, city, studentId, quarter) // выбор предмета стрелками const items = (term?.subjects || []).map((s) => { const name = (s.name?.ru || '—') const perc = typeof s.currScore === 'number' ? `${s.currScore}%` : '—' return `${name}${perc}` }) console.log(`\nВыберите предмет (стрелки, Enter) или [B] назад. [←/→] Сменить четверть`) footer() const res = await new Promise((resolve) => { let idx = 0 const render = () => { console.clear(); renderHeader(context, { ...global.storeRef }).then(() => { console.log(`${CYAN}Журнал — ${quarterLabel(quarter)}${RESET}`) items.forEach((t, i) => console.log(`${i === idx ? '>' : ' '} ${t}`)) console.log(`\nСтрелки ↑/↓, Enter — открыть предмет, B — назад, ←/→ — четверть`) footer() }) } const onData = (buf) => { const s = buf.toString() if (s === '\r' || s === '\n') { cleanup(); resolve({ type: 'open', idx }) } else if (s.toLowerCase() === 'b') { cleanup(); resolve({ type: 'back' }) } else if (s.toLowerCase() === 'k') { openUrl('https://t.me/qynon') } else if (s === '\u001b[A') { idx = (idx - 1 + items.length) % items.length; render() } else if (s === '\u001b[B') { idx = (idx + 1) % items.length; render() } else if (s === '\u001b[D') { quarter = quarter === 1 ? 4 : quarter - 1; cleanup(); resolve({ type: 'rerender' }) } else if (s === '\u001b[C') { quarter = quarter === 4 ? 1 : quarter + 1; cleanup(); resolve({ type: 'rerender' }) } } const cleanup = enableRaw(onData) render() }) if (res?.type === 'back') break if (res?.type === 'rerender') continue if (typeof res?.idx === 'number') { await showRubricFlow(context, city, studentId, term, res.idx, quarter) } } return quarter } function calcRubric(list = []) { let mark = 0, max = 0 for (const r of list) { const m = Number(r?.mark ?? 0) const mx = Number(r?.maxMark ?? 0) mark += m; max += mx } const pct = max > 0 ? Math.round((mark / max) * 10000) / 100 : 0 return { mark, max, pct } } async function showRubricFlow(context, city, studentId, term, startIdx, quarter) { if (!term) return let idx = startIdx || 0 while (true) { const subject = term.subjects[idx] const subjectId = subject?.id const subjectName = subject?.name?.ru || '—' const rubric = await getRubric(context.accessToken, city, studentId, subjectId, quarter) const sor = calcRubric(rubric?.sumChapterCriteria) const soch = calcRubric(rubric?.sumQuarterCriteria) const totalPct = Math.round((sor.pct + soch.pct) * 100) / 100 // как на сайте console.clear(); await renderHeader(context, { ...global.storeRef }) console.log(`${CYAN}${subjectName}${RESET}${quarterLabel(quarter)}`) console.log(`Итого: ${LIGHT}${totalPct}%${RESET}`) console.log(`\nСОР: ${sor.mark}/${sor.max}${sor.pct}%`) for (const r of (rubric?.sumChapterCriteria || [])) { console.log(` ${(r?.title?.ru || '—').slice(0, 40).padEnd(40, ' ')} ${LIGHT}${r?.mark}/${r?.maxMark}${RESET}`) } if ((rubric?.sumQuarterCriteria || []).length) { console.log(`\nСОЧ: ${soch.mark}/${soch.max}${soch.pct}%`) for (const r of (rubric?.sumQuarterCriteria || [])) { console.log(` ${(r?.title?.ru || '—').slice(0, 40).padEnd(40, ' ')} ${LIGHT}${r?.mark}/${r?.maxMark}${RESET}`) } } console.log(`\nНавигация: [←/→] Предмет [B] Назад`) footer() const nav = await new Promise((resolve) => { const onData = (buf) => { const s = buf.toString() if (s.toLowerCase() === 'b') { cleanup(); resolve('back') } else if (s.toLowerCase() === 'k') { openUrl('https://t.me/qynon') } else if (s === '\u001b[D') { idx = idx === 0 ? term.subjects.length - 1 : idx - 1; cleanup(); resolve('rerender') } else if (s === '\u001b[C') { idx = (idx + 1) % term.subjects.length; cleanup(); resolve('rerender') } } const cleanup = enableRaw(onData) }) if (nav === 'back') break } } function computeGpaFromReport(report) { const nums = [] for (const row of report?.reportCard || []) { const markStr = row?.yearMark?.ru || row?.resultMark?.ru || row?.secondHalfYearMark?.ru || row?.firstHalfYearMark?.ru || null if (!markStr) continue const n = parseFloat(String(markStr).replace(',', '.')) if (!Number.isNaN(n)) nums.push(n) } if (!nums.length) return null const avg = nums.reduce((a, b) => a + b, 0) / nums.length return Math.round(avg * 100) / 100 } function toText(markObj) { return markObj && typeof markObj === 'object' ? (markObj.ru ?? markObj.en ?? markObj.kk ?? '—') : (markObj ?? '—') } function toNum(text) { if (text == null) return null const n = parseFloat(String(text).replace(',', '.')) return Number.isNaN(n) ? null : n } function subjectNumeric(row) { // Приоритет: годовая/итог → полугодия → среднее по четвертям const year = toNum(toText(row?.yearMark) || toText(row?.resultMark)) if (year != null) return year const h1 = toNum(toText(row?.firstHalfYearMark)) const h2 = toNum(toText(row?.secondHalfYearMark)) const halves = [h1, h2].filter((x) => x != null) if (halves.length) return halves.reduce((a, b) => a + b, 0) / halves.length const p = [row?.firstPeriod, row?.secondPeriod, row?.thirdPeriod, row?.fourthPeriod] .map((m) => toNum(toText(m))) .filter((x) => x != null) if (p.length) return p.reduce((a, b) => a + b, 0) / p.length return null } async function showReportsFlow(context, studentId) { const reports = await getReports(context.accessToken, studentId) if (!Array.isArray(reports) || !reports.length) { console.log('Нет доступных табелей'); return } let idx = 0 while (true) { const options = reports.map((rc, i) => rc?.schoolYear?.name?.ru || `Год ${i + 1}`) const sel = await selectIndexArrows(options, idx, `${CYAN}Выбор года для табеля${RESET}`) if (sel.back) break idx = sel.idx console.clear(); await renderHeader(context, { ...global.storeRef }) const rc = reports[idx] const yearName = rc?.schoolYear?.name?.ru || 'Учебный год' console.log(`${CYAN}Табель — ${yearName}${RESET}`) // Шапка: I..IV, затем Год, Экз console.log(DIM + ' ' + 'Предмет'.padEnd(20, ' ') + 'I II III IV Год Экз' + RESET) rc?.reportCard?.forEach((row) => { const name = (row?.subject?.name?.ru || '—').slice(0, 20).padEnd(20, ' ') const p1 = toText(row?.firstPeriod).toString().padEnd(3, ' ') const p2 = toText(row?.secondPeriod).toString().padEnd(3, ' ') const p3 = toText(row?.thirdPeriod).toString().padEnd(3, ' ') const p4 = toText(row?.fourthPeriod).toString().padEnd(3, ' ') const yr = toText(row?.yearMark || row?.resultMark).toString().padEnd(3, ' ') const ex = toText(row?.examMark).toString().padEnd(3, ' ') console.log(` ${name} ${LIGHT}${p1}${RESET} ${LIGHT}${p2}${RESET} ${LIGHT}${p3}${RESET} ${LIGHT}${p4}${RESET} ${LIGHT}${yr}${RESET} ${LIGHT}${ex}${RESET}`) }) // GPA const nums = [] for (const row of (rc?.reportCard || [])) { const n = subjectNumeric(row); if (n != null) nums.push(n) } const gpa = nums.length ? Math.round((nums.reduce((a,b)=>a+b,0)/nums.length) * 100) / 100 : null console.log(`\nGPA: ${gpa != null ? GREEN + gpa.toFixed(2) + RESET : '—'}`) console.log(`\nНавигация: [Enter] Выбрать другой год [B] Назад`) footer() const nav = (await prompt('Выбор: ')).trim().toLowerCase() if (nav === 'b') break if (nav === 'k') openUrl('https://t.me/qynon') } } async function settingsMenu(context, store) { const currGpa = store?.settings?.gpaSystem || '5' const currSort = store?.settings?.sort || 'score-down' while (true) { console.clear(); await renderHeader(context, store) const options = [ `GPA система: ${LIGHT}${store?.settings?.gpaSystem || currGpa}${RESET}`, `Сортировка: ${LIGHT}${store?.settings?.sort || currSort}${RESET}`, 'Назад', ] const sel = await selectIndexArrows(options, 0, `${CYAN}Настройки${RESET}`) if (sel.back || sel.idx === 2) break if (sel.idx === 0) { const opt = await selectIndexArrows(['5', '4'], (store?.settings?.gpaSystem === '4') ? 1 : 0, `${CYAN}GPA система${RESET}`) if (!opt.back) { store.settings = store.settings || {} store.settings.gpaSystem = opt.idx === 1 ? '4' : '5' saveStore(store) } } else if (sel.idx === 1) { const values = ['asc', 'score-up', 'score-down'] const start = Math.max(0, values.indexOf(store?.settings?.sort || currSort)) const opt = await selectIndexArrows(values, start, `${CYAN}Сортировка${RESET}`) if (!opt.back) { store.settings = store.settings || {} store.settings.sort = values[opt.idx] saveStore(store) } } } } async function renderDashboard(store, context) { console.clear(); await renderHeader(context, store) const { city, studentId } = store.profile || {} store.city = city store.studentId = studentId saveStore(store) const selQ = store.selectedQuarter || 1 await showJournalTerm(context, city, studentId, selQ) footer() const options = ['Журнал (четверть)', 'Табель', 'Настройки', 'Выход из аккаунта', 'Выход'] const sel = await selectIndexArrows(options, 0, `${CYAN}Навигация${RESET}`) if (sel.back) return 'q' return ['j', 't', 's', 'l', 'q'][sel.idx] } async function main() { banner() const args = process.argv.slice(2) const cmd = args[0] if (cmd === 'logout') { saveStore({}) console.log('Logged out') return } let store = loadStore() || {} let { accessToken, refreshToken, applications } = store if (!accessToken || !refreshToken) { const iin = await prompt('ИИН: ') const password = await prompt('Пароль: ') inlineStatus('Вход...') try { const auth = await authenticate(iin.trim(), password.trim()) accessToken = auth.accessToken refreshToken = auth.refreshToken applications = auth.applications || [] store = { ...store, accessToken, refreshToken, applications } saveStore(store) inlineStatus('Вход... OK') } catch (e) { inlineStatus('Вход... ошибка') console.error(`\nОшибка входа:`, e?.code || e?.message) process.exit(1) } } const context = { accessToken, applications } global.storeRef = store while (true) { const nav = await renderDashboard(store, context) if (nav === 'j') { const quarter = await showJournalFlow(context, store.city, store.studentId, store.selectedQuarter || 1) if (quarter) { store.selectedQuarter = quarter; saveStore(store) } } else if (nav === 't') { await showReportsFlow(context, store.studentId) } else if (nav === 's') { await settingsMenu(context, store) } else if (nav === 'l') { saveStore({}) console.log('Выполнен выход. Перезапустите CLI для входа снова.') break } else if (nav === 'q') { break } } } main().catch((e) => { console.error('Ошибка CLI:', e?.response?.data || e.message) process.exit(1) })