edupage-cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. checksums.yaml +7 -0
  2. data/README.md +250 -0
  3. data/exe/edupage +7 -0
  4. data/lib/edupage/account.rb +121 -0
  5. data/lib/edupage/cache.rb +101 -0
  6. data/lib/edupage/cli/formatter.rb +56 -0
  7. data/lib/edupage/cli/table.rb +136 -0
  8. data/lib/edupage/cli.rb +543 -0
  9. data/lib/edupage/client.rb +152 -0
  10. data/lib/edupage/config.rb +96 -0
  11. data/lib/edupage/credentials/env.rb +27 -0
  12. data/lib/edupage/credentials/keychain.rb +120 -0
  13. data/lib/edupage/credentials.rb +118 -0
  14. data/lib/edupage/errors.rb +74 -0
  15. data/lib/edupage/model.rb +131 -0
  16. data/lib/edupage/models/assignment.rb +83 -0
  17. data/lib/edupage/models/classroom.rb +13 -0
  18. data/lib/edupage/models/day.rb +51 -0
  19. data/lib/edupage/models/grade.rb +100 -0
  20. data/lib/edupage/models/lesson.rb +58 -0
  21. data/lib/edupage/models/parent.rb +8 -0
  22. data/lib/edupage/models/period.rb +13 -0
  23. data/lib/edupage/models/person.rb +25 -0
  24. data/lib/edupage/models/school_class.rb +16 -0
  25. data/lib/edupage/models/student.rb +45 -0
  26. data/lib/edupage/models/subject.rb +9 -0
  27. data/lib/edupage/models/teacher.rb +25 -0
  28. data/lib/edupage/models/term.rb +36 -0
  29. data/lib/edupage/models/timeline_item.rb +87 -0
  30. data/lib/edupage/parsers/base.rb +150 -0
  31. data/lib/edupage/parsers/gcall.rb +53 -0
  32. data/lib/edupage/parsers/timeline.rb +46 -0
  33. data/lib/edupage/parsers/userhome.rb +84 -0
  34. data/lib/edupage/parsers/znamky.rb +83 -0
  35. data/lib/edupage/registry/resources.rb +175 -0
  36. data/lib/edupage/registry.rb +286 -0
  37. data/lib/edupage/relation.rb +185 -0
  38. data/lib/edupage/school.rb +358 -0
  39. data/lib/edupage/serializer.rb +53 -0
  40. data/lib/edupage/server/api.rb +113 -0
  41. data/lib/edupage/server/auth.rb +45 -0
  42. data/lib/edupage/server/mcp.rb +107 -0
  43. data/lib/edupage/server.rb +57 -0
  44. data/lib/edupage/session.rb +166 -0
  45. data/lib/edupage/session_store.rb +131 -0
  46. data/lib/edupage/version.rb +3 -0
  47. data/lib/edupage/year.rb +77 -0
  48. data/lib/edupage.rb +64 -0
  49. metadata +201 -0
@@ -0,0 +1,51 @@
1
+ module Edupage
2
+ module Models
3
+ # One day of a student's timetable.
4
+ class Day < Model
5
+ attribute :timetable_number, "tt_num", cast: :integer
6
+ attribute :timetable_day, "tt_day", cast: :integer
7
+ attribute :timetable_week, "tt_week", cast: :integer
8
+ attribute :timetable_term, "tt_term", cast: :integer
9
+
10
+ attr_reader :date
11
+
12
+ def initialize(data, school: nil, date: nil)
13
+ super(data, school: school)
14
+ @date = date.is_a?(String) ? Date.parse(date) : date
15
+ end
16
+
17
+ # Real lessons only; Edupage pads the plan with empty period rows.
18
+ def lessons
19
+ @lessons ||= Relation.wrap(all_entries.select(&:lesson?))
20
+ end
21
+
22
+ # Including the structural rows, for anyone rendering a full grid.
23
+ def entries = Relation.wrap(all_entries)
24
+
25
+ def lesson_at(period) = lessons.find { |l| l.period_number.to_s == period.to_s }
26
+
27
+ # Days off still come back with a plan, just without lessons.
28
+ def teaching_day? = lessons.any?
29
+
30
+ def absences = Array(data["student_absents"])
31
+
32
+ def id = date&.iso8601
33
+ def name = id
34
+
35
+ # Compact one-line view of the day, used by the table renderers.
36
+ def lesson_summary = lessons.map { |l| l.subject&.short || "?" }.join(" ")
37
+
38
+ def to_s = "#{id} #{lessons.map(&:to_s).join(" ")}"
39
+
40
+ def to_h
41
+ super.merge(date: id, lessons: lessons.map(&:to_h)).compact
42
+ end
43
+
44
+ private
45
+
46
+ def all_entries
47
+ @all_entries ||= Array(data["plan"]).map { |entry| Lesson.new(entry, school: school, date: date) }
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,100 @@
1
+ module Edupage
2
+ module Models
3
+ # A single mark.
4
+ #
5
+ # Edupage splits a grade in two: the row in `vsetkyZnamky` holds the value and who
6
+ # it belongs to, while everything descriptive - what it was for, how much it counts,
7
+ # the class average - lives on the event it references. Both are merged here, with
8
+ # the event kept under #event.
9
+ class Grade < Model
10
+ # p_typ_udalosti on the event: "1" is a classic 1-5 mark, "3" is points.
11
+ TYPE_MARK = "1".freeze
12
+ TYPE_POINTS = "3".freeze
13
+
14
+ # Edupage stores weights as a percentage of a "normal" grade, where 20 means 1.0.
15
+ WEIGHT_UNIT = 20.0
16
+
17
+ attribute :id, "znamkaid"
18
+ attribute :value, "data"
19
+ # The sub-period the grade sits in: "P2" for ordinary marks, "V2" for the ones
20
+ # that make up the end-of-term report. #term is the half-year it rolls up into.
21
+ attribute :period, "mesiac"
22
+ attribute :state, "stav"
23
+ attribute :provider
24
+ attribute :event_id, "udalostid"
25
+ attribute :created_at, "datum", cast: :time
26
+ attribute :signed_at, "podpisane", cast: :time
27
+ attribute :signed_by_parent_at, "podpisane_rodic", cast: :time
28
+ attribute(:subject) { |d| school&.subject(d["predmetid"]) }
29
+ attribute(:student) { |d| school&.student(d["studentid"]) }
30
+ attribute(:teacher) { |d| school&.teacher(d["ucitelid"]) }
31
+
32
+ attr_reader :event, :year, :term
33
+
34
+ def initialize(data, school: nil, event: nil, year: nil, term: nil)
35
+ super(data, school: school)
36
+ @event = event || {}
37
+ @year = year
38
+ @term = term
39
+ end
40
+
41
+ # --- from the event -------------------------------------------------------------
42
+
43
+ def title = presence(event["p_meno"])
44
+ def short = presence(event["p_skratka"])
45
+ def type = event["p_typ_udalosti"]
46
+ def due_on = Model.parse_date(event["p_termin"])
47
+ def class_average = event["priemer"]&.to_f
48
+ def school_class = school&.school_class(event["TriedaID"])
49
+
50
+ def weight
51
+ raw = event["p_vaha"]
52
+ raw.nil? || raw.to_s.empty? ? 1.0 : raw.to_f / WEIGHT_UNIT
53
+ end
54
+
55
+ # --- derived --------------------------------------------------------------------
56
+
57
+ def mark? = type == TYPE_MARK
58
+ def points? = type == TYPE_POINTS
59
+
60
+ def points = points? ? Float(value, exception: false) : nil
61
+
62
+ def max_points
63
+ return nil unless points?
64
+
65
+ Float(event["p_vaha_body"], exception: false)
66
+ end
67
+
68
+ def percentage
69
+ return nil unless points && max_points&.positive?
70
+
71
+ (points / max_points * 100).round(2)
72
+ end
73
+
74
+ def signed? = !signed_at.nil?
75
+ def signed_by_parent? = !signed_by_parent_at.nil?
76
+
77
+ def name = title || value
78
+
79
+ def match_strings = [id, value, title, short, subject&.short, subject&.name].compact.map(&:to_s)
80
+
81
+ def to_s
82
+ parts = [created_at&.strftime("%Y-%m-%d"), subject&.short, value]
83
+ parts << "(#{title})" if title
84
+ parts.compact.join(" ")
85
+ end
86
+
87
+ def to_h
88
+ super.merge(
89
+ title: title, weight: weight, type: type, points: points, max_points: max_points,
90
+ percentage: percentage, class_average: class_average,
91
+ year: year&.id, term: term&.id, signed_by_parent: signed_by_parent?
92
+ ).compact
93
+ end
94
+
95
+ private
96
+
97
+ def presence(value) = value.nil? || value.to_s.empty? ? nil : value.to_s
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,58 @@
1
+ module Edupage
2
+ module Models
3
+ # One entry in a day's plan.
4
+ #
5
+ # Edupage mixes real lessons with structural rows (empty periods, breaks); #lesson?
6
+ # separates them. Each entry carries both a flat form (subjectid, teacherids, ...)
7
+ # and a nested presentation form under "header"/"infos"; the flat form is used here
8
+ # and the nested one is left in #data for anything not modelled.
9
+ class Lesson < Model
10
+ attribute :type
11
+ attribute :period_number, "period"
12
+ attribute :start_time, "starttime"
13
+ attribute :end_time, "endtime"
14
+ attribute :lesson_id, "lid"
15
+ attribute(:groups) { |d| Array(d["groupnames"]).reject { |g| g.to_s.empty? } }
16
+ attribute(:subject) { |d| school&.subject(d["subjectid"]) }
17
+ attribute(:period) { |d| school&.period(d["period"]) }
18
+ attribute(:teachers) { |d| Array(d["teacherids"]).filter_map { |id| school&.teacher(id) } }
19
+ attribute(:classrooms) { |d| Array(d["classroomids"]).filter_map { |id| school&.classroom(id) } }
20
+ attribute(:classes) { |d| Array(d["classids"]).filter_map { |id| school&.school_class(id) } }
21
+
22
+ attr_reader :date
23
+
24
+ def initialize(data, school: nil, date: nil)
25
+ super(data, school: school)
26
+ @date = date
27
+ end
28
+
29
+ def lesson? = type == "lesson"
30
+
31
+ def teacher = teachers.first
32
+ def classroom = classrooms.first
33
+
34
+ # "Učivo: ..." - what was actually taught, when the teacher filled it in.
35
+ def curriculum
36
+ info_text("wd")&.sub(/\A[^:]*:\s*/, "")
37
+ end
38
+
39
+ def id = [date, period_number, data["subjectid"]].compact.join(":")
40
+
41
+ def name = subject&.name
42
+ def short = subject&.short
43
+
44
+ def to_s
45
+ return "#{period_number}. -" unless lesson?
46
+
47
+ "#{period_number}. #{subject&.short || "?"}#{groups.empty? ? "" : " (#{groups.join(", ")})"}"
48
+ end
49
+
50
+ private
51
+
52
+ def info_text(type)
53
+ Array(data["infos"]).find { |info| info["type"] == type }
54
+ &.dig("texts", 0, "text")
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,8 @@
1
+ module Edupage
2
+ module Models
3
+ # A guardian. dbi lists every pupil's parents, not only the account's own.
4
+ class Parent < Person
5
+ USER_STRING_PREFIX = "Rodic".freeze
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,13 @@
1
+ module Edupage
2
+ module Models
3
+ # One slot in the daily bell schedule ("2nd period, 08:45-09:30").
4
+ class Period < Model
5
+ attribute :name
6
+ attribute :short
7
+ attribute :start_time, "starttime"
8
+ attribute :end_time, "endtime"
9
+
10
+ def to_s = "#{name}. #{start_time}-#{end_time}"
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,25 @@
1
+ module Edupage
2
+ module Models
3
+ # Shared shape of everyone Edupage knows about: students, teachers and parents all
4
+ # carry the same name/gender/validity fields under the same keys.
5
+ class Person < Model
6
+ attribute :first_name, "firstname"
7
+ attribute :last_name, "lastname"
8
+ attribute :gender
9
+ attribute :valid_from, "datefrom", cast: :date
10
+ attribute :valid_to, "dateto", cast: :date
11
+ attribute :inactive, "isOut", cast: :boolean
12
+
13
+ alias inactive? inactive
14
+
15
+ def full_name = [first_name, last_name].compact.join(" ").strip
16
+ alias name full_name
17
+
18
+ # Edupage addresses people by a typed string such as "Student113506" or
19
+ # "Ucitel37135"; it appears all over the timeline payloads.
20
+ def user_string = "#{self.class::USER_STRING_PREFIX}#{id}"
21
+
22
+ def match_strings = (super + [full_name, user_string]).uniq
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,16 @@
1
+ module Edupage
2
+ module Models
3
+ # A class group such as "2.A". Named SchoolClass because `Class` is taken.
4
+ class SchoolClass < Model
5
+ attribute :name
6
+ attribute :short
7
+ attribute :grade, "grade", cast: :integer
8
+ attribute(:teacher) { |d| school&.teacher(d["teacherid"]) }
9
+ attribute(:classroom) { |d| school&.classroom(d["classroomid"]) }
10
+
11
+ def students
12
+ Relation.new(-> { school.students.select { |s| s.school_class == self } })
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,45 @@
1
+ module Edupage
2
+ module Models
3
+ # A pupil. For a parent account these are the account's own children, which is the
4
+ # entry point to everything year- and child-scoped.
5
+ class Student < Person
6
+ USER_STRING_PREFIX = "Student".freeze
7
+
8
+ attribute :number_in_class, "numberinclass", cast: :integer
9
+ attribute :number, "number", cast: :integer
10
+ attribute(:school_class) { |d| school&.school_class(d["classid"]) }
11
+ attribute(:parents) do |d|
12
+ %w[parent1id parent2id parent3id].filter_map { |key| school&.parent(d[key]) }
13
+ end
14
+
15
+ def class_name = school_class&.name
16
+
17
+ # --- year-scoped navigation -------------------------------------------------
18
+ # Grades and terms only exist within a school year, and Edupage keeps the
19
+ # selected year in the session, so they hang off a Year rather than the student.
20
+
21
+ def years = school.years_for(self)
22
+
23
+ def year(id)
24
+ years.find { |y| y.id == id.to_s } or
25
+ raise NotFoundError, "#{full_name} has no school year #{id}"
26
+ end
27
+
28
+ def current_year = years.find(&:current?) || years.first
29
+
30
+ # Convenience delegations: the common case is "this year".
31
+ def grades(...) = current_year.grades(...)
32
+ def terms(...) = current_year.terms(...)
33
+
34
+ # --- child-scoped, year-agnostic --------------------------------------------
35
+
36
+ def timetable(range = nil) = school.timetable_for(self, range)
37
+ def lessons(date = nil) = school.lessons_for(self, date)
38
+ def timeline = school.timeline_for(self)
39
+ def assignments = school.assignments_for(self)
40
+ def homeworks = assignments.where(type: Assignment::HOMEWORK_TYPES)
41
+
42
+ def to_s = "#{full_name}#{class_name ? " (#{class_name})" : ""}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,9 @@
1
+ module Edupage
2
+ module Models
3
+ # A taught subject, e.g. "Slovenský jazyk a literatúra" (SJL).
4
+ class Subject < Model
5
+ attribute :name
6
+ attribute :short
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,25 @@
1
+ module Edupage
2
+ module Models
3
+ # A teacher. `short` is the code that appears in timetable cells.
4
+ class Teacher < Person
5
+ USER_STRING_PREFIX = "Ucitel".freeze
6
+
7
+ attribute :short
8
+ attribute :name_prefix, "nameprefix"
9
+ attribute :name_suffix, "namesuffix"
10
+ attribute :hidden_in_classbook, "cb_hidden", cast: :boolean
11
+ attribute(:classroom) { |d| school&.classroom(d["classroomid"]) }
12
+
13
+ alias hidden_in_classbook? hidden_in_classbook
14
+
15
+ # "Mgr. Mária Kováčová, PhD."
16
+ def full_name_with_titles
17
+ leading = [name_prefix.to_s.strip, full_name].reject(&:empty?).join(" ")
18
+ suffix = name_suffix.to_s.strip
19
+ suffix.empty? ? leading : "#{leading}, #{suffix}"
20
+ end
21
+
22
+ def match_strings = (super + [short]).compact.reject(&:empty?).uniq
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,36 @@
1
+ module Edupage
2
+ module Models
3
+ # Half of a school year ("1. polrok"), as reported by yearterms.
4
+ #
5
+ # `grade_count` comes straight from Edupage and is the cheapest way to tell whether
6
+ # a term is worth fetching at all.
7
+ class Term < Model
8
+ attribute :id, "term"
9
+ attribute :name, "termName"
10
+ attribute :year_id, "yearid"
11
+ attribute :year_name, "yearName"
12
+ attribute :starts_on, "dateFrom", cast: :date
13
+ attribute :ends_on, "dateTo", cast: :date
14
+ attribute :grade_count, "numGrades", cast: :integer
15
+
16
+ attr_reader :year
17
+
18
+ def initialize(data, school: nil, year: nil)
19
+ super(data, school: school)
20
+ @year = year
21
+ end
22
+
23
+ def short = id
24
+
25
+ def grades = Relation.new(-> { year.grades_for(self) })
26
+
27
+ def current?
28
+ return false unless starts_on && ends_on
29
+
30
+ (starts_on..ends_on).cover?(Date.today)
31
+ end
32
+
33
+ def to_s = "#{year_name} #{name} (#{grade_count} grades)"
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,87 @@
1
+ module Edupage
2
+ module Models
3
+ # One entry in the school feed.
4
+ #
5
+ # Edupage packs a second JSON document into the `data` field as a string; its shape
6
+ # depends on the item type. #payload decodes it lazily.
7
+ class TimelineItem < Model
8
+ # The types worth naming; there are many more and they pass through as-is.
9
+ TYPES = {
10
+ "sprava" => :message,
11
+ "nastenka" => :noticeboard,
12
+ "homework" => :homework,
13
+ "znamka" => :grade,
14
+ "student_absent" => :absence,
15
+ "ospravedlnenka" => :absence_note,
16
+ "substitution" => :substitution,
17
+ "timetable" => :timetable,
18
+ "stravamenu" => :canteen_menu,
19
+ "payments" => :payments,
20
+ "signin" => :signin,
21
+ "event" => :event,
22
+ "ucivo" => :curriculum,
23
+ "h_testvysledok" => :test_result,
24
+ "testpridelenie" => :test_assignment
25
+ }.freeze
26
+
27
+ attribute :id, "timelineid"
28
+ attribute :raw_type, "typ"
29
+ attribute :text
30
+ attribute :recipient_user_string, "user"
31
+ attribute :recipient_name, "user_meno"
32
+ attribute :owner_user_string, "vlastnik"
33
+ attribute :owner_name, "vlastnik_meno"
34
+ attribute :created_at, "cas_pridania", cast: :time
35
+ attribute :occurred_at, "cas_udalosti", cast: :time
36
+ attribute :reply_to_id, "reakcia_na"
37
+ attribute :reply_count, "pocet_reakcii", cast: :integer
38
+ attribute :removed, "removed", cast: :boolean
39
+ attribute :other_id, "ineid"
40
+
41
+ alias removed? removed
42
+
43
+ attr_reader :student
44
+
45
+ def initialize(data, school: nil, student: nil)
46
+ super(data, school: school)
47
+ @student = student
48
+ end
49
+
50
+ # A friendly symbol where one exists, otherwise the raw Edupage string.
51
+ def type = TYPES.fetch(raw_type) { raw_type }
52
+
53
+ def reply? = !reply_to_id.nil? && !reply_to_id.to_s.empty?
54
+
55
+ # The embedded JSON document, or an empty hash.
56
+ def payload
57
+ @payload ||= begin
58
+ raw = data["data"]
59
+ parsed = raw.is_a?(String) ? (JSON.parse(raw) rescue nil) : raw
60
+ parsed.is_a?(Hash) ? parsed : {}
61
+ end
62
+ end
63
+
64
+ def owner
65
+ @owner ||= school&.resolve_user_string(owner_user_string)
66
+ end
67
+
68
+ def recipient
69
+ @recipient ||= school&.resolve_user_string(recipient_user_string)
70
+ end
71
+
72
+ def title = payload["nazov"].to_s.empty? ? nil : payload["nazov"]
73
+
74
+ def name = title || text
75
+ def match_strings = [id, title, text, raw_type].compact.map(&:to_s).reject(&:empty?)
76
+
77
+ def to_s
78
+ label = [title, text].compact.map { |s| s.to_s.gsub(/\s+/, " ") }.reject(&:empty?).first
79
+ "#{created_at&.strftime("%Y-%m-%d %H:%M")} [#{type}] #{label}"
80
+ end
81
+
82
+ def to_h
83
+ super.merge(type: type, title: title, payload_keys: payload.keys).compact
84
+ end
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,150 @@
1
+ require "json"
2
+ require "strscan"
3
+
4
+ module Edupage
5
+ module Parsers
6
+ # Every Edupage page ships its data as an argument to a JavaScript call:
7
+ #
8
+ # $j(document).ready(function() { ... .userhome({"dbi":{...}}); });
9
+ #
10
+ # The upstream JS library pulls these out with `fn\(([\s\S]*?)\);` which stops at
11
+ # the first `);` - including one inside a string value, which does occur in message
12
+ # bodies and homework titles. This scans for balanced brackets instead, so the
13
+ # payload is extracted correctly regardless of its contents.
14
+ module Base
15
+ module_function
16
+
17
+ # Returns the parsed JSON argument of the first call to +function+, or nil.
18
+ #
19
+ # +arg+ selects which argument to take. Most calls put the payload first, but
20
+ # /gcall answers with `classbook.fill("Student113506", {...})`.
21
+ def extract_blob(html, function, arg: 0)
22
+ raw = extract_raw(html, function, arg: arg)
23
+ return nil unless raw
24
+
25
+ JSON.parse(raw)
26
+ rescue JSON::ParserError => e
27
+ raise ParseError.new("#{function}(...) argument #{arg} is not valid JSON: #{e.message}", snippet: raw)
28
+ end
29
+
30
+ def extract_blob!(html, function, arg: 0)
31
+ extract_blob(html, function, arg: arg) or
32
+ raise ParseError.new("Could not find #{function}(...) in the response", snippet: html)
33
+ end
34
+
35
+ # The literal source text of the first bracket-balanced argument.
36
+ #
37
+ # Scanning happens on a binary view of the document. StringScanner#pos counts
38
+ # bytes while String#[] counts characters, and these pages are full of Slovak
39
+ # diacritics - mixing the two silently walks off the end of a string literal and
40
+ # starts counting brackets inside it.
41
+ def extract_raw(html, function, arg: 0)
42
+ bytes = html.b
43
+ needle = function.b
44
+ index = 0
45
+
46
+ while (found = bytes.index(needle, index))
47
+ index = found + needle.bytesize
48
+ open = skip_spaces(bytes, index)
49
+ next unless bytes[open] == "("
50
+
51
+ start = argument_start(bytes, open, arg)
52
+ next unless start && ["{", "["].include?(bytes[start])
53
+
54
+ finish = match_bracket(bytes, start)
55
+ return bytes[start..finish].force_encoding(Encoding::UTF_8) if finish
56
+ end
57
+
58
+ nil
59
+ end
60
+
61
+ # ASC settings are plain assignments rather than a call:
62
+ # ASC.gsechash = "803e65e8";
63
+ # ASC.gpid = 62780423;
64
+ def extract_assignments(html, namespace)
65
+ html.scan(/#{Regexp.escape(namespace)}\.([A-Za-z0-9_$]+)\s*=\s*([^\n;]*);/)
66
+ .each_with_object({}) do |(key, value), result|
67
+ value = value.strip
68
+ next if value.start_with?("function")
69
+
70
+ result[key] = (JSON.parse("[#{value}]").first rescue value)
71
+ end
72
+ end
73
+
74
+ def skip_spaces(text, index)
75
+ index += 1 while text[index] =~ /\s/
76
+ index
77
+ end
78
+
79
+ BRACKETS = { "{" => "}", "[" => "]" }.freeze
80
+
81
+ # Matches either a complete JSON string literal or a single structural bracket.
82
+ #
83
+ # Consuming whole string literals in one match is what makes this correct: values
84
+ # in these payloads routinely contain brackets, and the timeline embeds an entire
85
+ # JSON document as an escaped string, so brackets must only be counted outside
86
+ # string literals.
87
+ TOKEN = /"(?:[^"\\]|\\.)*"|[{}\[\]]/m.freeze
88
+
89
+ # As TOKEN, plus the punctuation that separates call arguments.
90
+ ARGUMENT_TOKEN = /"(?:[^"\\]|\\.)*"|[{}\[\](),]/m.freeze
91
+
92
+ # Byte index where argument +wanted+ of a call begins, given the position of the
93
+ # opening parenthesis. Commas inside strings, objects or nested calls do not
94
+ # count as separators.
95
+ def argument_start(text, open, wanted)
96
+ return skip_spaces(text, open + 1) if wanted.zero?
97
+
98
+ scanner = StringScanner.new(text)
99
+ scanner.pos = open + 1
100
+ depth = 0
101
+ seen = 0
102
+
103
+ while scanner.scan_until(ARGUMENT_TOKEN)
104
+ token = scanner.matched
105
+ next if token.start_with?('"')
106
+
107
+ case token
108
+ when "{", "[", "(" then depth += 1
109
+ when "}", "]" then depth -= 1
110
+ when ")"
111
+ return nil if depth.zero? # argument list ended before we got there
112
+
113
+ depth -= 1
114
+ when ","
115
+ next unless depth.zero?
116
+
117
+ seen += 1
118
+ return skip_spaces(text, scanner.pos) if seen == wanted
119
+ end
120
+ end
121
+
122
+ nil
123
+ end
124
+
125
+ # Byte index of the bracket closing the one at +start+, or nil when unbalanced.
126
+ # +text+ must be a binary string; see #extract_raw.
127
+ def match_bracket(text, start)
128
+ scanner = StringScanner.new(text)
129
+ scanner.pos = start + 1
130
+ stack = [BRACKETS[text[start]]]
131
+
132
+ while scanner.scan_until(TOKEN)
133
+ char = scanner.matched
134
+ next if char.start_with?('"') # a whole string literal, skipped wholesale
135
+
136
+ if BRACKETS.key?(char)
137
+ stack.push(BRACKETS[char])
138
+ else
139
+ return nil unless char == stack.last
140
+
141
+ stack.pop
142
+ return scanner.pos - 1 if stack.empty?
143
+ end
144
+ end
145
+
146
+ nil
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,53 @@
1
+ module Edupage
2
+ module Parsers
3
+ # Timetable for an arbitrary date range.
4
+ #
5
+ # /user/ only ever carries about four days. Anything wider goes through /gcall,
6
+ # Edupage's generic widget RPC, which answers with a blob of JavaScript rather than
7
+ # JSON - the payload sits in the second argument of a `classbook.fill(...)` call.
8
+ class Gcall
9
+ PATH = "/gcall".freeze
10
+ CLASSBOOK_PATH = "/dashboard/eb.php?barNoSkin=1".freeze
11
+ FILL = "classbook.fill".freeze
12
+
13
+ def initialize(school)
14
+ @school = school
15
+ end
16
+
17
+ # Returns { "2026-09-21" => {day payload}, ... } for the requested range.
18
+ def load(student:, from:, to:)
19
+ body = @school.session.with_cursor(child: student.id) do |session|
20
+ session.post(PATH, {
21
+ "gpid" => gpid(session),
22
+ "gsh" => gsec_hash,
23
+ "action" => "loadData",
24
+ "datefrom" => from.iso8601,
25
+ "dateto" => to.iso8601
26
+ }).body
27
+ end
28
+
29
+ payload = Base.extract_blob(body, FILL, arg: 1)
30
+ return {} unless payload
31
+
32
+ payload["dates"] || {}
33
+ end
34
+
35
+ private
36
+
37
+ def gsec_hash
38
+ @school.document.gsec_hash or
39
+ raise ParseError, "No gsecHash on the dashboard; /gcall cannot be called without it"
40
+ end
41
+
42
+ # The widget id is minted per page load and appears only in the class book page.
43
+ def gpid(session)
44
+ @gpid ||= begin
45
+ ids = session.get(CLASSBOOK_PATH).body.scan(/gpid="?(\d+)"?/).flatten.uniq
46
+ raise ParseError, "No gpid on the class book page" if ids.empty?
47
+
48
+ ids.last
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end