Source: index.js

  1. /* KoreanSchool
  2. Copyright (C) 2018 Seungjae Park
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU General Public License as published by
  5. the Free Software Foundation, either version 3 of the License, or
  6. (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU General Public License for more details.
  11. You should have received a copy of the GNU General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>. */
  13. const fs = require('fs');
  14. const iconv = require('iconv-lite');
  15. const jsdom = require('jsdom');
  16. const path = require('path');
  17. const puppeteer = require('puppeteer');
  18. const request = require('request');
  19. const { Dimigo } = require('./utils');
  20. const { JSDOM } = jsdom;
  21. const getLastLine = buffer => buffer.slice(buffer.lastIndexOf('\n'));
  22. const schools = JSON.parse(getLastLine(fs.readFileSync(path.resolve(__dirname, '../data/schools.min.json'))));
  23. const alias = {
  24. office: {
  25. 서울시: '서울특별시',
  26. 부산시: '부산광역시',
  27. 대구시: '대구광역시',
  28. 인천시: '인천광역시',
  29. 광주시: '광주광역시',
  30. 대전시: '대전광역시',
  31. 울산시: '울산광역시',
  32. 세종시: '세종특별자치시',
  33. 세종특별시: '세종특별자치시',
  34. 충북: '충청북도',
  35. 충남: '충청남도',
  36. 전북: '전라북도',
  37. 전남: '전라남도',
  38. 경북: '경상북도',
  39. 경남: '경상남도',
  40. 제주시: '제주특별자치도',
  41. 제주특별시: '제주특별자치도',
  42. },
  43. };
  44. const cache = {
  45. comcigan: {},
  46. };
  47. /**
  48. * @typedef {Object} ComciganData
  49. * @property {number} 교사수
  50. * @property {Array.<string>} 성명
  51. * @property {Array.<number>} 학급수
  52. * @property {Array.<Array.<number>>} 요일별시수
  53. * @property {Array.<string>} 긴과목명
  54. * @property {Array.<string>} 과목명
  55. * @property {Array.<Array>} 시간표
  56. * @property {Array.<number>} 전일제
  57. * @property {string} 버젼
  58. * @property {number} 동시수업수
  59. * @property {Array.<Array.<number>>} 담임
  60. * @property {Array.<number>} 가상학급수
  61. * @property {number} 특별실수
  62. * @property {string} 열람제한일
  63. * @property {string} 저장일
  64. * @property {string} 학기시작일자
  65. * @property {string} 학교명
  66. * @property {string} 지역명
  67. * @property {number} 학년도
  68. * @property {Array.<string>} 복수교사
  69. * @property {string} 시작일
  70. * @property {Array.<string>} 일과시간
  71. * @property {Array.<Array.<number|string>>} 일자자료
  72. * @property {number} 오늘r
  73. * @property {Array.<Array>} 학급시간표
  74. * @property {Array.<Array>} 교사시간표
  75. */
  76. /**
  77. * @typedef {Object} SchoolData
  78. * @property {string} code
  79. * @property {string} office
  80. * @property {string} officeDomain
  81. * @property {string} name
  82. */
  83. /**
  84. * @typedef {Object} SchoolInformation
  85. * @property {?string} address
  86. * @property {?string} area
  87. * @property {?string} class
  88. * @property {?string} office
  89. * @property {?string} phone
  90. * @property {?string} fax
  91. * @property {?string} establishmentDate
  92. * @property {?string} establishmentType
  93. * @property {?string} schoolAnniversary
  94. * @property {?string} schoolType
  95. * @property {?string} site
  96. */
  97. /**
  98. * @typedef {Object} SchoolMeal
  99. * @property {?string} breakfast
  100. * @property {?string} lunch
  101. * @property {?string} dinner
  102. */
  103. /**
  104. * @typedef {Object} SchoolSchedule
  105. * @property {?string} subject
  106. * @property {?string} subjectOriginal
  107. * @property {?string} teacher
  108. * @property {boolean} isChanged
  109. */
  110. /**
  111. * @typedef {Object} SchoolTeacherSchedule
  112. * @property {number} grade
  113. * @property {number} room
  114. * @property {?string} subject
  115. * @property {?string} subjectOriginal
  116. * @property {boolean} isChanged
  117. */
  118. /**
  119. * Returns all matched school data from DB with school's office and name.
  120. * @example
  121. * school.findAll('경기도', '백석고');
  122. * // [
  123. * // {
  124. * // code: 'J100000564',
  125. * // office: '경기도',
  126. * // officeDomain: 'goe.go',
  127. * // name: '백석고등학교'
  128. * // },
  129. * // {
  130. * // code: 'J100005280',
  131. * // office: '경기도',
  132. * // officeDomain: 'goe.go',
  133. * // name: '양주백석고등학교'
  134. * // }
  135. * // ]
  136. * @function
  137. * @param {string} office
  138. * @param {string} schoolName
  139. * @param {string} [useAlias=true]
  140. * @returns {?Array.<SchoolData>}
  141. */
  142. const findAll = (office, schoolName, useAlias = true) => {
  143. const matches = [];
  144. for (let i = 0, len = schools.length; i < len; i += 1) {
  145. const school = schools[i];
  146. let check = false;
  147. if (typeof office === 'string') {
  148. if (useAlias) {
  149. if (office in alias.office) {
  150. check = school.office.search(alias.office[office]) >= 0;
  151. } else {
  152. check = school.office.search(office) >= 0;
  153. }
  154. } else {
  155. check = school.office.search(office) >= 0;
  156. }
  157. } else if (typeof office === 'object' && office instanceof RegExp) {
  158. check = office.test(school.office);
  159. }
  160. if (check) {
  161. if (typeof schoolName === 'string') {
  162. if (useAlias) {
  163. check = school.name.replace(/초$/, '초등학교')
  164. .replace(/중$/, '중학교')
  165. .replace(/고$/, '고등학교')
  166. .replace(/여자?중학교/, '여자중학교')
  167. .replace(/여자?고등학교/, '여자고등학교')
  168. .search(schoolName) >= 0;
  169. } else {
  170. check = school.name.search(schoolName) >= 0;
  171. }
  172. } else if (typeof schoolName === 'object' && schoolName instanceof RegExp) {
  173. check = schoolName.test(school.name);
  174. }
  175. if (check) {
  176. matches.push(school);
  177. }
  178. }
  179. }
  180. if (matches.length > 0) {
  181. return matches;
  182. }
  183. return null;
  184. };
  185. /**
  186. * Returns the best matched school data in the DB with school's office and name.
  187. * @example
  188. * school.find('경기도', '백석고');
  189. * // {
  190. * // code: 'J100000564',
  191. * // office: '경기도',
  192. * // officeDomain: 'goe.go',
  193. * // name: '백석고등학교'
  194. * // }
  195. * @function
  196. * @param {string} office
  197. * @param {string} schoolName
  198. * @param {string} [useAlias=true]
  199. * @returns {?SchoolData}
  200. */
  201. const find = (office, schoolName, useAlias = true) => {
  202. let schoolNameLength;
  203. if (useAlias) {
  204. schoolNameLength = schoolName.replace(/초$/, '초등학교')
  205. .replace(/중$/, '중학교')
  206. .replace(/고$/, '고등학교')
  207. .replace(/여자?중학교/, '여자중학교')
  208. .replace(/여자?고등학교/, '여자고등학교').length;
  209. } else {
  210. schoolNameLength = schoolName.length;
  211. }
  212. const schoolData = findAll(office, schoolName, useAlias);
  213. if (schoolData) {
  214. return schoolData.reduce((accumulator, currentValue) => {
  215. if (schoolNameLength / accumulator.length < schoolNameLength / currentValue.length) {
  216. return currentValue;
  217. }
  218. return accumulator;
  219. });
  220. }
  221. return null;
  222. };
  223. /**
  224. * Returns all school data from DB.
  225. * @example
  226. * school.getAll();
  227. * // [
  228. * // ...
  229. * // { code: 'J100000564',
  230. * // office: '경기도',
  231. * // officeDomain: 'goe.go',
  232. * // name: '백석고등학교' }
  233. * // ...
  234. * // ]
  235. * @function
  236. * @returns {Array.<SchoolData>}
  237. */
  238. const getAll = () => schools;
  239. /**
  240. * Returns the school data from Comcigan server.
  241. * @example
  242. * await school.getComciganData(find('경기도', '백석고'));
  243. * // ComciganData {}
  244. * @async
  245. * @function
  246. * @param {SchoolData} school
  247. * @returns {?ComciganData}
  248. */
  249. const getComciganData = school => new Promise(async (resolve) => {
  250. const UPDATE_TIME = 5 * 60 * 1000; // 5 minutes
  251. const { code } = school;
  252. const { comcigan } = cache;
  253. if (code in comcigan && comcigan[code].date > Date.now() - UPDATE_TIME) {
  254. resolve(JSON.parse(comcigan[code].data));
  255. } else {
  256. const browser = await puppeteer.launch();
  257. const page = await browser.newPage();
  258. page.on('response', async (response) => {
  259. const content = await response.text();
  260. if (content.includes('{"학교검색":')) {
  261. page.click('tr.검색 > td > a').catch(() => { }); // puppeteer bug: https://github.com/GoogleChrome/puppeteer/issues/1769
  262. }
  263. if (content.includes('{"교사수":')) {
  264. const html = await page.content();
  265. await browser.close();
  266. let data = content.split('\n')[0]
  267. .replace(/(자료\d+)"/g, ($, $1) => {
  268. const matches = html.match(new RegExp(`([\\w가-힣]+)\\s*=\\s*자료\\.${$1}`));
  269. return matches ? `${matches[1]}"` : $;
  270. })
  271. .replace(/"자료\d+":("\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}")/, '"저장일":$1')
  272. .replace('"원자료"', '"시간표"')
  273. .replace('"일일자료"', '"학급시간표"');
  274. data = data.replace(/자료\d+"/g, $ => (data.split($).length === 2 ? '교사시간표"' : '과목명"'));
  275. comcigan[code] = {
  276. data,
  277. date: Date.now(),
  278. };
  279. resolve(JSON.parse(data));
  280. }
  281. });
  282. await page.goto('http://comci.kr:4081/st');
  283. await page.type('#sc', school.name);
  284. await page.click('input[value=\'검색\']');
  285. }
  286. });
  287. /**
  288. * Returns the school information from School Info.
  289. * @example
  290. * await school.getInformation(find('경기도', '백석고'));
  291. * // {
  292. * // address: '경기도 고양시 일산동구 강촌로 115',
  293. * // area: '경기',
  294. * // class: '고',
  295. * // office: '경기도교육청',
  296. * // phone: '031-901-0506',
  297. * // fax: '031-901-0501',
  298. * // establishmentDate: '1992-09-08',
  299. * // establishmentType: '공립',
  300. * // schoolAnniversary: '1992-09-08',
  301. * // schoolType: '단설',
  302. * // site: 'http://www.baekseok.hs.kr'
  303. * // }
  304. * @async
  305. * @function
  306. * @param {SchoolData} school
  307. * @returns {?SchoolInformation}
  308. */
  309. const getInformation = school => new Promise((resolve) => {
  310. request.post({
  311. url: `https://www.schoolinfo.go.kr/ei/ss/Pneiss_b01_s0.do?HG_CD=${school.code}`,
  312. encoding: null,
  313. headers: { 'User-Agent': 'Mozilla/5.0' },
  314. }, (err, httpResponse, body) => {
  315. if (err) {
  316. resolve(null);
  317. } else {
  318. const { document } = (new JSDOM(iconv.decode(body, 'euc-kr'))).window;
  319. const data = { // default values
  320. address: null,
  321. area: document.querySelector('.School_Division .mapD_Area').textContent || null,
  322. class: document.querySelector('.School_Division .mapD_Class').textContent || null,
  323. office: null,
  324. phone: null,
  325. fax: null,
  326. establishmentDate: null,
  327. establishmentType: null,
  328. schoolAnniversary: null,
  329. schoolType: null,
  330. site: null,
  331. };
  332. document.querySelectorAll('.School_Data li').forEach((element) => {
  333. if (element.textContent.search('학교주소') >= 0) {
  334. data.address = element.textContent.replace(/^\s*학교주소\s*/, '').replace(/\s*$/, '');
  335. } else if (element.textContent.search('관할교육청') >= 0) {
  336. data.office = element.textContent.replace(/^\s*관할교육청\s*/, '').replace(/\s*$/, '');
  337. } else if (element.textContent.search('전화/팩스') >= 0) {
  338. const str = element.textContent.replace(/^\s*전화\/팩스\s*/, '').replace(/\s*$/, '');
  339. if (str.search('전화') >= 0) {
  340. [, data.phone] = str.match(/전화([0-9-]+)/);
  341. }
  342. if (str.search('팩스') >= 0) {
  343. [, data.fax] = str.match(/팩스([0-9-]+)/);
  344. }
  345. } else if (element.textContent.search('설립일') >= 0) {
  346. data.establishmentDate = element.textContent.replace(/^\s*설립일\s*/, '').replace(/\s*$/, '');
  347. } else if (element.textContent.search('설립구분') >= 0) {
  348. data.establishmentType = element.textContent.replace(/^\s*설립구분\s*/, '').replace(/\s*$/, '');
  349. } else if (element.textContent.search('개교기념일') >= 0) {
  350. data.schoolAnniversary = element.textContent.replace(/^\s*개교기념일\s*/, '').replace(/\s*$/, '');
  351. } else if (element.textContent.search('설립유형') >= 0) {
  352. data.schoolType = element.textContent.replace(/^\s*설립유형\s*/, '').replace(/\s*$/, '');
  353. } else if (element.textContent.search('홈페이지') >= 0) {
  354. data.site = element.textContent.replace(/^\s*홈페이지\s*/, '').replace(/\s*$/, '');
  355. }
  356. });
  357. resolve(data);
  358. }
  359. });
  360. });
  361. /**
  362. * Returns the school monthly meals.
  363. * @example
  364. * await school.getMeals(find('경기도', '백석고'), new Date());
  365. * // [
  366. * // {
  367. * // breakfast: null,
  368. * // lunch: '보리밥_H\n어묵매운탕_H1.5.6.8.9.13.\n콘치즈떡갈비_H1.2.5.6.10.13.15.16.',
  369. * // dinner: null
  370. * // }
  371. * // ...
  372. * // ]
  373. * @async
  374. * @function
  375. * @param {SchoolData} school
  376. * @param {Date} date
  377. * @returns {?Array.<SchoolMeal>}
  378. */
  379. const getMeals = (school, date) => {
  380. if (school.code === 'J100000855') {
  381. return Dimigo.getMeals(date);
  382. }
  383. return new Promise((resolve) => {
  384. request.get({
  385. url: `http://stu.${school.officeDomain}.kr/sts_sci_md00_001.do`,
  386. form: {
  387. schulCode: school.code,
  388. schulCrseScCode: 4,
  389. ay: date.getFullYear(),
  390. mm: (`0${date.getMonth() + 1}`).substr(-2),
  391. },
  392. }, (err, httpResponse, body) => {
  393. if (err) {
  394. resolve(null);
  395. } else {
  396. const { document } = (new JSDOM(body)).window;
  397. const meals = [];
  398. document.querySelectorAll('.tbl_type3 tbody tr td div').forEach((element) => {
  399. const content = element.innerHTML.split('<br>').join('\n').split('&amp;').join('&');
  400. if (content.trim()) {
  401. const breakfast = (content.match(/\[조식\][^[]+/) || [''])[0].replace(/\[조식\]\s*/, '').replace(/\s*\[/, '');
  402. const lunch = (content.match(/\[중식\][^[]+/) || [''])[0].replace(/\[중식\]\s*/, '').replace(/\s*\[/, '');
  403. const dinner = (content.match(/\[석식\][^[]+/) || [''])[0].replace(/\[석식\]\s*/, '');
  404. meals[Number(content.match(/^\d+/)[0]) - 1] = {
  405. breakfast: breakfast || null,
  406. lunch: lunch || null,
  407. dinner: dinner || null,
  408. };
  409. }
  410. });
  411. resolve(meals);
  412. }
  413. });
  414. });
  415. };
  416. /**
  417. * Returns the school daily meal.
  418. * @example
  419. * await school.getMeal(find('경기도', '백석고'), new Date());
  420. * // {
  421. * // breakfast: null,
  422. * // lunch: '보리밥_H\n어묵매운탕_H1.5.6.8.9.13.\n콘치즈떡갈비_H1.2.5.6.10.13.15.16.',
  423. * // dinner: null
  424. * // }
  425. * @async
  426. * @function
  427. * @param {SchoolData} school
  428. * @param {Date} date
  429. * @returns {?SchoolMeal}
  430. */
  431. const getMeal = (school, date) => {
  432. if (school.code === 'J100000855') {
  433. return Dimigo.getMeal(date);
  434. }
  435. return new Promise(async (resolve) => {
  436. const meals = await getMeals(school, date);
  437. if (meals) {
  438. resolve(meals[date.getDate() - 1]);
  439. } else {
  440. resolve(null);
  441. }
  442. });
  443. };
  444. /**
  445. * Returns the school weekly schedule.
  446. * @async
  447. * @function
  448. * @param {SchoolData} school
  449. * @param {namber} grade
  450. * @param {namber} room
  451. * @returns {?Array.<SchoolSchedule>}
  452. */
  453. const getSchedules = (school, grade, room) => new Promise(async (resolve) => {
  454. const data = await getComciganData(school);
  455. if (data) {
  456. const weeklySchedule = data.학급시간표[grade][room];
  457. weeklySchedule[0] = null;
  458. for (let day = 1; day < 6; day += 1) {
  459. const schedule = weeklySchedule[day];
  460. for (let period = 0, len2 = schedule.length; period < len2; period += 1) {
  461. const subject = schedule[period];
  462. if (subject > 100) {
  463. weeklySchedule[day][period] = {
  464. subject: data.과목명[subject % 100] || null,
  465. subjectOriginal: data.긴과목명[subject % 100] || null,
  466. teacher: data.성명[Math.floor(subject / 100)] || null,
  467. isChanged: subject !== data.시간표[grade][room][day][period],
  468. };
  469. } else {
  470. weeklySchedule[day][period] = null;
  471. }
  472. }
  473. weeklySchedule[day].splice(0, 1);
  474. }
  475. weeklySchedule[6] = null;
  476. resolve(weeklySchedule);
  477. } else {
  478. resolve(null);
  479. }
  480. });
  481. /**
  482. * Returns the school daily schedule.
  483. * @async
  484. * @function
  485. * @param {SchoolData} school
  486. * @param {number} grade
  487. * @param {number} room
  488. * @param {Date} date
  489. * @returns {?SchoolSchedule}
  490. */
  491. const getSchedule = (school, grade, room, date) => new Promise(async (resolve) => {
  492. const schedules = await getSchedules(school, grade, room);
  493. if (schedules) {
  494. resolve(schedules[date.getDay()]);
  495. } else {
  496. resolve(null);
  497. }
  498. });
  499. /**
  500. * Returns the school teachers.
  501. * @async
  502. * @function
  503. * @param {SchoolData} school
  504. * @returns {?Array.<string>}
  505. */
  506. const getTeachers = school => new Promise(async (resolve) => {
  507. const data = await getComciganData(school);
  508. if (data) {
  509. resolve(data.성명.filter(value => value));
  510. } else {
  511. resolve(null);
  512. }
  513. });
  514. /**
  515. * Returns the school's teacher weekly schedule.
  516. * @async
  517. * @function
  518. * @param {SchoolData} school
  519. * @param {string} teacher
  520. * @returns {?Array.<SchoolTeacherSchedule>}
  521. */
  522. const getTeacherSchedules = (school, teacher) => new Promise(async (resolve) => {
  523. const data = await getComciganData(school);
  524. if (data) {
  525. const teacherIndex = data.성명.indexOf(teacher);
  526. if (teacherIndex >= 0) {
  527. const weeklySchedule = data.교사시간표[teacherIndex];
  528. weeklySchedule[0] = null;
  529. for (let day = 1; day < 6; day += 1) {
  530. const schedule = weeklySchedule[day];
  531. for (let period = 0, len2 = schedule.length; period < len2; period += 1) {
  532. const teacherData = schedule[period];
  533. if (teacherData > 100) {
  534. const tmp = Math.floor(teacherData / 100);
  535. const grade = Math.floor(tmp / 100);
  536. const room = tmp % 100;
  537. const subject = data.시간표[grade][room][day][period] % 100;
  538. weeklySchedule[day][period] = {
  539. grade,
  540. room,
  541. subject: data.과목명[teacherData % 100] || null,
  542. subjectOriginal: data.긴과목명[teacherData % 100] || null,
  543. isChanged: teacherData !== (grade * 10000) + (room * 100) + subject,
  544. };
  545. } else {
  546. weeklySchedule[day][period] = null;
  547. }
  548. }
  549. weeklySchedule[day].splice(0, 1);
  550. }
  551. weeklySchedule[6] = null;
  552. resolve(weeklySchedule);
  553. } else {
  554. resolve(null);
  555. }
  556. } else {
  557. resolve(null);
  558. }
  559. });
  560. /**
  561. * Returns the school's teacher daily schedule.
  562. * @async
  563. * @function
  564. * @param {SchoolData} school
  565. * @param {string} teacher
  566. * @param {Date} date
  567. * @returns {?SchoolTeacherSchedule}
  568. */
  569. const getTeacherSchedule = (school, teacher, date) => new Promise(async (resolve) => {
  570. const schedules = await getTeacherSchedules(school, teacher);
  571. if (schedules) {
  572. resolve(schedules[date.getDay()]);
  573. } else {
  574. resolve(null);
  575. }
  576. });
  577. exports.find = find;
  578. exports.findAll = findAll;
  579. exports.getAll = getAll;
  580. exports.getComciganData = getComciganData;
  581. exports.getInformation = getInformation;
  582. exports.getMeal = getMeal;
  583. exports.getMeals = getMeals;
  584. exports.getSchedule = getSchedule;
  585. exports.getSchedules = getSchedules;
  586. exports.getTeachers = getTeachers;
  587. exports.getTeacherSchedule = getTeacherSchedule;
  588. exports.getTeacherSchedules = getTeacherSchedules;