Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,19 @@
*.xml
# Python
__pycache__/
*.py[cod]
*.egg-info/

# Environments
.env
.venv/
venv/

# macOS
.DS_Store

# Local output
/tmp/
*.ics*.xml
.idea
.venv
__pycache__
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@ Everytime 앱 보다 캘린더로 일정을 관리하는게 편한 마음에 항

## 어떻게 사용하나요?

`python every2cal.py --begin 학기가 시작하는 날짜 --end 학기가 끝나는 날짜`
CLI 예시

```
python every2cal.py --source https://everytime.kr/@<식별자> --begin 20240304 --end 20240621
```

로 사용합니다.

Every2Cal은 Everytime 내부의 AJAX로 불러와지는 .xml 형식 시간표를 활용하고 있습니다.

추후에 이미지 기반 시간표 읽어오기도 지원 예정입니다.

웹 서버 실행(Flask)

```
python index.py
```
174 changes: 111 additions & 63 deletions convert.py
Original file line number Diff line number Diff line change
@@ -1,80 +1,128 @@
# -*- coding: utf8 -*-
__author__ = "Hoseong Son <me@sookcha.com>"
"""Utilities to convert Everytime timetable XML into an iCalendar (.ics) file."""

import datetime
from __future__ import annotations

import logging
import os
import xml.etree.ElementTree as ElementTree
from pathlib import Path
from typing import Any, Dict, Iterable, List, Optional
import datetime as dt
import xml.etree.ElementTree as ET

from dateutil import parser
from dateutil import parser as dtparse
from icalendar import Calendar, Event


class Convert():
def __init__(self, filename):
self.filename = filename

def get_subjects(self):
result = []
try:
tree = ElementTree.parse(self.filename)
root = tree.getroot()
except:
tree = ElementTree.fromstring(self.filename)
root = tree

for subject in root.iter('subject'):
name = subject.find("name").get("value")
single_subject = {"name": name, "professor": subject.find("professor").get("value"), "info": list(map(
lambda x: {
"day": x.get("day"),
"place": x.get("place"),
"startAt": '{:02d}:{:02d}'.format(*divmod(int(x.get("starttime")) * 5, 60)),
"endAt": '{:02d}:{:02d}'.format(*divmod(int(x.get("endtime")) * 5, 60))
}, subject.find("time").findall("data")
)
)}

logger = logging.getLogger(__name__)


class Convert:
"""Convert timetable XML to subjects and calendar.

The class accepts either a path to an XML file or an XML string.
"""

def __init__(self, xml_or_path: str) -> None:
self._xml_or_path = xml_or_path

def _get_root(self) -> ET.Element:
"""Return the root element of the XML.

Tries to resolve input as a filesystem path first when it exists; otherwise
treats it as an XML string.
"""
candidate = Path(self._xml_or_path)
if candidate.exists():
logger.debug("Parsing timetable from file: %s", candidate)
tree = ET.parse(candidate)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security (python.lang.security.use-defused-xml-parse): The native Python xml library is vulnerable to XML External Entity (XXE) attacks. These attacks can leak confidential data and "XML bombs" can cause denial of service. Do not use this library to parse untrusted input. Instead the Python documentation recommends using defusedxml.

Suggested change
tree = ET.parse(candidate)
tree = defusedxml.etree.ElementTree.parse(candidate)

Source: opengrep

return tree.getroot()

logger.debug("Parsing timetable from XML string (%d chars)", len(self._xml_or_path))
return ET.fromstring(self._xml_or_path)

def get_subjects(self) -> List[Dict[str, Any]]:
"""Parse XML and return a normalized list of subjects.

Returns a list of dicts with keys: name, professor, info[].
"""
result: List[Dict[str, Any]] = []
root = self._get_root()

for subject in root.iter("subject"):
name_attr = subject.find("name")
professor_attr = subject.find("professor")
if name_attr is None or professor_attr is None:
logger.debug("Skipping subject missing name/professor")
continue

def _slot(x: ET.Element) -> Dict[str, str]:
start_raw = int(x.get("starttime", "0"))
end_raw = int(x.get("endtime", "0"))
# Each unit is 5 minutes; convert to HH:MM
start_at = "{:02d}:{:02d}".format(*divmod(start_raw * 5, 60))
end_at = "{:02d}:{:02d}".format(*divmod(end_raw * 5, 60))
return {
"day": x.get("day", "0"),
"place": x.get("place", ""),
"startAt": start_at,
"endAt": end_at,
}

time_node = subject.find("time")
times = [] if time_node is None else list(map(_slot, time_node.findall("data")))
Comment on lines +57 to +71
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Default values are provided for missing XML attributes.

Defaulting missing attributes improves robustness, but may lead to events with invalid times or locations. Consider skipping entries with critical missing data instead.

Suggested change
def _slot(x: ET.Element) -> Dict[str, str]:
start_raw = int(x.get("starttime", "0"))
end_raw = int(x.get("endtime", "0"))
# Each unit is 5 minutes; convert to HH:MM
start_at = "{:02d}:{:02d}".format(*divmod(start_raw * 5, 60))
end_at = "{:02d}:{:02d}".format(*divmod(end_raw * 5, 60))
return {
"day": x.get("day", "0"),
"place": x.get("place", ""),
"startAt": start_at,
"endAt": end_at,
}
time_node = subject.find("time")
times = [] if time_node is None else list(map(_slot, time_node.findall("data")))
def _slot(x: ET.Element) -> Optional[Dict[str, str]]:
starttime = x.get("starttime")
endtime = x.get("endtime")
day = x.get("day")
place = x.get("place")
if starttime is None or endtime is None or day is None or place is None:
logger.debug("Skipping slot missing starttime/endtime/day/place")
return None
try:
start_raw = int(starttime)
end_raw = int(endtime)
except ValueError:
logger.debug("Skipping slot with invalid starttime/endtime")
return None
# Each unit is 5 minutes; convert to HH:MM
start_at = "{:02d}:{:02d}".format(*divmod(start_raw * 5, 60))
end_at = "{:02d}:{:02d}".format(*divmod(end_raw * 5, 60))
return {
"day": day,
"place": place,
"startAt": start_at,
"endAt": end_at,
}
time_node = subject.find("time")
times = []
if time_node is not None:
for slot in time_node.findall("data"):
slot_dict = _slot(slot)
if slot_dict is not None:
times.append(slot_dict)


single_subject = {
"name": name_attr.get("value", ""),
"professor": professor_attr.get("value", ""),
"info": times,
}
result.append(single_subject)

return result

def get_calendar(self, timetable, start_date, end_date, id):
def get_calendar(
self,
timetable: Iterable[Dict[str, Any]],
start_date: str,
end_date: str,
identifier: str,
) -> Optional[str]:
"""Create a weekly-recurring calendar between start_date and end_date.

Returns the file path to the generated .ics on success, otherwise None
when there are no events.
"""
cal = Calendar()
event_count = 0

for item in timetable:
for time in item["info"]:
for time in item.get("info", []):
event = Event()
event.add('summary', item["name"])
event.add('dtstart',
parser.parse("%s %s" % (self.get_nearest_date(start_date, time["day"]), time["startAt"])))
event.add('dtend',
parser.parse("%s %s" % (self.get_nearest_date(start_date, time["day"]), time["endAt"])))
event.add('rrule', {'freq': 'WEEKLY', 'until': parser.parse(end_date)})
if time["place"] != "":
event.add('location', time["place"])
event.add("summary", item.get("name", ""))
start_dt = dtparse.parse(f"{self.get_nearest_date(start_date, time.get('day', '0'))} {time.get('startAt', '00:00')}")
end_dt = dtparse.parse(f"{self.get_nearest_date(start_date, time.get('day', '0'))} {time.get('endAt', '00:00')}")
event.add("dtstart", start_dt)
event.add("dtend", end_dt)
event.add("rrule", {"freq": "WEEKLY", "until": dtparse.parse(end_date)})
place = time.get("place")
if place:
Comment on lines +106 to +107
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Use named expression to simplify assignment and conditional (use-named-expression)

Suggested change
place = time.get("place")
if place:
if place := time.get("place"):

event.add("location", place)
cal.add_component(event)
event_count += 1

if len(str(cal.to_ical())) <= 39:
if event_count == 0:
logger.warning("No events found in timetable; skipping file generation.")
return None
else:
f = open(os.path.join('/', 'tmp', f'{id}.ics'), 'w+')
f.write("BEGIN:VCALENDAR\nVERSION:2.0")
f.close()

f = open(os.path.join('/', 'tmp', f'{id}.ics'), 'ab')
f.write(cal.to_ical()[15:])
f.close()

print("작업 완료!🙌")

def get_nearest_date(self, start_date, weekday):
start_date = parser.parse(start_date)
weekday = int(weekday)

if start_date.weekday() >= weekday:
if start_date.weekday() > weekday: start_date += datetime.timedelta(days=7)
start_date -= datetime.timedelta(start_date.weekday() - weekday)
else:
start_date += datetime.timedelta(weekday - start_date.weekday())

return start_date
out_path = Path("/tmp") / f"{identifier}.ics"
out_path.parent.mkdir(parents=True, exist_ok=True)
with out_path.open("wb") as f:
f.write(cal.to_ical())
logger.info("Calendar generated: %s (%d events)", out_path, event_count)
return str(out_path)

def get_nearest_date(self, start_date: str, weekday: str | int) -> dt.date:
"""Return the first date on/after start_date that matches weekday (0=Mon)."""
start = dtparse.parse(start_date).date()
wd = int(weekday)
delta = (wd - start.weekday()) % 7
return start + dt.timedelta(days=delta)
66 changes: 29 additions & 37 deletions every2cal.py
Original file line number Diff line number Diff line change
@@ -1,47 +1,39 @@
# -*- coding: utf8 -*-
"""CLI entry point for converting Everytime timetable into .ics."""

from __future__ import annotations

import argparse
import getpass
from typing import Optional

import everytime
from convert import Convert


def main():
parser = argparse.ArgumentParser()
parser.add_argument("--xml", type=str, help="Location of timetable xml file", required=False)
parser.add_argument("--begin", type=str, help="Semester beginning date", required=True)
parser.add_argument("--end", type=str, help="Semester ending date", required=True)
args = parser.parse_args()

xml = ""
if args.xml:
xml = args.xml

else:
username = input('에브리타임 아이디 : ')
password = getpass.getpass()

year = input('가져올 년도 : ')
semester = input('가져올 학기 : ')

e = everytime.Everytime(username, password)
xml = e.get_timetable(year, semester)
def main(argv: Optional[list[str]] = None) -> int:
parser = argparse.ArgumentParser(description="Everytime timetable to .ics converter")
parser.add_argument("--source", type=str, help="Path or Everytime URL/identifier", required=False)
parser.add_argument("--begin", type=str, help="Semester beginning date (YYYYMMDD)", required=True)
parser.add_argument("--end", type=str, help="Semester ending date (YYYYMMDD)", required=True)
args = parser.parse_args(argv)

c = Convert(xml)
c.get_calendar(c.get_subjects(), args.begin, args.end)


def down_cal(begin, end, schd_url):
xml = ""
if schd_url:
xml = schd_url
else:
path = input('경로: ')

e = everytime.Everytime(path)
if args.source:
src = args.source
e = everytime.Everytime(src)
xml = e.get_timetable()
else:
# Expect XML to be piped via stdin or provided as file path; keep simple
raise SystemExit("--source is required when using CLI")
Comment on lines +19 to +25
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): We've found these issues:


c = Convert(xml)
c.get_calendar(c.get_subjects(), begin, end)

print('test SUCESS')
# Use the identifier portion as file name if a URL was provided
identifier = (args.source or "timetable").split("/")[-1]
cal_path = c.get_calendar(c.get_subjects(), args.begin, args.end, identifier)
if not cal_path:
print("No events found.")
return 1
print(f"Generated: {cal_path}")
return 0


if __name__ == "__main__":
raise SystemExit(main())
60 changes: 38 additions & 22 deletions everytime.py
Original file line number Diff line number Diff line change
@@ -1,29 +1,45 @@
import requests
"""Small client to fetch Everytime timetable XML."""

from __future__ import annotations

from typing import Optional
from urllib.parse import urlparse
import requests


API_URL = "https://api.everytime.kr/find/timetable/table/friend"
DEFAULT_HEADERS = {
"Accept": "*/*",
"Connection": "keep-alive",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Host": "api.everytime.kr",
"Origin": "https://everytime.kr",
"Referer": "https://everytime.kr/",
"User-Agent": (
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/93.0.4577.63 Safari/537.36"
),
}


class Everytime:
def __init__(self, path):
def __init__(self, path: str) -> None:
url = urlparse(path)
if url.netloc == "everytime.kr":
self.path = url.path.replace("/@", "")
return
self.path = path
else:
self.path = path

def get_timetable(self):
return requests.post(
"https://api.everytime.kr/find/timetable/table/friend",
data={
"identifier": self.path,
"friendInfo": 'true'
},
headers={
"Accept": "*/*",
"Connection": "keep-alive",
"Pragma": "no-cache",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Host": "api.everytime.kr",
"Origin": "https://everytime.kr",
"Referer": "https://everytime.kr/",
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/93.0.4577.63 Safari/537.36"
}).text
def get_timetable(self, timeout: float = 10.0) -> str:
"""Fetch timetable XML as a string. Raises for HTTP errors."""
resp = requests.post(
API_URL,
data={"identifier": self.path, "friendInfo": "true"},
headers=DEFAULT_HEADERS,
timeout=timeout,
)
resp.raise_for_status()
return resp.text
Loading