Ruby爬取教务系统生成课程表

我为什么要虐自己

最近觉得课程格子广告越来越多,乱七八糟的东西越来越多,完全失去了一开始的存在价值,并且没有电脑端app,想查看课程必须拿出手机,而我使用电脑频率要比手机高,所以才有了折腾的动力。

于是我打开日历app,开始添加日程,添加一门课,填写课程名字,填写上课地点,选择日期,选择时间,选择重复方式,选择重复次数,加点备注……

看了一下课程数量,因为上课时间地点不同,每个课程还有可能要添加好几个日程,实在是有点多。

写脚本呗。

日历的格式

我随便导出了一个日程,导出的文件格式是ics,用文本编辑器打开,可以看到下面这样的数据结构。

BEGIN:VCALENDAR
VERSION:2.0
X-WR-CALNAME:课程表
X-APPLE-CALENDAR-COLOR:#F64F00FF
BEGIN:VTIMEZONE
TZID:Asia/Shanghai
X-LIC-LOCATION:Asia/Shanghai
BEGIN:STANDARD
DTSTART:19010101T000000
RDATE;VALUE=DATE-TIME:19010101T000000
TZNAME:CST
TZOFFSETFROM:+080543
TZOFFSETTO:+0800
END:STANDARD
END:VTIMEZONE
BEGIN:VEVENT
DTEND;TZID=Asia/Shanghai:20170420T093500
LAST-MODIFIED:20170110T165154Z
UID:088E2918-EEF6-4860-BFE6-5AA498939D98
DTSTAMP:20170111T050243Z
LOCATION:博B403
DESCRIPTION:测试
SEQUENCE:0
SUMMARY:软件质量保证与测试
DTSTART;TZID=Asia/Shanghai:20170420T080000
CREATED:20170110T165154Z
RRULE:FREQ=WEEKLY;COUNT=8
END:VEVENT
END:VCALENDAR

看着结构还是很清晰的,稍微分析一下可以知道整体包在VCALENDAR中,然后有个VTIMEZONE,然后下面就是日程VEVENT了。Event中又有几个字段,比如SUMMARY表示事件名称,DESCRIPTION表示备注,DTSTART表示事件开始时间,还有DTEND表示事件结束时间等等。于是想到应该可以用代码自动生成这些。

解析教务系统里的课程表

分析原数据

从官网查看本学期课表,检查元素,找到课程表的链接,打开源码,把源码保存到本地以便于分析。

稍微整理一下数据可以得到下面这样的结果。因为数据很多很乱,我用分割线将每一门课分割开来。

Raw

合并课程项

可以看出有的课程很长有的很短,分析发现是因为每门课可能每周有两个上课时间,第二个上课时间被分离到另一个数组了,所以要做一下组装。长度超过6的就把它上面的那条记录取出来,替换掉尾部,代码如下。

courses=[]
temp.each do |x|
  if x[1].length > 6
    courses << x
  else
    courses << (courses.last.slice(0..(-x.length-1)) + x)
  end
end

打印结果如下,符合预期。

合并后的数据

提取有用的信息

courses.each do |c|
  course = {
    name: c[2].to_s,
    credit: c[4].to_f,
    prop: c[5].to_s,
    exam: c[6].to_s,
    teacher: c[7].to_s.delete(" ").split("*"),
    week: c[11].to_s.delete("周上 ").split(","),
    weekday: c[12].to_i,
    order: real_order[c[13].to_i],
    count: c[14].to_i,
    place: c[16].to_s,
    classroom: c[17].to_s
  }
  formatted << course
end

将每一个课程组成Hash对象,存到数组里。
其中week的内容是由"4-11,14-17周上"处理得到的,将它格式化成一个数组,每个元素形如"4-11",任课教师teacher的内容也差不多。

打印结果如下,这样看起来已经很不错了。

格式化后的数据

生成日历格式的数据

课程数据已经基本到手,要做成日历格式的数据,就单独写了个ICalendar类,根据参数来生成相应的日历元数据。网上可以找得到自动生成日历的gem,我看了一下,感觉要用它的API还得学一会文档,也用不了这么多功能,干脆就自己写了。

分析了一下日历的元数据,发现有很多字段是可以省略掉的,比如事件的创建时间,修改时间,这些没有必要存在,之后导入的时候会自动生成,我也不需要控制这个信息。还有UID,估计是用来表示事件的唯一,以免重复导入,也懒得做了,毕竟导入的时候会生成新的日历,不需要操心这个。

于是日历的模版就想好了。

BEGIN:VCALENDAR
    VERSION:2.0
    X-WR-CALNAME:课程表
    X-APPLE-CALENDAR-COLOR:#{@color}
    BEGIN:VTIMEZONE
        TZID:#{@tzid}
        X-LIC-LOCATION:#{@tzid}
    END:VTIMEZONE

    BEGIN:VEVENT
        DTEND;TZID=#{@tzid}:#{e[:end_time]}
        LOCATION:#{e[:location]}
        DESCRIPTION:#{e[:description]}
        SUMMARY:#{e[:summary]}
        DTSTART;TZID=#{@tzid}:#{e[:start_time]}
        RRULE:#{e[:rrule]}
    END:VEVENT
END:VCALENDAR

所以只要用@event变量存放一个课程数组,到时候拼接一下就可以得到所有日程组成的字符串。

代码如下:

class ICalendar
  def initialize(cal_name = '课程表' + Time.now.to_i.to_s)
    @version = 2.0
    @x_wr_calname = cal_name
    @x_apple_calendar_color = '#F64F00FF'
    @tzid = 'Asia/Shanghai'
    @x_lic_location = 'Asia/Shanghai'
    @events = []
  end

  def add_event(options={})
    default_event = {
      start_time: '20170420T080000',
      end_time: '20170420T093500',
      location: '地点',
      description: '备注\n详细描述',
      summary: "标题",
      rrule: 'FREQ=WEEKLY;COUNT=8'
    }
    @events << default_event.merge(options)
  end

  def publish
    gen_header + gen_events + gen_footer
  end

  private

  def gen_header
    header = <<-BAR
BEGIN:VCALENDAR
VERSION:#{@version}
X-WR-CALNAME:#{@x_wr_calname}
X-APPLE-CALENDAR-COLOR:#{@x_apple_calendar_color}
BEGIN:VTIMEZONE
TZID:#{@tzid}
X-LIC-LOCATION:#{@tzid}
END:VTIMEZONE
    BAR
  end

  def gen_events
    events_cal = @events.map do |e|
      event = <<-FOO
BEGIN:VEVENT
DTEND;TZID=#{@tzid}:#{e[:end_time]}
LOCATION:#{e[:location]}
DESCRIPTION:#{e[:description]}
SUMMARY:#{e[:summary]}
DTSTART;TZID=#{@tzid}:#{e[:start_time]}
RRULE:#{e[:rrule]}
END:VEVENT
      FOO
    end
    events_cal.join
  end

  def gen_footer
    "END:VCALENDAR\n"
  end

end

计算上课日期和时间

其实这个是我最懒的去思考的部分,整体思路很清晰,但这里很令人头疼,因为能自动获取的数据只有第几周开始上课,第几周结束,每天第几节课上课,上几节课。所以要求我自己提供一个开学日期,也就是要确定第一周是哪一周,以及每节课的上课下课时间,然后才能计算具体的课程时间。

计算开课日期

第一步先确定那门课程第一次课的日期。

  1. 设置默认开学日期20170220,用这个初始化一个Time实例,得到的具体时间是2017年2月20日0点整。存为变量start_day,然后调用wday方法获取weekday,也就是确定那天是周几,存为变量start_weekday
  2. 根据开课的周次来确定周偏移量,因为开学那周是第一周,所以偏移量为class_start_week - 1。
  3. 根据开课的weekday,也就是周几开课,来确定“日偏移量”,即f[:weekday] - start_weekday表示。
  4. 所以开始上课的那天就是start_day加上周偏移量乘以7天乘以24小时乘以3600秒,再加上日偏移量乘以24小时乘以3600秒。
that_day = start_day + ((class_start_week - 1) * 7 + f[:weekday] - start_weekday) * 24 * 3600

计算课程开始和结束时间

接下来就是用that_day加上上课时间的“秒偏移量”就行了。这里其实很烦,因为每节课的时间是没有什么规律的,起初我认为是第一节课早上8点上课,然后每节课45分钟,每小节课中间休息5分钟,每大节课中间休息15分钟。在这个想法上抽象了一个算法来动态计算,后来发现一节大课有时候是2小节课有时候是3小节课,中午休息的时间和晚上休息的时间都不一样,修正模型太麻烦了,所以就手动录入了每一节课的上下课时间,用查表的形式获取时间。

START_TIME_OF_CLASS = [8 * 3600,
                      8  * 3600 + 50 * 60,
                      9  * 3600 + 50 * 60,
                      10 * 3600 + 40 * 60,
                      11 * 3600 + 30 * 60,
                      14 * 3600,
                      14 * 3600 + 50 * 60,
                      15 * 3600 + 50 * 60,
                      16 * 3600 + 40 * 60,
                      18 * 3600 + 30 * 60,
                      19 * 3600 + 20 * 60,
                      20 * 3600 + 10 * 60]
END_TIME_OF_CLASS = [8 * 3600 + 45 * 60,
                    9  * 3600 + 35 * 60,
                    10 * 3600 + 35 * 60,
                    11 * 3600 + 25 * 60,
                    12 * 3600 + 15 * 60,
                    14 * 3600 + 45 * 60,
                    15 * 3600 + 35 * 60,
                    16 * 3600 + 35 * 60,
                    17 * 3600 + 25 * 60,
                    19 * 3600 + 15 * 60,
                    20 * 3600 + 05 * 60,
                    20 * 3600 + 55 * 60]

于是课程的上课时间就是根据节次,即用节次减1作为下标从表里取出的时间,而下课时间则是由节次减1加上节数减1得到。

s_time = that_day + START_TIME_OF_CLASS[f[:order] - 1]
e_time = that_day + END_TIME_OF_CLASS[f[:order] + f[:count] - 2]

然后格式化成日历所需要的时间格式就行了

s_time.strftime("%Y%m%dT%H%M%S")
# => 20170222T095000

Done

剩下的就是把这些子模块拼接起来,整体的思路就是获取课程原数据,用nokogiri解析并处理得到可控的数据结构,然后遍历每节课程生成一坨events,添加到ICalendar实例的@events变量中。最后用publish方法拼接出完整的日历元数据,写入ics文件,再用邮件发送到指定订阅用户的邮箱中。

使用效果如下:

运行程序获取日历.png

从邮件的附件中下载ics文件,用日历app打开,即可导入日历中,最好是新建一个日历以免出现错误后不方便删除。

导入后结果如下,时间完美正确,还有一些有用的备注信息。

日历截图

完美,开心,告辞。


源码地址:hack_my_school

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 216,287评论 6 498
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,346评论 3 392
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 162,277评论 0 353
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,132评论 1 292
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,147评论 6 388
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,106评论 1 295
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,019评论 3 417
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,862评论 0 274
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,301评论 1 310
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,521评论 2 332
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,682评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,405评论 5 343
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,996评论 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,651评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,803评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,674评论 2 368
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,563评论 2 352

推荐阅读更多精彩内容