Дорогой читатель! Если тебя интересует парсинг html и разработка под Android, то эта статья для тебя. Надеюсь ты найдешь в ней много интересного и полезного. В ней я хочу поделиться своим опытом в данной сфере.
Описание проблемы
Немного обо мне. Я студент третьего курса ИТА ЮФУ. Также как и всем студентам, мне нужно каждый день смотреть расписание занятий. Причём мне нужно знать расписание не только на следующий день, но и на одну-две недели вперёд.
Казалось бы, почему нельзя просто сохранить расписание и пользоваться им? К сожалению, есть ряд причин, которые этому препятствуют, а именно:
- Расписание на одну неделю может сильно отличаться от расписания на другую
- Расписание не постоянно и может меняться
Конечно, есть сайт с расписанием, но он не очень удобен, так как на нём выводится просто сырая таблица с расписанием на 20 недель. Студенту приходится листать большую страницу, в поисках расписания на нужный день. Кроме того, в оффлайн режиме расписание становится недоступным.
Я решил сделать небольшое приложение, которое могло бы парсить сайт с расписанием моего института, и обладало бы следующим набором плюшек:
- Отображение: номера текущей недели, даты, дня недели и расписания на этот день
- Возможность перелистывать расписание кнопками «назад» и «далее»
- При отсутствии интернета показывать последнюю загруженную оффлайн версию расписания
Приступим к экзекуции
Итак, закатав рукава, я приступил к работе. Начать необходимо с малого. А именно — с редактирования файла манифеста. Стоит помнить, что наше приложение будет работать с интернетом и нам очень важно получить соответствующее разрешение:
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="com.example.myapplication">
<uses-permission android:name="android.permission.INTERNET" />
...
</manifest>
Теперь перейдём к интерфейсу. Пока сделаем акцент на функционал и не будем злоупотреблять виджетами. Поэтому я разместил всего четыре виджета: Заголовок, текстовое поле и кнопки: назад и далее.
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:id="@+id/WeekNumber"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Номер недели"
app:layout_constraintTop_toTopOf="parent" />
<EditText
android:id="@+id/timetable"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginBottom="100dp"
android:ems="10"
android:inputType="textMultiLine"
android:text="Расписание"
app:layout_constraintTop_toBottomOf="@+id/WeekNumber"
tools:layout_editor_absoluteX="0dp" />
<Button
android:id="@+id/next"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Далее"
app:layout_constraintBottom_toBottomOf="parent"
tools:layout_editor_absoluteX="0dp"></Button>
<Button
android:id="@+id/down"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Назад"
app:layout_constraintBottom_toTopOf="@+id/next"
tools:layout_editor_absoluteX="0dp"></Button>
</androidx.constraintlayout.widget.ConstraintLayout>
Теперь начнём писать парсинг. Тут нам поможет замечательный парсер с открытым исходным кодом Jsoup. Вариант с использованием WebView я сразу отмёл, так как я посчитал этот способ крайне неудобным. К тому же мне не очень хотелось использовать лишний виджет, без которого легко можно обойтись.
implementation 'org.jsoup:jsoup:1.11.1'
Не стоит забывать, что работа с web для Android — это тяжёлая задача. Чтобы приложение не висло, нужно чтобы работа с web располагалась вне потока UI. Поэтому будем использовать класс AsyncTask. В него мы и заложим основной функционал, а потом просто передадим данные в UI-поток.
Для тех, кто не знаком с AsyncTask, хочу сказать, что данный класс должен располагаться внутри класса вашего activity. Сам класс приведён ниже.
package com.example.myapplication;
import androidx.appcompat.app.AppCompatActivity;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class MainActivity extends AppCompatActivity {
public boolean offline;
public String request;
public String WeekNumber;
public int count;
//Виджеты
public TextView weeknumber;
public EditText timetable;
public Button next;
public Button down;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
offline = false;// Работаем а онлайн режиме
count = 0;//Описание этой переменной будет дано ниже
weeknumber = findViewById(R.id.WeekNumber);
timetable = findViewById(R.id.timetable);
next = findViewById(R.id.next);
down = findViewById(R.id.down);
getting AsyncTask = new getting();
AsyncTask.execute();
}
class getting extends AsyncTask<String, String, String> {
@Override
protected void onPreExecute() {
super.onPreExecute();
//В этом методе код перед началом выполнения фонового процесса
}
@Override
protected String doInBackground(String... params) {
/*Этот метод выполняется в фоне
Тут мы обращаемся к сайту и вытаскиваем его html код
*/
String answer = "";// В эту переменную мы будем класть ответ от сайта. Пока что она пустая
String url = "https://ictis.sfedu.ru/rasp/HTML/82.htm";// Адрес сайта с расписанием
Document document = null;
try {
document = Jsoup.connect(url).get();// Коннектимся и получаем страницу
answer = document.body().html();// Получаем код из тега body страницы
} catch (IOException e) {
// Если произошла ошибка, значит вероятнее всего, отсутствует соединение с интернетом
// Загружаем в переменную answer оффлайн версию из txt файла
try {
BufferedReader read = new BufferedReader(new InputStreamReader(openFileInput("timetable.txt")));
String str = "";
while ((str = read.readLine())!=null){
answer +=str;
}
read.close();
offline = true;//работаем в оффлайн режиме
} catch (FileNotFoundException ex) {
//Если файла с сохранённым расписанием нет, то записываем в answer пустоту
answer = "";
ex.printStackTrace();
} catch (IOException ex) {
ex.printStackTrace();
}
}
//Убираю лишний текст из html
//Заменяю html код отсутствия пары на запись nolessone
//Убираю двойные пробелы
answer = answer.replace("Пары","")
.replace("Время","")
.replace("<br>","br")
.replace("<font face=\"Arial\" size=\"1\"></font><p align=\"CENTER\"><font face=\"Arial\" size=\"1\"></font>","nolessone")//Заменяем "сигнатуру" пустой пары на nolessone
.replace(" ","");
return Jsoup.parse(answer).text();//Вытаскиваем текст из кода в переменной answer и передаём в UI-поток
}
@Override
protected void onPostExecute(String result) {
super.onPostExecute(result);
/*Этот метод выполняется при завершении фонового кода
Сюда возвращаются данные из потока
*/
request = "";//Начинаем формировать ответ
String temp = result.toString();//Делаём временную строку
// Записываем содержимое, в файл timetable.txt, в котором будем хранить оффлайн версию расписания
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(openFileOutput("timetable.txt",MODE_PRIVATE)));
writer.write(temp);
writer.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
boolean start = false;
for(String str:temp.split("Неделя: ")){
if(start) {
//В начало каждой недели добавляем слово newweek и добавляем в request
request += "newweek"+str.split("Расписание")[0] + "\n";
}
start = true;
}
// Добавляем к дням недели приставку newday, для дальнейшей разбивки строки
request = request.replace("Пнд","newdayПнд").replace("Втр","newdayВтр")
.replace("Срд","newdayСрд").replace("Чтв","newdayЧтв")
.replace("Птн","newdayПтн").replace("Сбт","newdayСбт");
/*Получаем дату дня
Если count = 0, то вернётся дата сегодняшнего дня
Если count = -1, то вчерашнего
Если count = 1, то завтрашнего и т.д
*/
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR,count);
Date dayformat = calendar.getTime();
SimpleDateFormat format = new SimpleDateFormat("dd MMMM");
//Выводим результат
timetable.setText(request);
if(offline && !temp.equals("")){
//Уведомляем пользователя, что загружена оффлайн версия расписания
Toast.makeText(getApplicationContext(),"Загружена оффлайн версия расписания!",Toast.LENGTH_LONG).show();
}
//Если наш ответ равен пустоте, значит произошла ошибка
if(temp.equals("")){
Toast.makeText(getApplicationContext(),"Произошла ошибка!",Toast.LENGTH_LONG).show();
}
}
}
}
В итоге мы получим данные вот в таком виде:
Разберём методы, которые мы использовали:
Создаём элемент типа Document
Document document = null;
Получаем страницу
document = Jsoup.connect(url).get();
Теперь достаём содержимое тега body
answer = document.body().html();
В Jsoup также можно получить содержимое других основных тегов. Например можно получить заголовок страницы, используя метод title() и т.д. Метод html() Возвращает html код, а text() — обычный текст без html тегов.
Получив html код, можно преобразовать его в обычный текст, убрав все теги. Это можно сделать с помощью parse(htmlcode).text():
return Jsoup.parse(answer).text();
Хотелось бы поделиться ещё полезными методами Jsoup, которые не были использованы:
Element link = document.select("tag");//выберет элемент с тегом
String url = link.attr("attribute"); // выдаст атрибут тега
На картинке в спойлере выше приведён пример расписания на одну неделю. В действительности нам будет возвращено 20 таких недель. Теперь наша задача найти в этом наборе данных сегодняшний день и вывести его.
Доведение до ума
Итак, что мы имеем? Мы научились приводить html код страницы в строку, которую можно легко распарсить. Это легко можно сделать используя строковые методы .split() и .replace().
В общем случае алгоритм будет выглядеть так.
Сначала получаем нужную дату от Android. Потом делаем два цикла, один вложенный в другой. Первый цикл проходит по неделям, второй, который внутри него, пробегает по дням недели. Если дата данного дня совпадает с датой, полученной от Android, то выводим расписание этого дня в текстовое поле. Однако каждый может написать этот алгоритм по своему. Я прикрепил свою версию его реализации.
package com.example.myapplication;
import androidx.appcompat.app.AppCompatActivity;
import android.os.AsyncTask;
import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.Button;
import android.widget.EditText;
import android.widget.TextView;
import android.widget.Toast;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.text.SimpleDateFormat;
import java.util.Calendar;
import java.util.Date;
public class MainActivity extends AppCompatActivity {
public boolean offline;
public String request;
public String WeekNumber;
public int count;
//Виджеты
public TextView weeknumber;
public EditText timetable;
public Button next;
public Button down;
public void formating(String day){
String DayTimetable = "";
String[] weeks = request.split("newweek");
String DayData = "";//Тут будет день недели и дата
/*
Переменные ниже будут содержать информацию о каждой паре
Всего в день может быть семь пар
массив less содержит названия каждого предмета
массив tich содержит ФИО преподавателя каждого предмета
массив aud содержит аудиторию, в которой будет проходить предмет (например Д-230)
*/
String less[] = new String[7];
String tich[] = new String[7];
String aud[] = new String[7];
for(String thisweek:weeks){//пробегаемся по неделям
if(thisweek.indexOf(day) != -1) {//Если нужный нам день найден в этой неделе то...
WeekNumber = thisweek.split(" ")[0];//Достаём номер недели
for(String thisday:thisweek.split("newday")){//Теперь пробегаемся по дням этой недели
if(thisday.indexOf(day) != -1) {//Если данный день совпадает с нужным нам днём, то...
//Делаем так, тобы перед каждой парой была приставка newless
//пара всегда начинается с соответствующей приставки пр. лек. лаб. и пр.
thisday = thisday.replace("no","newless")
.replace("пр.","newlessпр.")
.replace("лек.","newlessлек.")
.replace("лаб.","newlessлаб.");
int i = 0;
for(String thislessone:thisday.split("newless")) {//Теперь пробегаемся по предметам данного дня
if(i != 0) {
String[] ScienceInformation = thislessone.replace("br ","").split("br");
String science = ScienceInformation[0];
science = science.replace("lessone","Окно");
String ticher = "";
if(ScienceInformation.length > 1)
ticher = ScienceInformation[1];
DayTimetable += i + "-ая: Предмет - " + science+"\n"+ticher+"\n\n";
ticher = ticher.replace("А-","@А-").replace("А-","@Б-")
.replace("В-","@В-").replace("Г-","@Г-")
.replace("Д-","@Д-").replace("Е-","@Е-")
.replace("И-","@И-").replace("K-","@K-");
String Auditory;
if(ticher.split("@").length == 2){
Auditory = "Аудитория: "+ticher.split("@")[1];
}else
Auditory = "Аудитория: Дома";//На случай если пары нет
ticher = ticher.split("@")[0];
if(ticher.length() >0){
ticher = "Преподаватель: "+ticher;
}else{
ticher = "Самоподготовка";
}
if(i==1){
less[i-1] = "1-ая (8:00-9:35) "+science;
}
if(i==2){
less[i-1] = "2-ая (9:50-11:25) "+science;
}
if(i==3){
less[i-1] = "3-ая (11:55-13:30) "+science;
}
if(i==4){
less[i-1] = "4-ая (13:45-15:20) "+science;
}
if(i==5){
less[i-1] = "5-ая (15:50-17:25) "+science;
}
if(i==6){
less[i-1] = "6-ая (17:40-19:15) "+science;
}
if(i==7){
less[i-1] = "7-ая (19:30-21:05) "+science;
}
tich[i-1] = ticher;
aud[i-1] = Auditory;
}else
DayData = thislessone;//При i=0 в thislessone будет дата текущего дня
i++;
}
}
}
}
}
timetable.setText(DayData);//Выводим дату
for(int i = 0; i <=6; i++){
timetable.setText(timetable.getText()+"\n"+less[i]+tich[i]+aud[i]);//Вывод пары, препода и аудитории каждой пары (от нулевой до шестой)
}
weeknumber.setText("Сейчас "+ WeekNumber + " неделя");//Выводим номер неддели
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
offline = false;// Работаем в онлайн режиме
count = 0;
weeknumber = findViewById(R.id.WeekNumber);
timetable = findViewById(R.id.timetable);
next = findViewById(R.id.next);
down = findViewById(R.id.down);
getting getting = new getting();
getting.execute();
//События для кнопок назад и вперёд
next.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
count++;
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR,count);
Date dayformat = calendar.getTime();
SimpleDateFormat format = new SimpleDateFormat("dd MMMM");
formating(format.format(dayformat));
}
});
down.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
count--;
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR,count);
Date dayformat = calendar.getTime();
SimpleDateFormat format = new SimpleDateFormat("dd MMMM");
formating(format.format(dayformat));
}
});
}
class getting extends AsyncTask<String, String, String> {
@Override
protected void onPreExecute() {
super.onPreExecute();
//В этом методе код перед началом выполнения фонового процесса
getSupportActionBar().setTitle("Загрузка...");
}
@Override
protected String doInBackground(String... params) {
/*Этот метод выполняется в фоне
Тут мы обращаемся к сайту и вытаскиваем его html код
*/
String answer = "";// В эту переменную мы будем класть ответ от сайта. Пока что она пустая
String url = "https://ictis.sfedu.ru/rasp/HTML/82.htm";// Адрес сайта с расписанием
Document document = null;
try {
document = Jsoup.connect(url).get();// Коннектимся и получаем страницу
answer = document.body().html();// Получаем код из тега body страницы
} catch (IOException e) {
// Если произошла ошибка, значит вероятнее всего, отсутствует соединение с интернетом
// Загружаем в переменную answer офлайн версию из txt файла
try {
BufferedReader read = new BufferedReader(new InputStreamReader(openFileInput("timetable.txt")));
String str = "";
while ((str = read.readLine())!=null){
answer +=str;
}
read.close();
offline = true;//работаем в оффлайн режиме
} catch (FileNotFoundException ex) {
//Если файла с сохранённым расписанием нет, то записываем в answer пустоту
answer = "";
ex.printStackTrace();
} catch (IOException ex) {
ex.printStackTrace();
}
}
//Убираю лишний текст из html
//Заменяю html код отсутствия пары на запись nolessone
//Убираю двойные пробелы
answer = answer.replace("Пары","")
.replace("Время","")
.replace("<br>","br")
.replace("<font face=\"Arial\" size=\"1\"></font><p align=\"CENTER\"><font face=\"Arial\" size=\"1\"></font>","nolessone")
.replace(" ","");
return Jsoup.parse(answer).text();//Вытаскиваем текст из кода в переменной answer и передаём в UI-поток
}
@Override
protected void onPostExecute(String result) {
super.onPostExecute(result);
/*Этот метод выполняется при завершении фонового кода
Сюда возвращаются данные из потока
*/
request = "";//Начинаем формировать ответ
String temp = result.toString();//Делаём временную строку
// Записываем содержимое, в файл timetable.txt, в котором будем хранить оффлайн версию расписания
try {
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(openFileOutput("timetable.txt",MODE_PRIVATE)));
writer.write(temp);
writer.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}
boolean start = false;
for(String str:temp.split("Неделя: ")){
if(start) {
//В начало каждой недели добавляем слово newweek и добавляем в request
request += "newweek"+str.split("Расписание")[0] + "\n";
}
start = true;
}
// Добавляем к дням недели приставку newday, для дальнейшей разбивки строки
request = request.replace("Пнд","newdayПнд").replace("Втр","newdayВтр")
.replace("Срд","newdayСрд").replace("Чтв","newdayЧтв")
.replace("Птн","newdayПтн").replace("Сбт","newdayСбт");
/*Получаем дату дня
Если count = 0, то вернётся дата сегодняшнего дня
Если count = -1, то вчерашнего
Если count = 1, то завтрашнего и т.д
*/
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR,count);
Date dayformat = calendar.getTime();
SimpleDateFormat format = new SimpleDateFormat("dd MMMM");
//Вызываем функцию, которая будет заниматься представлением данных
formating(format.format(dayformat));
if(offline && !temp.equals("")){
//Уведомляем пользователя, что загружена оффлайн версия расписания
Toast.makeText(getApplicationContext(),"Загружена оффлайн версия расписания!",Toast.LENGTH_LONG).show();
}
//Если наш ответ равен пустоте, значит произошла ошибка
if(temp.equals("")){
Toast.makeText(getApplicationContext(),"Произошла ошибка!",Toast.LENGTH_LONG).show();
}
getSupportActionBar().setTitle("Готово");
}
}
}
выборка расписания происходит в методе formating(). Подав на вход методу дату, мы получим расписание на данный день. Так мы легко можем реализовать код для кнопок «назад» и «далее»
Код кнопки «Далее»:
count++;
Calendar calendar = Calendar.getInstance();
calendar.add(Calendar.DAY_OF_YEAR,count);
Date dayformat = calendar.getTime();
SimpleDateFormat format = new SimpleDateFormat("dd MMMM");
formating(format.format(dayformat));// Вызываем formating
С помощью Calendar мы получаем сегодняшнюю дату. С помощью метода add мы прибавляем к сегодняшней дате количество дней, записанных в count. Код кнопки «назад» будет аналогичен, только из count нужно будет убавлять значение.
Заключение
Конечно, можно поработать над дизайном, но это уже другая тема. Я лишь хотел поделиться основными технологиями. В спойлерах ниже я прикрепил скриншоты с улучшенным дизайном. Также я добавил несколько функций, например: настройки, возможность выбрать учебную группу и т.д. Само приложение можно будет посмотреть чуть позже, как только доведу его до ума.
Комментарии (12)
Xokare228
09.01.2020 15:14Ха ха, у вас есть сайт. У нас старый добрый стенд с огромными листами
maxzh83
09.01.2020 15:44Ну так это же хорошо, можно навернуть еще больше ада. Сначала надо распознать текст (лучше если рукописный), а потом уже все остальное.
Xokare228
09.01.2020 15:47Распознать то не проблема, проблема его получить. Это же надо что бы кто-то сходил, сфотографировал, но даже если этот кто-то найдётся текст будет не в фокусе, с бликами и при ужасном освещении, так что обработка пред распознаванием даст массу новых ощущений в области ниже пояса. И всё равно в самый интересный момент расписание внезапно поменяется. Enjoy
maxzh83
09.01.2020 18:09Это же надо что бы кто-то сходил, сфотографировал
Недавно на хабре была статься про телеграм-бота для добавления людей в футбольную команду. Тут нужен такой же, который будет решать, кто пойдет фоткать.
текст будет не в фокусе, с бликами и при ужасном освещении
А нейронные сети на что?
На самом деле, это все ирония. Просто показалось, что в статье слишком много всего придумано просто «потому что могу».Xokare228
09.01.2020 18:14Недавно на хабре была статься про телеграм-бота для добавления людей в футбольную команду. Тут нужен такой же, который будет решать, кто пойдет фоткать.
Слишком просто, двадцать первый век же. Надо собрать дрона который будет раз в n времени подлетать и фотографировать доску, после чего отправит это всё с помощью лазерного модема
А нейронные сети на что?
Да запросто, достроим и через набор программ от топаза прогоним, ну и немного велосипедов, куда же без них
На самом деле, это все ирония. Просто показалось, что в статье слишком много всего придумано просто «потому что могу».
Да я тоже местами иронизирую. ИМХО тут надо с источником информации работать, а не костыли городить
Alyoshka1976
09.01.2020 16:59Что отобразится, когда при инкрементировании count дата прыгнет на воскресенье?
Spok99 Автор
09.01.2020 17:11Тогда вместо номера недели будет выдан null. В этом случае этот null отслеживается и совершается прыжек еще на один день
402d
09.01.2020 18:28Вы только свое приложение в Гугл плее от своего имени не выкладывайте. Бан Веронике на 99 процентов
censor2005
Чёрт, а я все думал, писать или не писать статью про своё решение проблемы расписаний. Только я сделал помимо андроид приложения Telegram-бота и веб-страничку. Сервер парсит Excel-файл и кидает расписание в базу, а дальше из этой базы бот, приложение и страничка показывают инфу пользователям. Но подумал что никому не открою этой статьёй Америку )
GokenTanmay
Каждый год в разработку приходят тысячи «неокрепших», каждый месяц рефакторятся или создаются новые библиотеки, фреймворки, обертки…
Написав статью, как минимум получишь фидбэк, где можно сделать проще/быстрее, а в максимальном случае — сразу покажешь правильный подход.
censor2005
Ну так то я в разработке около 12 лет. Не скажу что я написал правильный и красивый код, но он максимально удобно решал мою задачу (и задачу 700 студентов и примерно 100 преподавателей), а я сам параллельно познакомился с Android-разработкой.
Собственно, наверное мне просто стыдно за тот код, написанный в процессе, что и останавливало от написания статьи )
Кстати, для тех, кто интересуется генерацией расписаний: есть бесплатный кросплатформенный опенсорс проект FET (https://lalescu.ro/liviu/fet/), интерфейс сложный, но сама программа потрясающе функциональна и позволяет покрыть практически все потребности в автоматической генерации расписаний занятий для вузов/школ итд. А при необходимости можно допилить исходники )