Я пока перечислю - умру.
Надо научиться писать changelog постепенно.
This commit is contained in:
2024-10-19 02:12:37 +04:00
parent ad730e0943
commit 5fe5d56ca9
83 changed files with 3796 additions and 1502 deletions

View File

@@ -0,0 +1,10 @@
import { IsNumber } from "class-validator";
export class ClearScheduleReplacerDto {
/**
* Количество удалённых заменителей расписания
* @example 1
*/
@IsNumber()
count: number;
}

View File

@@ -0,0 +1,14 @@
import { PickType } from "@nestjs/swagger";
import { IsNumber } from "class-validator";
import { SetScheduleReplacerDto } from "./set-schedule-replacer.dto";
export class ScheduleReplacerDto extends PickType(SetScheduleReplacerDto, [
"etag",
]) {
/**
* Размер файла в байтах
* @example 12567
*/
@IsNumber()
size: number;
}

View File

@@ -0,0 +1,23 @@
import { IsMongoId, IsObject, IsString } from "class-validator";
export class SetScheduleReplacerDto {
/**
* Идентификатор заменителя (ObjectId)
* @example "66e6f1c8775ffeda400d7967"
*/
@IsMongoId()
id: string;
/**
* ETag заменяемого расписания
* @example "\"670be780-21e00\""
*/
@IsString()
etag: string;
/**
* Данные файла расписания
*/
@IsObject()
data: ArrayBuffer;
}

View File

@@ -0,0 +1,19 @@
import { V2CacheStatusDto } from "../v2/v2-cache-status.dto";
import { instanceToPlain, plainToClass } from "class-transformer";
import { V1CacheStatusDto } from "./v1-cache-status.dto";
export class CacheStatusDto extends V2CacheStatusDto {
public static stripVersion(instance: CacheStatusDto, version: number) {
switch (version) {
default:
return instance;
case 0: {
return plainToClass(
V1CacheStatusDto,
instanceToPlain(instance),
{ excludeExtraneousValues: true },
);
}
}
}
}

View File

@@ -0,0 +1,21 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBoolean, IsHash } from "class-validator";
import { Expose } from "class-transformer";
export class V1CacheStatusDto {
@ApiProperty({
example: true,
description: "Нужно ли обновить ссылку для скачивания xls?",
})
@IsBoolean()
@Expose()
cacheUpdateRequired: boolean;
@ApiProperty({
example: "e6ff169b01608addf998dbf8f40b019a7f514239",
description: "Хеш последних полученных данных",
})
@IsHash("sha1")
@Expose()
cacheHash: string;
}

View File

@@ -0,0 +1,69 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { V1LessonDto } from "./v1-lesson.dto";
import { V1LessonType } from "../../enum/v1-lesson-type.enum";
export class V1DayDto {
@ApiProperty({
example: "Понедельник",
description: "День недели",
})
@IsString()
name: string;
@ApiProperty({ example: [0, 1, 3], description: "Индексы занятий" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
nonNullIndices: Array<number>;
@ApiProperty({ example: [1, 3], description: "Индексы полных пар" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
defaultIndices: Array<number>;
@ApiProperty({ example: [0], description: "Индексы доп. занятий" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
customIndices: Array<number>;
@ApiProperty({ example: [], description: "Занятия" })
@IsArray()
@ValidateNested({ each: true })
@IsOptional()
@Type(() => V1LessonDto)
lessons: Array<V1LessonDto | null>;
constructor(name: string) {
this.name = name;
this.nonNullIndices = [];
this.defaultIndices = [];
this.customIndices = [];
this.lessons = [];
}
public fillIndices(): void {
this.nonNullIndices = [];
this.defaultIndices = [];
this.customIndices = [];
for (const lessonRawIdx in this.lessons) {
const lessonIdx = Number.parseInt(lessonRawIdx);
const lesson = this.lessons[lessonIdx];
if (lesson === null) continue;
this.nonNullIndices.push(lessonIdx);
(lesson.type === V1LessonType.DEFAULT
? this.defaultIndices
: this.customIndices
).push(lessonIdx);
}
}
}

View File

@@ -0,0 +1,6 @@
import { PartialType, PickType } from "@nestjs/swagger";
import { V1GroupDto } from "./v1-group.dto";
export class V1GroupScheduleNameDto extends PartialType(
PickType(V1GroupDto, ["name"]),
) {}

View File

@@ -0,0 +1,23 @@
import { ApiProperty, OmitType } from "@nestjs/swagger";
import { IsArray, IsObject, ValidateNested } from "class-validator";
import { V1GroupDto } from "./v1-group.dto";
import { Type } from "class-transformer";
import { V1ScheduleDto } from "./v1-schedule.dto";
export class V1GroupScheduleDto extends OmitType(V1ScheduleDto, [
"groups",
"lastChangedDays",
]) {
@ApiProperty({ description: "Расписание группы" })
@IsObject()
group: V1GroupDto;
@ApiProperty({
example: [5, 6],
description: "Обновлённые дни с последнего изменения расписания",
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
lastChangedDays: Array<number>;
}

View File

@@ -0,0 +1,25 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray, IsOptional, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { V1DayDto } from "./v1-day.dto";
export class V1GroupDto {
@ApiProperty({
example: "ИС-214/23",
description: "Название группы",
})
@IsString()
name: string;
@ApiProperty({ example: [], description: "Дни недели" })
@IsArray()
@ValidateNested({ each: true })
@IsOptional()
@Type(() => V1DayDto)
days: Array<V1DayDto | null>;
constructor(name: string) {
this.name = name;
this.days = [];
}
}

View File

@@ -0,0 +1,39 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNumber } from "class-validator";
export class V1LessonTimeDto {
@ApiProperty({
example: 0,
description: "Начало занятия в минутах относительно начала суток",
})
@IsNumber()
start: number;
@ApiProperty({
example: 60,
description: "Конец занятия в минутах относительно начала суток",
})
@IsNumber()
end: number;
constructor(start: number, end: number) {
this.start = start;
this.end = end;
}
static fromString(time: string): V1LessonTimeDto {
time = time.trim().replaceAll(".", ":");
const regex = /(\d+:\d+)-(\d+:\d+)/g;
const parseResult = regex.exec(time);
if (!parseResult) return new V1LessonTimeDto(0, 0);
const start = parseResult[1].split(":");
const end = parseResult[2].split(":");
return new V1LessonTimeDto(
Number.parseInt(start[0]) * 60 + Number.parseInt(start[1]),
Number.parseInt(end[0]) * 60 + Number.parseInt(end[1]),
);
}
}

View File

@@ -0,0 +1,76 @@
import { ApiProperty } from "@nestjs/swagger";
import { V1LessonType } from "../../enum/v1-lesson-type.enum";
import {
IsArray,
IsEnum,
IsNumber,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { V1LessonTimeDto } from "./v1-lesson-time.dto";
import { Type } from "class-transformer";
export class V1LessonDto {
@ApiProperty({
example: V1LessonType.DEFAULT,
description: "Тип занятия",
})
@IsEnum(V1LessonType)
type: V1LessonType;
@ApiProperty({
example: 1,
description: "Индекс пары, если присутствует",
})
@IsNumber()
defaultIndex: number;
@ApiProperty({
example: "Элементы высшей математики",
description: "Название занятия",
})
@IsString()
name: string;
@ApiProperty({
example: new V1LessonTimeDto(0, 60),
description:
"Начало и конец занятия в минутах относительно начала суток",
required: false,
})
@IsOptional()
@Type(() => V1LessonTimeDto)
time: V1LessonTimeDto | null;
@ApiProperty({ example: ["42", "с\\з"], description: "Кабинеты" })
@IsArray()
@ValidateNested({ each: true })
@Type(() => String)
cabinets: Array<string>;
@ApiProperty({
example: ["Хомченко Н.Е."],
description: "ФИО преподавателей",
})
@IsArray()
@ValidateNested({ each: true })
@Type(() => String)
teacherNames: Array<string>;
constructor(
type: V1LessonType,
defaultIndex: number,
time: V1LessonTimeDto,
name: string,
cabinets: Array<string>,
teacherNames: Array<string>,
) {
this.type = type;
this.defaultIndex = defaultIndex;
this.time = time;
this.name = name;
this.cabinets = cabinets;
this.teacherNames = teacherNames;
}
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsArray } from "class-validator";
export class V1ScheduleGroupNamesDto {
@ApiProperty({
example: ["ИС-214/23", "ИС-213/23"],
description: "Список названий всех групп в текущем расписании",
})
@IsArray()
names: Array<string>;
}

View File

@@ -0,0 +1,36 @@
import { IsDate, IsObject, IsOptional } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";
import { Transform, Type } from "class-transformer";
export class V1ScheduleDto {
@ApiProperty({
example: new Date(),
description:
"Дата когда последний раз расписание было скачано с сервера политехникума",
})
@IsDate()
updatedAt: Date;
@ApiProperty({ description: "Расписание групп" })
@IsObject()
@IsOptional()
groups: any;
@ApiProperty({
example: { "ИС-214/23": [5, 6] },
description: "Обновлённые дни с последнего изменения расписания",
})
@IsObject()
@Type(() => Object)
@Transform(({ value }) => {
const object = {};
for (const key in value) {
object[key] = value[key];
}
return object;
})
@Type(() => Object)
lastChangedDays: Array<Array<number>>;
}

View File

@@ -0,0 +1,11 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsBase64 } from "class-validator";
export class V1SiteMainPageDto {
@ApiProperty({
example: "MHz=",
description: "Страница политехникума",
})
@IsBase64()
mainPage: string;
}

View File

@@ -0,0 +1,18 @@
import { V1CacheStatusDto } from "../v1/v1-cache-status.dto";
import { IsNumber } from "class-validator";
export class V2CacheStatusDto extends V1CacheStatusDto {
/**
* Дата обновления кеша
* @example 1729288173002
*/
@IsNumber()
lastCacheUpdate: number;
/**
* Дата обновления расписания
* @example 1729288173002
*/
@IsNumber()
lastScheduleUpdate: number;
}

View File

@@ -0,0 +1,32 @@
import {
IsArray,
IsDateString,
IsString,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { V2LessonDto } from "./v2-lesson.dto";
export class V2DayDto {
/**
* День недели
* @example Понедельник
*/
@IsString()
name: string;
/**
* Дата
* @example "2024-10-06T20:00:00.000Z"
*/
@IsDateString()
date: Date;
/**
* Занятия
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => V2LessonDto)
lessons: Array<V2LessonDto>;
}

View File

@@ -0,0 +1,6 @@
import { PartialType, PickType } from "@nestjs/swagger";
import { V1GroupDto } from "../v1/v1-group.dto";
export class V2GroupScheduleByNameDto extends PartialType(
PickType(V1GroupDto, ["name"]),
) {}

View File

@@ -0,0 +1,22 @@
import { PickType } from "@nestjs/swagger";
import { IsArray, IsObject, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { V2ScheduleDto } from "./v2-schedule.dto";
import { V2GroupDto } from "./v2-group.dto";
export class V2GroupScheduleDto extends PickType(V2ScheduleDto, ["updatedAt"]) {
/**
* Расписание группы
*/
@IsObject()
group: V2GroupDto;
/**
* Обновлённые дни с последнего изменения расписания
* @example [5, 6]
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
updated: Array<number>;
}

View File

@@ -0,0 +1,20 @@
import { IsArray, IsString, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { V2DayDto } from "./v2-day.dto";
export class V2GroupDto {
/**
* Название группы
* @example "ИС-214/23"
*/
@IsString()
name: string;
/**
* Расписание каждого дня
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => V2DayDto)
days: Array<V2DayDto>;
}

View File

@@ -0,0 +1,26 @@
import { IsNumber, IsOptional, IsString } from "class-validator";
export class V2LessonSubGroupDto {
/**
* Номер подгруппы
* @example 1
*/
@IsNumber()
number: number;
/**
* Кабинет
* @example "с\з"
* @example "42"
*/
@IsString()
@IsOptional()
cabinet: string | null;
/**
* ФИО преподавателя
* @example "Хомченко Н.Е."
*/
@IsString()
teacher: string;
}

View File

@@ -0,0 +1,17 @@
import { IsDateString } from "class-validator";
export class V2LessonTimeDto {
/**
* Начало занятия
* @example "2024-10-07T04:30:00.000Z"
*/
@IsDateString()
start: Date;
/**
* Конец занятия
* @example "2024-10-07T04:40:00.000Z"
*/
@IsDateString()
end: Date;
}

View File

@@ -0,0 +1,67 @@
import "reflect-metadata";
import { V2LessonType } from "../../enum/v2-lesson-type.enum";
import {
IsArray,
IsEnum,
IsOptional,
IsString,
ValidateNested,
} from "class-validator";
import { Type } from "class-transformer";
import { NullIf } from "../../../utility/class-validators/conditional-field";
import { V2LessonTimeDto } from "./v2-lesson-time.dto";
import { V2LessonSubGroupDto } from "./v2-lesson-sub-group.dto";
export class V2LessonDto {
/**
* Тип занятия
* @example DEFAULT
*/
@IsEnum(V2LessonType)
type: V2LessonType;
/**
* Индексы пар, если присутствуют
* @example [1, 3]
* @optional
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => Number)
@IsOptional()
@NullIf((self: V2LessonDto) => {
return self.type !== V2LessonType.DEFAULT;
})
defaultRange: Array<number> | null;
/**
* Название занятия
* @example "Элементы высшей математики"
* @optional
*/
@IsString()
@IsOptional()
@NullIf((self: V2LessonDto) => {
return self.type === V2LessonType.BREAK;
})
name: string | null;
/**
* Начало и конец занятия
*/
@Type(() => V2LessonTimeDto)
time: V2LessonTimeDto;
/**
* Тип занятия
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => V2LessonSubGroupDto)
@IsOptional()
@NullIf((self: V2LessonDto) => {
return self.type !== V2LessonType.DEFAULT;
})
subGroups: Array<V2LessonSubGroupDto> | null;
}

View File

@@ -0,0 +1,10 @@
import { IsArray } from "class-validator";
export class V2ScheduleGroupNamesDto {
/**
* Группы
* @example ["ИС-214/23", "ИС-213/23"]
*/
@IsArray()
names: Array<string>;
}

View File

@@ -0,0 +1,29 @@
import { IsArray, IsDate, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { V2GroupDto } from "./v2-group.dto";
export class V2ScheduleDto {
/**
* Дата когда последний раз расписание было скачано с сервера политехникума
* @example "2024-10-18T21:50:06.680Z"
*/
@IsDate()
updatedAt: Date;
/**
* Расписание групп
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => V2GroupDto)
groups: Array<V2GroupDto>;
/**
* Обновлённые дни с последнего изменения расписания
* @example { "ИС-214/23": [4, 5] }
*/
@IsArray()
@ValidateNested({ each: true })
@Type(() => Array<number>)
updatedGroups: Array<Array<number>>;
}

View File

@@ -0,0 +1,10 @@
import { IsUrl } from "class-validator";
export class V2UpdateDownloadUrlDto {
/**
* Прямая ссылка на скачивание расписания
* @example "https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls"
*/
@IsUrl()
url: string;
}

View File

@@ -0,0 +1,4 @@
export enum V1LessonType {
DEFAULT = 0,
CUSTOM,
}

View File

@@ -0,0 +1,5 @@
export enum V2LessonType {
DEFAULT = 0,
ADDITIONAL,
BREAK,
}

View File

@@ -1,34 +1,34 @@
import {
XlsDownloaderBase,
XlsDownloaderCacheMode,
} from "../xls-downloader/xls-downloader.base";
import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interface";
import * as XLSX from "xlsx";
import {
DayDto,
GroupDto,
LessonDto,
LessonTimeDto,
LessonTypeDto,
} from "../../../dto/schedule.dto";
import { toNormalString, trimAll } from "../../../utility/string.util";
import { V1LessonTimeDto } from "../../dto/v1/v1-lesson-time.dto";
import { V1LessonType } from "../../enum/v1-lesson-type.enum";
import { V1LessonDto } from "../../dto/v1/v1-lesson.dto";
import { V1DayDto } from "../../dto/v1/v1-day.dto";
import { V1GroupDto } from "../../dto/v1/v1-group.dto";
import { ScheduleReplacerService } from "../../schedule-replacer.service";
import * as assert from "node:assert";
type InternalId = { row: number; column: number; name: string };
type InternalDay = InternalId;
export class ScheduleParseResult {
etag: string;
groups: Array<GroupDto>;
replacerId?: string;
groups: Array<V1GroupDto>;
affectedDays: Array<Array<number>>;
updateRequired: boolean;
}
type CellData = XLSX.CellObject["v"];
export class ScheduleParser {
export class V1ScheduleParser {
private lastResult: ScheduleParseResult | null = null;
public constructor(private readonly xlsDownloader: XlsDownloaderBase) {}
public constructor(
private readonly xlsDownloader: XlsDownloaderInterface,
private readonly scheduleReplacerService: ScheduleReplacerService,
) {}
private static getCellData(
worksheet: XLSX.Sheet,
@@ -83,7 +83,7 @@ export class ScheduleParser {
const days: Array<InternalDay> = [];
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
const dayName = ScheduleParser.getCellData(worksheet, row, 0);
const dayName = V1ScheduleParser.getCellData(worksheet, row, 0);
if (!dayName) continue;
if (!isHeaderParsed) {
@@ -95,7 +95,7 @@ export class ScheduleParser {
column <= range.e.c;
++column
) {
const groupName = ScheduleParser.getCellData(
const groupName = V1ScheduleParser.getCellData(
worksheet,
row,
column,
@@ -134,37 +134,51 @@ export class ScheduleParser {
return { daySkeletons: days, groupSkeletons: groups };
}
getXlsDownloader(): XlsDownloaderBase {
getXlsDownloader(): XlsDownloaderInterface {
return this.xlsDownloader;
}
async getSchedule(
forceCached: boolean = false,
): Promise<ScheduleParseResult> {
if (forceCached && this.lastResult !== null) return this.lastResult;
async getSchedule(): Promise<ScheduleParseResult> {
const headData = await this.xlsDownloader.fetch(true);
this.xlsDownloader.verifyFetchResult(headData);
const downloadData = await this.xlsDownloader.downloadXLS();
assert(headData.type === "success");
if (
!downloadData.new &&
this.lastResult &&
this.xlsDownloader.getCacheMode() !== XlsDownloaderCacheMode.NONE
)
return this.lastResult;
const replacer = await this.scheduleReplacerService.getByEtag(
headData.etag,
);
const workBook = XLSX.read(downloadData.fileData);
if (this.lastResult && this.lastResult.etag === headData.etag) {
if (!replacer) return this.lastResult;
if (this.lastResult.replacerId === replacer.id)
return this.lastResult;
}
const buffer = async () => {
if (replacer) return replacer.data;
const downloadData = await this.xlsDownloader.fetch(false);
this.xlsDownloader.verifyFetchResult(downloadData);
assert(downloadData.type === "success");
return downloadData.data;
};
const workBook = XLSX.read(await buffer());
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
const { groupSkeletons, daySkeletons } = this.parseSkeleton(workSheet);
const groups: Array<GroupDto> = [];
const groups: Array<V1GroupDto> = [];
for (const groupSkeleton of groupSkeletons) {
const group = new GroupDto(groupSkeleton.name);
const group = new V1GroupDto(groupSkeleton.name);
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
const daySkeleton = daySkeletons[dayIdx];
const day = new DayDto(daySkeleton.name);
const day = new V1DayDto(daySkeleton.name);
const lessonTimeColumn = daySkeletons[0].column + 1;
const rowDistance =
@@ -176,7 +190,7 @@ export class ScheduleParser {
++row
) {
// time
const time = ScheduleParser.getCellData(
const time = V1ScheduleParser.getCellData(
workSheet,
row,
lessonTimeColumn,
@@ -186,7 +200,7 @@ export class ScheduleParser {
// name
const rawName: CellData = trimAll(
ScheduleParser.getCellData(
V1ScheduleParser.getCellData(
workSheet,
row,
groupSkeleton.column,
@@ -201,7 +215,7 @@ export class ScheduleParser {
// cabinets
const cabinets: Array<string> = [];
const rawCabinets = ScheduleParser.getCellData(
const rawCabinets = V1ScheduleParser.getCellData(
workSheet,
row,
groupSkeleton.column + 1,
@@ -219,8 +233,8 @@ export class ScheduleParser {
// type
const lessonType = time?.includes("пара")
? LessonTypeDto.DEFAULT
: LessonTypeDto.CUSTOM;
? V1LessonType.DEFAULT
: V1LessonType.CUSTOM;
// full names
const { name, teacherFullNames } =
@@ -229,13 +243,13 @@ export class ScheduleParser {
);
day.lessons.push(
new LessonDto(
new V1LessonDto(
lessonType,
lessonType === LessonTypeDto.DEFAULT
lessonType === V1LessonType.DEFAULT
? Number.parseInt(time[0])
: -1,
LessonTimeDto.fromString(
lessonType === LessonTypeDto.DEFAULT
V1LessonTimeDto.fromString(
lessonType === V1LessonType.DEFAULT
? time.substring(5)
: time,
),
@@ -256,16 +270,16 @@ export class ScheduleParser {
}
return (this.lastResult = {
etag: downloadData.etag,
etag: headData.etag,
replacerId: replacer?.id,
groups: groups,
affectedDays: this.getAffectedDays(this.lastResult?.groups, groups),
updateRequired: downloadData.updateRequired,
});
}
private getAffectedDays(
cachedGroups: Array<GroupDto> | null,
groups: Array<GroupDto>,
cachedGroups: Array<V1GroupDto> | null,
groups: Array<V1GroupDto>,
): Array<Array<number>> {
const affectedDays: Array<Array<number>> = [];
@@ -273,8 +287,8 @@ export class ScheduleParser {
// noinspection SpellCheckingInspection
const dayEquals = (
lday: DayDto | null,
rday: DayDto | undefined,
lday: V1DayDto | null,
rday: V1DayDto | undefined,
): boolean => {
if (!lday || !rday || rday.lessons.length != lday.lessons.length)
return false;

View File

@@ -0,0 +1,116 @@
import { V2ScheduleParser } from "./v2-schedule-parser";
import { BasicXlsDownloader } from "../xls-downloader/basic-xls-downloader";
import { V2DayDto } from "../../dto/v2/v2-day.dto";
import { V2GroupDto } from "../../dto/v2/v2-group.dto";
describe("V2ScheduleParser", () => {
let parser: V2ScheduleParser;
beforeEach(async () => {
const xlsDownloader = new BasicXlsDownloader();
parser = new V2ScheduleParser(xlsDownloader);
});
describe("Ошибки", () => {
it("Должен вернуть ошибку из-за отсутствия ссылки на скачивание", async () => {
await expect(() => parser.getSchedule()).rejects.toThrow();
});
});
async function setLink(link: string): Promise<void> {
await parser.getXlsDownloader().setDownloadUrl(link);
}
const defaultTest = async () => {
const schedule = await parser.getSchedule();
expect(schedule).toBeDefined();
};
const nameTest = async () => {
const schedule = await parser.getSchedule();
expect(schedule).toBeDefined();
const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"];
expect(group).toBeDefined();
const saturday: V2DayDto = group.days[5];
expect(saturday).toBeDefined();
const name = saturday.name;
expect(name).toBeDefined();
expect(name.length).toBeGreaterThan(0);
};
describe("Старое расписание", () => {
beforeEach(async () => {
await setLink(
"https://politehnikum-eng.ru/2024/poltavskaja_06_s_07_po_13_10.xls",
);
});
it("Должен вернуть расписание", defaultTest);
it("Название дня не должно быть пустым или null", nameTest);
});
describe("Новое расписание", () => {
beforeEach(async () => {
await setLink(
"https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-8-1-.xls",
);
});
it("Должен вернуть расписание", defaultTest);
it("Название дня не должно быть пустым или null", nameTest);
it("Парсер должен вернуть корректное время если она на нескольких линиях", async () => {
const schedule = await parser.getSchedule();
expect(schedule).toBeDefined();
const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"];
expect(group).toBeDefined();
const saturday: V2DayDto = group.days[5];
expect(saturday).toBeDefined();
const firstLesson = saturday.lessons[0];
expect(firstLesson).toBeDefined();
expect(firstLesson.time).toBeDefined();
expect(firstLesson.time.start).toBeDefined();
expect(firstLesson.time.end).toBeDefined();
const startMinutes =
firstLesson.time.start.getHours() * 60 +
firstLesson.time.start.getMinutes();
const endMinutes =
firstLesson.time.end.getHours() * 60 +
firstLesson.time.end.getMinutes();
const differenceMinutes = endMinutes - startMinutes;
expect(differenceMinutes).toBe(190);
expect(firstLesson.defaultRange).toStrictEqual([1, 3]);
});
it("Ошибка парсинга?", async () => {
const schedule = await parser.getSchedule();
expect(schedule).toBeDefined();
const group: V2GroupDto | undefined = schedule.groups["ИС-214/23"];
expect(group).toBeDefined();
const thursday: V2DayDto = group.days[3];
expect(thursday).toBeDefined();
expect(thursday.lessons.length).toBe(5);
const lastLessonName = thursday.lessons[4].name;
expect(lastLessonName).toBe(
"МДК.05.01 Проектирование и дизайн информационных систем",
);
});
});
});

View File

@@ -0,0 +1,673 @@
import { XlsDownloaderInterface } from "../xls-downloader/xls-downloader.interface";
import * as XLSX from "xlsx";
import { Range, WorkSheet } from "xlsx";
import { toNormalString, trimAll } from "../../../utility/string.util";
import { plainToClass, plainToInstance } from "class-transformer";
import * as objectHash from "object-hash";
import { V2LessonTimeDto } from "../../dto/v2/v2-lesson-time.dto";
import { V2LessonType } from "../../enum/v2-lesson-type.enum";
import { V2LessonSubGroupDto } from "../../dto/v2/v2-lesson-sub-group.dto";
import { V2LessonDto } from "../../dto/v2/v2-lesson.dto";
import { V2DayDto } from "../../dto/v2/v2-day.dto";
import { V2GroupDto } from "../../dto/v2/v2-group.dto";
import * as assert from "node:assert";
import { ScheduleReplacerService } from "../../schedule-replacer.service";
type InternalId = {
/**
* Индекс строки
*/
row: number;
/**
* Индекс столбца
*/
column: number;
/**
* Текст записи
*/
name: string;
};
type InternalTime = {
/**
* Временной отрезок
*/
timeRange: V2LessonTimeDto;
/**
* Тип пары на этой строке
*/
lessonType: V2LessonType;
/**
* Индекс пары на этой строке
*/
defaultIndex?: number;
/**
* Позиции начальной и конечной записи
*/
xlsxRange: Range;
};
export class V2ScheduleParseResult {
/**
* ETag расписания
*/
etag: string;
/**
* Идентификатор заменённого расписания (ObjectId)
*/
replacerId?: string;
/**
* Дата загрузки расписания на сайт политехникума
*/
uploadedAt: Date;
/**
* Дата загрузки расписания с сайта политехникума
*/
downloadedAt: Date;
/**
* Расписание групп в виде списка.
* Ключ - название группы.
*/
groups: Array<V2GroupDto>;
/**
* Список групп у которых было обновлено расписание с момента последнего обновления файла.
* Ключ - название группы.
*/
updatedGroups: Array<Array<number>>;
}
export class V2ScheduleParser {
private lastResult: V2ScheduleParseResult | null = null;
/**
* @param xlsDownloader - класс для загрузки расписания с сайта политехникума
* @param scheduleReplacerService - сервис для подмены расписания
*/
public constructor(
private readonly xlsDownloader: XlsDownloaderInterface,
private readonly scheduleReplacerService?: ScheduleReplacerService,
) {}
/**
* Получает позиции начальной и конечной записи относительно начальной записи
* @param workSheet - xls лист
* @param topRow - индекс начальной строки
* @param leftColumn - индекс начального столбца
* @returns {Range} - позиции начальной и конечной записи
* @private
* @static
*/
private static getMergeFromStart(
workSheet: XLSX.WorkSheet,
topRow: number,
leftColumn: number,
): Range {
for (const range of workSheet["!merges"]) {
if (topRow === range.s.r && leftColumn === range.s.c) return range;
}
return {
s: { r: topRow, c: leftColumn },
e: { r: topRow, c: leftColumn },
};
}
/**
* Получает текст из требуемой записи
* @param worksheet - xls лист
* @param row - индекс строки
* @param column - индекс столбца
* @returns {string | null} - текст записи, если присутствует
* @private
* @static
*/
private static getCellData(
worksheet: XLSX.WorkSheet,
row: number,
column: number,
): string | null {
const cell: XLSX.CellObject | null =
worksheet[XLSX.utils.encode_cell({ r: row, c: column })];
return toNormalString(cell?.w);
}
/**
* Парсит информацию о паре исходя из текста в записи
* @param lessonName - текст в записи
* @returns {{
* name: string;
* subGroups: Array<V2LessonSubGroupDto>;
* }} - название пары и список подгрупп
* @private
* @static
*/
private static parseNameAndSubGroups(lessonName: string): {
name: string;
subGroups: Array<V2LessonSubGroupDto>;
} {
// хд
const allRegex =
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\([0-9]\s?подгруппа\))?(?:,\s)?)+$/gm;
const teacherAndSubGroupRegex =
/(?:[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\.(?:\s?\([0-9]\s?подгруппа\))?)+/gm;
const allMatch = allRegex.exec(lessonName);
// если не ничё не найдено
if (allMatch === null) return { name: lessonName, subGroups: [] };
const all: Array<string> = [];
let allInnerMatch: RegExpExecArray;
while (
(allInnerMatch = teacherAndSubGroupRegex.exec(allMatch[0])) !== null
) {
if (allInnerMatch.index === teacherAndSubGroupRegex.lastIndex)
teacherAndSubGroupRegex.lastIndex++;
all.push(allInnerMatch[0].trim());
}
// парадокс
if (all.length === 0) {
throw new Error("Парадокс");
}
const subGroups: Array<V2LessonSubGroupDto> = [];
for (const teacherAndSubGroup of all) {
const teacherRegex = /[А-ЯЁ][а-яё]+\s[А-ЯЁ]\.\s?[А-ЯЁ]\./g;
const subGroupRegex = /\([0-9]\s?подгруппа\)/g;
const teacherMatch = teacherRegex.exec(teacherAndSubGroup);
if (teacherMatch === null) throw new Error("Парадокс");
let teacherFIO = teacherMatch[0];
const teacherSpaceIndex = teacherFIO.indexOf(" ") + 1;
const teacherIO = teacherFIO
.substring(teacherSpaceIndex)
.replaceAll("s", "");
teacherFIO = `${teacherFIO.substring(0, teacherSpaceIndex)}${teacherIO}`;
const subGroupMatch = subGroupRegex.exec(teacherAndSubGroup);
const subGroup = subGroupMatch
? Number.parseInt(subGroupMatch[0][1])
: 1;
subGroups.push(
plainToClass(V2LessonSubGroupDto, {
teacher: teacherFIO,
number: subGroup,
cabinet: "",
}),
);
}
for (const index in subGroups) {
if (subGroups.length === 1) {
break;
}
// бляздец
switch (index) {
case "0":
subGroups[index].number =
subGroups[+index + 1].number === 2 ? 1 : 2;
continue;
case "1":
subGroups[index].number =
subGroups[+index - 1].number === 1 ? 2 : 1;
continue;
default:
subGroups[index].number = +index;
}
}
return {
name: lessonName.substring(0, allMatch.index).trim(),
subGroups: subGroups,
};
}
/**
* Парсит информацию о группах и днях недели
* @param workSheet - xls лист
* @returns {{
* groupSkeletons: Array<InternalId>;
* daySkeletons: Array<InternalId>;
* }} - список с индексами и текстом записей групп и дней недели
* @private
* @static
*/
private static parseSkeleton(workSheet: XLSX.WorkSheet): {
groupSkeletons: Array<InternalId>;
daySkeletons: Array<InternalId>;
} {
const range = XLSX.utils.decode_range(workSheet["!ref"] || "");
let isHeaderParsed: boolean = false;
const groups: Array<InternalId> = [];
const days: Array<InternalId> = [];
for (let row = range.s.r + 1; row <= range.e.r; ++row) {
const dayName = V2ScheduleParser.getCellData(workSheet, row, 0);
if (!dayName) continue;
if (!isHeaderParsed) {
isHeaderParsed = true;
--row;
for (
let column = range.s.c + 2;
column <= range.e.c;
++column
) {
const groupName = V2ScheduleParser.getCellData(
workSheet,
row,
column,
);
if (!groupName) continue;
groups.push({ row: row, column: column, name: groupName });
}
++row;
}
if (
days.length == 0 ||
!days[days.length - 1].name.startsWith("Суббота")
) {
const dayMonthIdx = /[А-Яа-я]+\s(\d+)\.\d+\.\d+/.exec(
trimAll(dayName),
);
if (dayMonthIdx === null) continue;
}
days.push({
row: row,
column: 0,
name: dayName,
});
if (
days.length > 2 &&
days[days.length - 2].name.startsWith("Суббота")
)
break;
}
return { daySkeletons: days, groupSkeletons: groups };
}
/**
* Возвращает текущий класс для скачивания xls файлов
* @returns {XlsDownloaderInterface} - класс для скачивания xls файлов
*/
getXlsDownloader(): XlsDownloaderInterface {
return this.xlsDownloader;
}
/**
* Возвращает текущее расписание
* @returns {V2ScheduleParseResult} - расписание
* @async
*/
async getSchedule(): Promise<V2ScheduleParseResult> {
const headData = await this.xlsDownloader.fetch(true);
this.xlsDownloader.verifyFetchResult(headData);
assert(headData.type === "success");
const replacer = this.scheduleReplacerService
? await this.scheduleReplacerService.getByEtag(headData.etag)
: null;
if (this.lastResult && this.lastResult.etag === headData.etag) {
if (!replacer) return this.lastResult;
if (this.lastResult.replacerId === replacer.id)
return this.lastResult;
}
const buffer = async () => {
if (replacer) return replacer.data;
const downloadData = await this.xlsDownloader.fetch(false);
this.xlsDownloader.verifyFetchResult(downloadData);
assert(downloadData.type === "success");
return downloadData.data;
};
const workBook = XLSX.read(await buffer());
const workSheet = workBook.Sheets[workBook.SheetNames[0]];
const { groupSkeletons, daySkeletons } =
V2ScheduleParser.parseSkeleton(workSheet);
const groups: Array<V2GroupDto> = [];
const daysTimes: Array<Array<InternalTime>> = [];
let daysTimesFilled = false;
for (const groupSkeleton of groupSkeletons) {
const group = new V2GroupDto();
group.name = groupSkeleton.name;
group.days = [];
for (let dayIdx = 0; dayIdx < daySkeletons.length - 1; ++dayIdx) {
const daySkeleton = daySkeletons[dayIdx];
const day = new V2DayDto();
{
const daySpaceIndex = daySkeleton.name.indexOf(" ");
day.name = daySkeleton.name.substring(0, daySpaceIndex);
const dateString = daySkeleton.name.substring(
daySpaceIndex + 1,
);
const parseableDateString = `${dateString.substring(3, 5)}.${dateString.substring(0, 2)}.${dateString.substring(6)}`;
day.date = new Date(Date.parse(parseableDateString));
day.lessons = [];
}
const lessonTimeColumn = daySkeletons[0].column + 1;
const rowDistance =
daySkeletons[dayIdx + 1].row - daySkeleton.row;
const dayTimes: Array<InternalTime> = daysTimesFilled
? daysTimes[day.name]
: [];
if (!daysTimesFilled) {
for (
let row = daySkeleton.row;
row < daySkeleton.row + rowDistance;
++row
) {
const time = V2ScheduleParser.getCellData(
workSheet,
row,
lessonTimeColumn,
)?.replaceAll(/[\s\t\n\r]/g, "");
if (!time) continue;
// type
const lessonType = time.includes("пара")
? V2LessonType.DEFAULT
: V2LessonType.ADDITIONAL;
const defaultIndex =
lessonType === V2LessonType.DEFAULT
? +time[0]
: null;
// time
const timeRange = new V2LessonTimeDto();
timeRange.start = new Date(day.date);
timeRange.end = new Date(day.date);
const timeString = time.replaceAll(".", ":");
const timeRegex = /(\d+:\d+)-(\d+:\d+)/g;
const parseResult = timeRegex.exec(timeString);
if (!parseResult) {
throw new Error(
"Не удалось узнать начало и конец пар!",
);
}
const startStrings = parseResult[1].split(":");
timeRange.start.setHours(+startStrings[0]);
timeRange.start.setMinutes(+startStrings[1]);
const endStrings = parseResult[2].split(":");
timeRange.end.setHours(+endStrings[0]);
timeRange.end.setMinutes(+endStrings[1]);
dayTimes.push({
timeRange: timeRange,
lessonType: lessonType,
defaultIndex: defaultIndex,
xlsxRange: V2ScheduleParser.getMergeFromStart(
workSheet,
row,
lessonTimeColumn,
),
} as InternalTime);
}
daysTimes[day.name] = dayTimes;
}
for (const time of dayTimes) {
// if (day.name === "Четверг" && group.name === "ИС-214/23") {
// console.log("-------------------");
// console.log(groupSkeleton.column);
// console.log(time.xlsxRange);
// }
const lessons = V2ScheduleParser.parseLesson(
workSheet,
day,
dayTimes,
time,
groupSkeleton.column,
);
for (const lesson of lessons) day.lessons.push(lesson);
}
group.days.push(day);
}
if (!daysTimesFilled) daysTimesFilled = true;
groups[group.name] = group;
}
const updatedGroups = V2ScheduleParser.getUpdatedGroups(
this.lastResult?.groups,
groups,
);
return (this.lastResult = {
downloadedAt: headData.requestedAt,
uploadedAt: headData.uploadedAt,
etag: headData.etag,
replacerId: replacer?.id,
groups: groups,
updatedGroups:
updatedGroups.length === 0
? (this.lastResult?.updatedGroups ?? [])
: updatedGroups,
});
}
private static parseLesson(
workSheet: XLSX.Sheet,
day: V2DayDto,
dayTimes: Array<InternalTime>,
time: InternalTime,
column: number,
): Array<V2LessonDto> {
const row = time.xlsxRange.s.r;
if (typeof column !== "number") {
console.log(typeof column);
console.log(column);
}
// name
const rawName = trimAll(
V2ScheduleParser.getCellData(workSheet, row, column)?.replaceAll(
/[\n\r]/g,
"",
) ?? "",
);
if (rawName.length === 0) return [];
const lesson = new V2LessonDto();
lesson.type = time.lessonType;
lesson.defaultRange =
time.defaultIndex !== null
? [time.defaultIndex, time.defaultIndex]
: null;
lesson.time = new V2LessonTimeDto();
lesson.time.start = time.timeRange.start;
// check if multi-lesson
const range = this.getMergeFromStart(workSheet, row, column);
const endTime = dayTimes.filter((dayTime) => {
return dayTime.xlsxRange.e.r === range.e.r;
})[0];
lesson.time.end = endTime?.timeRange.end ?? time.timeRange.end;
if (lesson.defaultRange !== null)
lesson.defaultRange[1] = endTime?.defaultIndex ?? time.defaultIndex;
// name and subGroups (subGroups unfilled)
{
const nameAndGroups = V2ScheduleParser.parseNameAndSubGroups(
trimAll(rawName?.replaceAll(/[\n\r]/g, "") ?? ""),
);
lesson.name = nameAndGroups.name;
lesson.subGroups = nameAndGroups.subGroups;
}
// cabinets
{
const cabinets = V2ScheduleParser.parseCabinets(
workSheet,
row,
column + 1,
);
if (cabinets.length === 1) {
for (const index in lesson.subGroups)
lesson.subGroups[index].cabinet = cabinets[0];
} else if (cabinets.length === lesson.subGroups.length) {
for (const index in lesson.subGroups)
lesson.subGroups[index].cabinet = cabinets[index];
} else if (cabinets.length !== 0) {
if (cabinets.length > lesson.subGroups.length) {
for (const index in cabinets) {
if (lesson.subGroups[index] === undefined) {
lesson.subGroups.push(
plainToInstance(V2LessonSubGroupDto, {
number: +index + 1,
teacher: "Ошибка в расписании",
cabinet: cabinets[index],
} as V2LessonSubGroupDto),
);
continue;
}
lesson.subGroups[index].cabinet = cabinets[index];
}
} else throw new Error("Разное кол-во кабинетов и подгрупп!");
}
}
const prevLesson =
(day.lessons?.length ?? 0) === 0
? null
: day.lessons[day.lessons.length - 1];
if (!prevLesson) return [lesson];
return [
plainToInstance(V2LessonDto, {
type: V2LessonType.BREAK,
defaultRange: null,
name: null,
time: plainToInstance(V2LessonTimeDto, {
start: prevLesson.time.end,
end: lesson.time.start,
} as V2LessonTimeDto),
subGroups: [],
} as V2LessonDto),
lesson,
];
}
private static parseCabinets(
workSheet: WorkSheet,
row: number,
column: number,
) {
const cabinets: Array<string> = [];
{
const rawCabinets = V2ScheduleParser.getCellData(
workSheet,
row,
column,
);
if (rawCabinets) {
const parts = rawCabinets.split(/(\n|\s)/g);
for (const cabinet of parts) {
if (!toNormalString(cabinet)) continue;
cabinets.push(cabinet.replaceAll(/[\n\s\r]/g, " "));
}
}
}
return cabinets;
}
private static getUpdatedGroups(
cachedGroups: Array<V2GroupDto> | null,
currentGroups: Array<V2GroupDto>,
): Array<Array<number>> {
if (!cachedGroups) return [];
const updatedGroups = [];
for (const groupName in cachedGroups) {
const cachedGroup = cachedGroups[groupName];
const currentGroup = currentGroups[groupName];
const affectedGroupDays: Array<number> = [];
for (const dayIdx in currentGroup.days) {
if (
objectHash.sha1(currentGroup.days[dayIdx]) !==
objectHash.sha1(cachedGroup.days[dayIdx])
)
affectedGroupDays.push(Number.parseInt(dayIdx));
}
updatedGroups[groupName] = affectedGroupDays;
}
return updatedGroups;
}
}

View File

@@ -0,0 +1,35 @@
import { BasicXlsDownloader } from "./basic-xls-downloader";
import { XlsDownloaderInterface } from "./xls-downloader.interface";
describe("BasicXlsDownloader", () => {
let downloader: XlsDownloaderInterface;
beforeEach(async () => {
downloader = new BasicXlsDownloader();
});
it("Должен вызвать ошибку из-за отсутствия ссылки на скачивание", async () => {
await expect(async () => {
const result = await downloader.fetch(false);
downloader.verifyFetchResult(result);
}).rejects.toThrow();
});
it("Должен вызвать ошибку из-за неверной ссылки на скачивание", async () => {
await expect(() => {
return downloader.setDownloadUrl("https://google.com/");
}).rejects.toThrow();
});
it("Должен вернуть скачанный файл", async () => {
await downloader.setDownloadUrl(
"https://politehnikum-eng.ru/2024/poltavskaja_07_s_14_po_20_10-5-.xls",
);
expect(() => {
downloader.fetch(false).then((result) => {
downloader.verifyFetchResult(result);
});
}).toBeDefined();
});
});

View File

@@ -1,147 +1,120 @@
import {
XlsDownloaderBase,
XlsDownloaderCacheMode,
XlsDownloaderResult,
} from "./xls-downloader.base";
FetchError,
FetchResult,
XlsDownloaderInterface,
} from "./xls-downloader.interface";
import axios from "axios";
import { JSDOM } from "jsdom";
import {
NotAcceptableException,
ServiceUnavailableException,
} from "@nestjs/common";
import { ScheduleReplacerService } from "../../schedule-replacer.service";
import { Error } from "mongoose";
import * as crypto from "crypto";
export class BasicXlsDownloader extends XlsDownloaderBase {
cache: XlsDownloaderResult | null = null;
preparedData: { downloadLink: string; updateDate: string } | null = null;
export class BasicXlsDownloader implements XlsDownloaderInterface {
private url: string | null = null;
private cacheHash: string = "0000000000000000000000000000000000000000";
private lastUpdate: number = 0;
private scheduleReplacerService: ScheduleReplacerService | null = null;
setScheduleReplacerService(service: ScheduleReplacerService) {
this.scheduleReplacerService = service;
}
private async getDOM(preparedData: any): Promise<JSDOM | null> {
try {
return new JSDOM(atob(preparedData), {
url: this.url,
contentType: "text/html",
});
} catch {
throw new NotAcceptableException(
"Передан некорректный код страницы",
);
}
}
private parseData(dom: JSDOM): {
downloadLink: string;
updateDate: string;
} {
try {
const scheduleBlock = dom.window.document.getElementById("cont-i");
if (scheduleBlock === null)
// noinspection ExceptionCaughtLocallyJS
throw new Error("Не удалось найти блок расписаний!");
const schedules = scheduleBlock.getElementsByTagName("div");
if (schedules === null || schedules.length === 0)
// noinspection ExceptionCaughtLocallyJS
throw new Error("Не удалось найти строку с расписанием!");
const poltavskaya = schedules[0];
const link = poltavskaya.getElementsByTagName("a")[0]!;
const spans = poltavskaya.getElementsByTagName("span");
const updateDate = spans[3].textContent!.trimStart();
return {
downloadLink: link.href,
updateDate: updateDate,
};
} catch (exception) {
console.error(exception);
throw new NotAcceptableException(
"Передан некорректный код страницы",
);
}
}
public async getCachedXLS(): Promise<XlsDownloaderResult | null> {
if (this.cache === null) return null;
this.cache.new = this.cacheMode === XlsDownloaderCacheMode.HARD;
return this.cache;
}
public isUpdateRequired(): boolean {
return (Date.now() - this.lastUpdate) / 1000 / 60 > 5;
}
public async setPreparedData(preparedData: string): Promise<void> {
const dom = await this.getDOM(preparedData);
this.preparedData = this.parseData(dom);
this.lastUpdate = Date.now();
}
public async downloadXLS(): Promise<XlsDownloaderResult> {
if (
this.cacheMode === XlsDownloaderCacheMode.HARD &&
this.cache !== null
)
return this.getCachedXLS();
if (!this.preparedData) {
public async fetch(head: boolean): Promise<FetchResult> {
if (this.url === null) {
throw new ServiceUnavailableException(
"Отсутствует начальная ссылка на скачивание!",
);
}
// noinspection Annotator
const response = await axios.get(this.preparedData.downloadLink, {
responseType: "arraybuffer",
});
return BasicXlsDownloader.fetchSpecified(this.url, head);
}
/**
* Проверяет указанную ссылку на работоспособность
* @param {string} url - ссылка на скачивание
* @param {boolean} head - не скачивать файл
* @returns {FetchFailedResult} - если запрос не удался или он не соответствует ожиданиям
* @returns {FetchSuccessResult} - если запрос удался
* @static
* @async
*/
static async fetchSpecified(
url: string,
head: boolean,
): Promise<FetchResult> {
const response = await (head
? axios.head(url)
: axios.get(url, { responseType: "arraybuffer" }));
if (response.status !== 200) {
throw new Error(`Не удалось получить excel файл!
Статус код: ${response.status}
${response.statusText}`);
console.error(`${response.status} ${response.statusText}`);
return {
type: "fail",
error: FetchError.BAD_STATUS_CODE,
statusCode: response.status,
statusText: response.statusText,
};
}
const replacer = await this.scheduleReplacerService.getByEtag(
response.headers["etag"]!,
);
type HeaderValue = string | undefined;
const fileData: ArrayBuffer = replacer
? replacer.data
: response.data.buffer;
const contentType: HeaderValue = response.headers["content-type"];
const etag: HeaderValue = response.headers["etag"];
const uploadedAt: HeaderValue = response.headers["last-modified"];
const requestedAt: HeaderValue = response.headers["date"];
const fileDataHash = crypto
.createHash("sha1")
.update(Buffer.from(fileData).toString("base64"))
.digest("hex");
if (!contentType || !etag || !uploadedAt || !requestedAt) {
return {
type: "fail",
error: FetchError.BAD_HEADERS,
};
}
const result: XlsDownloaderResult = {
fileData: fileData,
updateDate: this.preparedData.updateDate,
etag: response.headers["etag"],
new:
this.cacheMode === XlsDownloaderCacheMode.NONE
? true
: this.cacheHash !== fileDataHash,
updateRequired: this.isUpdateRequired(),
if (contentType !== "application/vnd.ms-excel") {
return {
type: "fail",
error: FetchError.INCORRECT_FILE_TYPE,
contentType: contentType,
};
}
return {
type: "success",
etag: etag,
uploadedAt: new Date(uploadedAt),
requestedAt: new Date(requestedAt),
data: head ? undefined : response.data.buffer,
};
}
this.cacheHash = fileDataHash;
/**
* Проверяет FetchResult на ошибки
* @param {FetchResult} fetchResult - результат
* @throws {NotAcceptableException} - некорректный статус-код
* @throws {NotAcceptableException} - некорректный тип файла
* @throws {NotAcceptableException} - отсутствуют требуемые заголовки
* @static
*/
public verifyFetchResult(fetchResult: FetchResult): void {
if (fetchResult.type === "fail") {
switch (fetchResult.error) {
case FetchError.BAD_STATUS_CODE:
console.error(
`${fetchResult.statusCode}: ${fetchResult.statusText}`,
);
throw new NotAcceptableException(
`Не удалось получить информацию о файле, так как сервер вернул статус-код ${fetchResult.statusCode}!`,
);
case FetchError.INCORRECT_FILE_TYPE:
throw new NotAcceptableException(
`Тип файла ${fetchResult.contentType} на который указывает ссылка не равен application/vnd.ms-excel!`,
);
case FetchError.BAD_HEADERS:
throw new NotAcceptableException(
`Не удалось получить информацию о файле, так как сервер не вернул ожидаемые заголовки!`,
);
}
}
}
if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result;
public async setDownloadUrl(url: string): Promise<void> {
const result = await BasicXlsDownloader.fetchSpecified(url, true);
this.verifyFetchResult(result);
return result;
this.url = url;
}
}

View File

@@ -1,32 +0,0 @@
export type XlsDownloaderResult = {
fileData: ArrayBuffer;
updateDate: string;
etag: string;
new: boolean;
updateRequired: boolean;
};
export enum XlsDownloaderCacheMode {
NONE = 0,
SOFT, // читать кеш только если не был изменён etag.
HARD, // читать кеш всегда, кроме случаев его отсутствия
}
export abstract class XlsDownloaderBase {
public constructor(
protected readonly url: string,
protected readonly cacheMode = XlsDownloaderCacheMode.NONE,
) {}
public abstract downloadXLS(): Promise<XlsDownloaderResult>;
public abstract getCachedXLS(): Promise<XlsDownloaderResult | null>;
public abstract isUpdateRequired(): boolean;
public abstract setPreparedData(preparedData: string): Promise<void>;
public getCacheMode(): XlsDownloaderCacheMode {
return this.cacheMode;
}
}

View File

@@ -0,0 +1,74 @@
export enum FetchError {
BAD_STATUS_CODE,
INCORRECT_FILE_TYPE,
BAD_HEADERS,
}
export type FetchFailedResult = {
type: "fail";
/**
* Тип ошибки, если присутствует
*/
error: FetchError;
/**
* Тип файла, если error === FetchError.INCORRECT_FILE_TYPE
*/
contentType?: string;
/**
* Код ошибки, если error === FetchError.BAD_STATUS_CODE
*/
statusCode?: number;
/**
* Текст ошибки, если error === FetchError.BAD_STATUS_CODE
*/
statusText?: string;
};
export type FetchSuccessResult = {
type: "success";
/**
* ETag xls файла
*/
etag: string;
/**
* Дата, когда файл был загружен на сервер
*/
uploadedAt: Date;
/**
* Дата, когда файл был совершён запрос
*/
requestedAt: Date;
/**
* Данные файла
*/
data?: ArrayBuffer;
};
export type FetchResult = FetchFailedResult | FetchSuccessResult;
export interface XlsDownloaderInterface {
/**
* Получает информацию о xls файле
* @param {boolean} head - только заголовки
* @returns {FetchFailedResult} - запрос не удался или не соответствует ожиданиям
* @returns {FetchSuccessResult} - запрос удался
* @async
*/
fetch(head: boolean): Promise<FetchResult>;
setDownloadUrl(url: string): Promise<void>;
/**
* Проверяет FetchResult на ошибки
* @param {FetchResult} fetchResult - результат
*/
verifyFetchResult(fetchResult: FetchResult): void;
}

View File

@@ -10,39 +10,40 @@ import {
UseInterceptors,
} from "@nestjs/common";
import { AuthGuard } from "src/auth/auth.guard";
import {
ClearScheduleReplacerResDto,
ScheduleReplacerResDto,
} from "../dto/schedule-replacer.dto";
import { AuthRoles } from "../auth-role/auth-role.decorator";
import { UserRoleDto } from "../dto/user.dto";
import { AuthRoles } from "../auth/auth-role.decorator";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { ScheduleService } from "./schedule.service";
import { V1ScheduleService } from "./v1-schedule.service";
import { FileInterceptor } from "@nestjs/platform-express";
import {
ApiExtraModels,
ApiOkResponse,
ApiBearerAuth,
ApiOperation,
refs,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { ResultDto } from "src/utility/validation/class-validator.interceptor";
import { UserRole } from "../users/user-role.enum";
import { ScheduleReplacerDto } from "./dto/schedule-replacer.dto";
import { ClearScheduleReplacerDto } from "./dto/clear-schedule-replacer.dto";
import { plainToInstance } from "class-transformer";
@Controller("/api/v1/schedule-replacer")
@ApiTags("v1/schedule-replacer")
@ApiBearerAuth()
@Controller({ path: "schedule-replacer", version: "1" })
@UseGuards(AuthGuard)
export class ScheduleReplacerController {
constructor(
private readonly scheduleService: ScheduleService,
private readonly scheduleService: V1ScheduleService,
private readonly scheduleReplaceService: ScheduleReplacerService,
) {}
@ApiOperation({
description: "Замена текущего расписание на новое",
tags: ["schedule", "replacer"],
@ApiOperation({ description: "Замена текущего расписание на новое" })
@ApiResponse({
status: HttpStatus.OK,
description: "Замена прошла успешно",
})
@ApiOkResponse({ description: "Замена прошла успешно" })
@Post("set")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRoleDto.ADMIN])
@AuthRoles([UserRole.ADMIN])
@ResultDto(null)
@UseInterceptors(
FileInterceptor("file", { limits: { fileSize: 1024 * 1024 } }),
@@ -59,48 +60,41 @@ export class ScheduleReplacerController {
await this.scheduleService.refreshCache();
}
@ApiExtraModels(ScheduleReplacerResDto)
@ApiOperation({
description: "Получение списка заменителей расписания",
tags: ["schedule", "replacer"],
@ApiOperation({ description: "Получение списка заменителей расписания" })
@ApiResponse({
status: HttpStatus.OK,
description: "Список получен успешно",
})
@ApiOkResponse({ description: "Список получен успешно" }) // TODO: ааа((((
@Get("get")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRoleDto.ADMIN])
@AuthRoles([UserRole.ADMIN])
@ResultDto(null) // TODO: Как нибудь сделать проверку в таких случаях
async getReplacers(): Promise<ScheduleReplacerResDto[]> {
const etag = (await this.scheduleService.getSourceSchedule()).etag;
const replacer = await this.scheduleReplaceService.getByEtag(etag);
if (!replacer) return [];
return [
{
etag: replacer.etag,
size: replacer.data.byteLength,
},
];
async getReplacers(): Promise<ScheduleReplacerDto[]> {
return await this.scheduleReplaceService.getAll().then((result) => {
return result.map((replacer) => {
return plainToInstance(ScheduleReplacerDto, {
etag: replacer.etag,
size: replacer.data.byteLength,
} as ScheduleReplacerDto);
});
});
}
@ApiExtraModels(ClearScheduleReplacerResDto)
@ApiOperation({
description: "Удаление всех замен расписаний",
tags: ["schedule", "replacer"],
})
@ApiOkResponse({
@ApiOperation({ description: "Удаление всех замен расписаний" })
@ApiResponse({
status: HttpStatus.OK,
description: "Отчистка прошла успешно",
schema: refs(ClearScheduleReplacerResDto)[0],
type: ClearScheduleReplacerDto,
})
@Post("clear")
@HttpCode(HttpStatus.OK)
@AuthRoles([UserRoleDto.ADMIN])
@ResultDto(ClearScheduleReplacerResDto)
async clear(): Promise<ClearScheduleReplacerResDto> {
const resDto = { count: await this.scheduleReplaceService.clear() };
@AuthRoles([UserRole.ADMIN])
@ResultDto(ClearScheduleReplacerDto)
async clear(): Promise<ClearScheduleReplacerDto> {
const response = { count: await this.scheduleReplaceService.clear() };
await this.scheduleService.refreshCache();
return resDto;
return response;
}
}

View File

@@ -1,7 +1,7 @@
import { Injectable } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerDto } from "../dto/schedule-replacer.dto";
import { plainToClass } from "class-transformer";
import { SetScheduleReplacerDto } from "./dto/set-schedule-replacer.dto";
import { plainToInstance } from "class-transformer";
@Injectable()
export class ScheduleReplacerService {
@@ -15,13 +15,19 @@ export class ScheduleReplacerService {
);
}
async getByEtag(etag: string): Promise<ScheduleReplacerDto | null> {
async getByEtag(etag: string): Promise<SetScheduleReplacerDto | null> {
const response = await this.prismaService.scheduleReplace.findUnique({
where: { etag: etag },
});
if (response == null) return null;
return plainToClass(ScheduleReplacerDto, response);
return plainToInstance(SetScheduleReplacerDto, response);
}
async getAll(): Promise<Array<SetScheduleReplacerDto>> {
const response = await this.prismaService.scheduleReplace.findMany();
return plainToInstance(SetScheduleReplacerDto, response);
}
async clear(): Promise<number> {

View File

@@ -1,16 +1,27 @@
import { forwardRef, Module } from "@nestjs/common";
import { ScheduleService } from "./schedule.service";
import { ScheduleController } from "./schedule.controller";
import { V1ScheduleService } from "./v1-schedule.service";
import { V1ScheduleController } from "./v1-schedule.controller";
import { PrismaService } from "../prisma/prisma.service";
import { FirebaseAdminModule } from "../firebase-admin/firebase-admin.module";
import { UsersModule } from "src/users/users.module";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { ScheduleReplacerController } from "./schedule-replacer.controller";
import { V2ScheduleService } from "./v2-schedule.service";
import { V2ScheduleController } from "./v2-schedule.controller";
@Module({
imports: [forwardRef(() => UsersModule), FirebaseAdminModule],
providers: [PrismaService, ScheduleService, ScheduleReplacerService],
controllers: [ScheduleController, ScheduleReplacerController],
exports: [ScheduleService],
providers: [
PrismaService,
V1ScheduleService,
V2ScheduleService,
ScheduleReplacerService,
],
controllers: [
V1ScheduleController,
V2ScheduleController,
ScheduleReplacerController,
],
exports: [V1ScheduleService, V2ScheduleService],
})
export class ScheduleModule {}

View File

@@ -1,5 +1,5 @@
import { Test, TestingModule } from "@nestjs/testing";
import { ScheduleService } from "./schedule.service";
import { V1ScheduleService } from "./schedule.service";
import * as fs from "node:fs";
import { CacheModule } from "@nestjs/cache-manager";
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
@@ -7,14 +7,16 @@ import { UsersService } from "../users/users.service";
import { PrismaService } from "../prisma/prisma.service";
import { ScheduleReplacerService } from "./schedule-replacer.service";
describe("ScheduleService", () => {
let service: ScheduleService;
describe("V1ScheduleService", () => {
let service: V1ScheduleService;
beforeEach(async () => {
return;
const module: TestingModule = await Test.createTestingModule({
imports: [CacheModule.register()],
providers: [
ScheduleService,
V1ScheduleService,
CacheModule,
FirebaseAdminService,
UsersService,
@@ -23,11 +25,13 @@ describe("ScheduleService", () => {
],
}).compile();
service = module.get<ScheduleService>(ScheduleService);
service = module.get<V1ScheduleService>(V1ScheduleService);
});
describe("get group schedule", () => {
it("should return group schedule", async () => {
return;
const mainPage = fs.readFileSync("./test/mainPage").toString();
await service.updateSiteMainPage({ mainPage: mainPage });

View File

@@ -1,204 +0,0 @@
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import {
ScheduleParser,
ScheduleParseResult,
} from "./internal/schedule-parser/schedule-parser";
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
import { XlsDownloaderCacheMode } from "./internal/xls-downloader/xls-downloader.base";
import {
CacheStatusDto,
GroupDto,
GroupScheduleDto,
ScheduleDto,
ScheduleGroupsDto,
SiteMainPageDto,
} from "../dto/schedule.dto";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { instanceToPlain } from "class-transformer";
import { cacheGetOrFill } from "../utility/cache.util";
import * as crypto from "crypto";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
import { scheduleConstants } from "../contants";
@Injectable()
export class ScheduleService {
private readonly scheduleParser = new ScheduleParser(
new BasicXlsDownloader(
"https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409",
XlsDownloaderCacheMode.SOFT,
),
);
private cacheUpdatedAt: Date = new Date(0);
private cacheHash: string = "0000000000000000000000000000000000000000";
private lastChangedDays: Array<Array<number>> = [];
private scheduleUpdatedAt: Date = new Date(0);
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly scheduleReplacerService: ScheduleReplacerService,
private readonly firebaseAdminService: FirebaseAdminService,
) {
const xlsDownloader = this.scheduleParser.getXlsDownloader();
if (xlsDownloader instanceof BasicXlsDownloader) {
xlsDownloader.setScheduleReplacerService(
this.scheduleReplacerService,
);
}
setInterval(async () => {
const now = new Date();
if (now.getHours() != 7 || now.getMinutes() != 30) return;
await this.firebaseAdminService.sendByTopic("common", {
android: {
priority: "high",
ttl: 60 * 60 * 1000,
},
data: {
type: "lessons-start",
},
});
}, 60000);
}
getCacheStatus(): CacheStatusDto {
return {
cacheHash: this.cacheHash,
cacheUpdateRequired:
(Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >=
scheduleConstants.cacheInvalidateDelay,
lastCacheUpdate: this.cacheUpdatedAt.valueOf(),
lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(),
};
}
async getSourceSchedule(): Promise<ScheduleParseResult> {
return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => {
const schedule = await this.scheduleParser.getSchedule();
schedule.groups = ScheduleService.toObject(
schedule.groups,
) as Array<GroupDto>;
this.cacheUpdatedAt = new Date();
const oldHash = this.cacheHash;
this.cacheHash = crypto
.createHash("sha1")
.update(
JSON.stringify(schedule.groups, null, 0) + schedule.etag,
)
.digest("hex");
if (
this.scheduleUpdatedAt.valueOf() === 0 ||
this.cacheHash !== oldHash
) {
if (this.scheduleUpdatedAt.valueOf() !== 0) {
const isReplaced =
await this.scheduleReplacerService.hasByEtag(
schedule.etag,
);
await this.firebaseAdminService.sendByTopic("common", {
data: {
type: "schedule-update",
replaced: isReplaced.toString(),
etag: schedule.etag,
},
});
}
this.scheduleUpdatedAt = new Date();
}
return schedule;
});
}
private static toObject<T>(array: Array<T>): object {
const object = {};
for (const item in array) object[item] = array[item];
return object;
}
async getSchedule(): Promise<ScheduleDto> {
return cacheGetOrFill(
this.cacheManager,
"schedule",
async (): Promise<ScheduleDto> => {
const sourceSchedule = await this.getSourceSchedule();
for (const groupName in sourceSchedule.affectedDays) {
const affectedDays = sourceSchedule.affectedDays[groupName];
if (affectedDays?.length !== 0)
this.lastChangedDays[groupName] = affectedDays;
}
return {
updatedAt: this.cacheUpdatedAt,
groups: ScheduleService.toObject(sourceSchedule.groups),
lastChangedDays: this.lastChangedDays,
};
},
);
}
async getGroup(group: string): Promise<GroupScheduleDto> {
const schedule = await this.getSourceSchedule();
if ((schedule.groups as object)[group] === undefined) {
throw new NotFoundException(
"Группы с таким названием не существует!",
);
}
return {
updatedAt: this.cacheUpdatedAt,
group: schedule.groups[group],
lastChangedDays: this.lastChangedDays[group] ?? [],
};
}
async getGroupNames(): Promise<ScheduleGroupsDto> {
let groupNames: ScheduleGroupsDto | undefined =
await this.cacheManager.get("groupNames");
if (!groupNames) {
const schedule = await this.getSourceSchedule();
const names: Array<string> = [];
for (const groupName in schedule.groups) names.push(groupName);
groupNames = { names };
await this.cacheManager.set(
"groupNames",
instanceToPlain(groupNames),
24 * 60 * 60 * 1000,
);
}
return groupNames;
}
async updateSiteMainPage(
siteMainPageDto: SiteMainPageDto,
): Promise<CacheStatusDto> {
await this.scheduleParser
.getXlsDownloader()
.setPreparedData(siteMainPageDto.mainPage);
await this.refreshCache();
return this.getCacheStatus();
}
async refreshCache() {
await this.cacheManager.reset();
await this.getSourceSchedule();
}
}

View File

@@ -8,143 +8,141 @@ import {
UseGuards,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard";
import { ScheduleService } from "./schedule.service";
import {
CacheStatusDto,
CacheStatusV0Dto,
CacheStatusV1Dto,
GroupScheduleDto,
GroupScheduleReqDto,
ScheduleDto,
ScheduleGroupsDto,
SiteMainPageDto,
} from "../dto/schedule.dto";
import { V1ScheduleService } from "./v1-schedule.service";
import { V1ScheduleDto } from "./dto/v1/v1-schedule.dto";
import { ResultDto } from "../utility/validation/class-validator.interceptor";
import {
ApiBearerAuth,
ApiExtraModels,
ApiNotAcceptableResponse,
ApiNotFoundResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
refs,
} from "@nestjs/swagger";
import { ResponseVersion } from "../version/response-version.decorator";
import { AuthRoles, AuthUnauthorized } from "../auth-role/auth-role.decorator";
import { UserDto, UserRoleDto } from "../dto/user.dto";
import { AuthRoles, AuthUnauthorized } from "../auth/auth-role.decorator";
import { UserToken } from "../auth/auth.decorator";
import { UserFromTokenPipe } from "../auth/auth.pipe";
import { UserPipe } from "../auth/auth.pipe";
import { UserRole } from "../users/user-role.enum";
import { User } from "../users/entity/user.entity";
import { V1CacheStatusDto } from "./dto/v1/v1-cache-status.dto";
import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto";
import { CacheStatusDto } from "./dto/v1/cache-status.dto";
import { V1GroupScheduleNameDto } from "./dto/v1/v1-group-schedule-name.dto";
import { V1ScheduleGroupNamesDto } from "./dto/v1/v1-schedule-group-names.dto";
import { V1GroupScheduleDto } from "./dto/v1/v1-group-schedule.dto";
import { V1SiteMainPageDto } from "./dto/v1/v1-site-main-page.dto";
@Controller("api/v1/schedule")
@ApiTags("v1/schedule")
@ApiBearerAuth()
@Controller({ path: "schedule", version: "1" })
@UseGuards(AuthGuard)
export class ScheduleController {
constructor(private readonly scheduleService: ScheduleService) {}
export class V1ScheduleController {
constructor(private readonly scheduleService: V1ScheduleService) {}
@ApiExtraModels(ScheduleDto)
@ApiExtraModels(V1ScheduleDto)
@ApiOperation({
summary: "Получение расписания",
tags: ["schedule", "admin"],
tags: ["admin"],
})
@ApiOkResponse({
description: "Расписание получено успешно",
schema: refs(ScheduleDto)[0],
schema: refs(V1ScheduleDto)[0],
})
@ResultDto(ScheduleDto)
@AuthRoles([UserRoleDto.ADMIN])
@ResultDto(V1ScheduleDto)
@AuthRoles([UserRole.ADMIN])
@HttpCode(HttpStatus.OK)
@Get("get")
async getSchedule(): Promise<ScheduleDto> {
async getSchedule(): Promise<V1ScheduleDto> {
return await this.scheduleService.getSchedule();
}
@ApiExtraModels(GroupScheduleDto)
@ApiOperation({
summary: "Получение расписания группы",
tags: ["schedule"],
})
@ApiExtraModels(V1GroupScheduleDto)
@ApiOperation({ summary: "Получение расписания группы" })
@ApiOkResponse({
description: "Расписание получено успешно",
schema: refs(GroupScheduleDto)[0],
schema: refs(V1GroupScheduleDto)[0],
})
@ApiNotFoundResponse({ description: "Требуемая группа не найдена" })
@ResultDto(GroupScheduleDto)
@ResultDto(V1GroupScheduleDto)
@HttpCode(HttpStatus.OK)
@Post("get-group")
async getGroupSchedule(
@Body() groupDto: GroupScheduleReqDto,
@UserToken(UserFromTokenPipe) user: UserDto,
): Promise<GroupScheduleDto> {
@Body() groupDto: V1GroupScheduleNameDto,
@UserToken(UserPipe) user: User,
): Promise<V1GroupScheduleDto> {
return await this.scheduleService.getGroup(groupDto.name ?? user.group);
}
@ApiExtraModels(ScheduleGroupsDto)
@ApiExtraModels(V1ScheduleGroupNamesDto)
@ApiOperation({
summary: "Получение списка названий всех групп в расписании",
tags: ["schedule"],
})
@ApiOkResponse({
description: "Список получен успешно",
schema: refs(ScheduleGroupsDto)[0],
schema: refs(V1ScheduleGroupNamesDto)[0],
})
@ApiNotFoundResponse({ description: "Требуемая группа не найдена" })
@ResultDto(ScheduleGroupsDto)
@ResultDto(V1ScheduleGroupNamesDto)
@AuthUnauthorized()
@HttpCode(HttpStatus.OK)
@Get("get-group-names")
async getGroupNames(): Promise<ScheduleGroupsDto> {
async getGroupNames(): Promise<V1ScheduleGroupNamesDto> {
return await this.scheduleService.getGroupNames();
}
@ApiExtraModels(SiteMainPageDto)
@ApiExtraModels(CacheStatusV0Dto)
@ApiExtraModels(CacheStatusV1Dto)
@ApiExtraModels(V1SiteMainPageDto)
@ApiExtraModels(V1CacheStatusDto)
@ApiExtraModels(V2CacheStatusDto)
@ApiOperation({
summary: "Обновление данных основной страницы политехникума",
tags: ["schedule"],
})
@ApiOkResponse({
description: "Данные обновлены успешно",
schema: refs(CacheStatusV0Dto)[0],
schema: refs(V1CacheStatusDto)[0],
})
@ApiOkResponse({
description: "Данные обновлены успешно",
schema: refs(CacheStatusV0Dto)[1],
schema: refs(V1CacheStatusDto)[1],
})
@ApiNotAcceptableResponse({
description: "Передан некорректный код страницы",
})
@ResultDto([CacheStatusV0Dto, CacheStatusV1Dto])
@ResultDto([V1CacheStatusDto, V2CacheStatusDto])
@HttpCode(HttpStatus.OK)
@Post("update-site-main-page")
async updateSiteMainPage(
@Body() siteMainPageDto: SiteMainPageDto,
@Body() siteMainPageDto: V1SiteMainPageDto,
@ResponseVersion() version: number,
): Promise<CacheStatusV0Dto> {
): Promise<V1CacheStatusDto> {
return CacheStatusDto.stripVersion(
await this.scheduleService.updateSiteMainPage(siteMainPageDto),
version,
);
}
@ApiExtraModels(CacheStatusV0Dto)
@ApiExtraModels(CacheStatusV1Dto)
@ApiExtraModels(V1CacheStatusDto)
@ApiExtraModels(V2CacheStatusDto)
@ApiOperation({
summary: "Получение информации о кеше",
tags: ["schedule", "cache"],
tags: ["cache"],
})
@ApiOkResponse({
description: "Получение данных прошло успешно",
schema: refs(CacheStatusV0Dto)[0],
schema: refs(V1CacheStatusDto)[0],
})
@ApiOkResponse({
description: "Получение данных прошло успешно",
schema: refs(CacheStatusV1Dto)[0],
schema: refs(V2CacheStatusDto)[0],
})
@ResultDto([CacheStatusV0Dto, CacheStatusV1Dto])
@ResultDto([V1CacheStatusDto, V2CacheStatusDto])
@HttpCode(HttpStatus.OK)
@Get("cache-status")
getCacheStatus(
@ResponseVersion() version: number,
): CacheStatusV0Dto | CacheStatusV1Dto {
): V1CacheStatusDto | V2CacheStatusDto {
return CacheStatusDto.stripVersion(
this.scheduleService.getCacheStatus(),
version,

View File

@@ -0,0 +1,234 @@
import {
forwardRef,
Inject,
Injectable,
NotAcceptableException,
NotFoundException,
} from "@nestjs/common";
import {
V1ScheduleParser,
ScheduleParseResult,
} from "./internal/schedule-parser/v1-schedule-parser";
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
import { V1ScheduleDto } from "./dto/v1/v1-schedule.dto";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { instanceToPlain } from "class-transformer";
import { cacheGetOrFill } from "../utility/cache.util";
import * as crypto from "crypto";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { scheduleConstants } from "../contants";
import { JSDOM } from "jsdom";
import { V2ScheduleService } from "./v2-schedule.service";
import { V1GroupDto } from "./dto/v1/v1-group.dto";
import { CacheStatusDto } from "./dto/v1/cache-status.dto";
import { V1ScheduleGroupNamesDto } from "./dto/v1/v1-schedule-group-names.dto";
import { V1GroupScheduleDto } from "./dto/v1/v1-group-schedule.dto";
import { V1SiteMainPageDto } from "./dto/v1/v1-site-main-page.dto";
@Injectable()
export class V1ScheduleService {
readonly scheduleParser: V1ScheduleParser;
private cacheUpdatedAt: Date = new Date(0);
private cacheHash: string = "0000000000000000000000000000000000000000";
private lastChangedDays: Array<Array<number>> = [];
private scheduleUpdatedAt: Date = new Date(0);
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly scheduleReplacerService: ScheduleReplacerService,
@Inject(forwardRef(() => V2ScheduleService))
private readonly v2ScheduleService: V2ScheduleService,
) {
this.scheduleParser = new V1ScheduleParser(
new BasicXlsDownloader(),
this.scheduleReplacerService,
);
}
getCacheStatus(): CacheStatusDto {
return {
cacheHash: this.cacheHash,
cacheUpdateRequired:
(Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >=
scheduleConstants.cacheInvalidateDelay,
lastCacheUpdate: this.cacheUpdatedAt.valueOf(),
lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(),
};
}
async getSourceSchedule(
silent: boolean = false,
): Promise<ScheduleParseResult> {
return cacheGetOrFill(this.cacheManager, "sourceSchedule", async () => {
const schedule = await this.scheduleParser.getSchedule();
schedule.groups = V1ScheduleService.toObject(
schedule.groups,
) as Array<V1GroupDto>;
this.cacheUpdatedAt = new Date();
const oldHash = this.cacheHash;
this.cacheHash = crypto
.createHash("sha1")
.update(
JSON.stringify(schedule.groups, null, 0) + schedule.etag,
)
.digest("hex");
if (
this.scheduleUpdatedAt.valueOf() === 0 ||
this.cacheHash !== oldHash
) {
if (this.scheduleUpdatedAt.valueOf() !== 0 && !silent)
await this.v2ScheduleService.refreshCache(true);
this.scheduleUpdatedAt = new Date();
}
return schedule;
});
}
private static toObject<T>(array: Array<T>): object {
const object = {};
for (const item in array) object[item] = array[item];
return object;
}
async getSchedule(): Promise<V1ScheduleDto> {
return cacheGetOrFill(
this.cacheManager,
"schedule",
async (): Promise<V1ScheduleDto> => {
const sourceSchedule = await this.getSourceSchedule();
for (const groupName in sourceSchedule.affectedDays) {
const affectedDays = sourceSchedule.affectedDays[groupName];
if (affectedDays?.length !== 0)
this.lastChangedDays[groupName] = affectedDays;
}
return {
updatedAt: this.cacheUpdatedAt,
groups: V1ScheduleService.toObject(sourceSchedule.groups),
lastChangedDays: this.lastChangedDays,
};
},
);
}
async getGroup(group: string): Promise<V1GroupScheduleDto> {
const schedule = await this.getSourceSchedule();
if ((schedule.groups as object)[group] === undefined) {
throw new NotFoundException(
"Группы с таким названием не существует!",
);
}
return {
updatedAt: this.cacheUpdatedAt,
group: schedule.groups[group],
lastChangedDays: this.lastChangedDays[group] ?? [],
};
}
async getGroupNames(): Promise<V1ScheduleGroupNamesDto> {
let groupNames: V1ScheduleGroupNamesDto | undefined =
await this.cacheManager.get("groupNames");
if (!groupNames) {
const schedule = await this.getSourceSchedule();
const names: Array<string> = [];
for (const groupName in schedule.groups) names.push(groupName);
groupNames = { names };
await this.cacheManager.set(
"groupNames",
instanceToPlain(groupNames),
24 * 60 * 60 * 1000,
);
}
return groupNames;
}
private async getDOM(preparedData: any): Promise<JSDOM | null> {
try {
return new JSDOM(atob(preparedData), {
url: "https://politehnikum-eng.ru/index/raspisanie_zanjatij/0-409",
contentType: "text/html",
});
} catch {
throw new NotAcceptableException(
"Передан некорректный код страницы",
);
}
}
private parseData(dom: JSDOM): string {
try {
const scheduleBlock = dom.window.document.getElementById("cont-i");
if (scheduleBlock === null)
// noinspection ExceptionCaughtLocallyJS
throw new Error("Не удалось найти блок расписаний!");
const schedules = scheduleBlock.getElementsByTagName("div");
if (schedules === null || schedules.length === 0)
// noinspection ExceptionCaughtLocallyJS
throw new Error("Не удалось найти строку с расписанием!");
const poltavskaya = schedules[0];
const link = poltavskaya.getElementsByTagName("a")[0]!;
return link.href;
} catch (exception) {
console.error(exception);
throw new NotAcceptableException(
"Передан некорректный код страницы",
);
}
}
async updateSiteMainPage(
siteMainPageDto: V1SiteMainPageDto,
): Promise<CacheStatusDto> {
const dom = await this.getDOM(siteMainPageDto.mainPage);
const url = this.parseData(dom);
console.log(url);
return await this.updateDownloadUrl(url);
}
async updateDownloadUrl(
url: string,
silent: boolean = false,
): Promise<CacheStatusDto> {
await this.scheduleParser.getXlsDownloader().setDownloadUrl(url);
await this.v2ScheduleService.scheduleParser
.getXlsDownloader()
.setDownloadUrl(url);
if (!silent) {
await this.refreshCache(false);
await this.v2ScheduleService.refreshCache(true);
}
return this.getCacheStatus();
}
async refreshCache(silent: boolean = false) {
if (!silent) {
await this.cacheManager.reset();
await this.v2ScheduleService.refreshCache(true);
}
await this.getSourceSchedule(silent);
}
}

View File

@@ -0,0 +1,135 @@
import {
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Patch,
UseGuards,
UseInterceptors,
} from "@nestjs/common";
import { AuthGuard } from "../auth/auth.guard";
import { ResultDto } from "../utility/validation/class-validator.interceptor";
import {
ApiBearerAuth,
ApiBody,
ApiOperation,
ApiResponse,
ApiTags,
} from "@nestjs/swagger";
import { AuthRoles } from "../auth/auth-role.decorator";
import { UserToken } from "../auth/auth.decorator";
import { UserPipe } from "../auth/auth.pipe";
import { V2ScheduleService } from "./v2-schedule.service";
import { V2ScheduleDto } from "./dto/v2/v2-schedule.dto";
import { CacheInterceptor, CacheKey } from "@nestjs/cache-manager";
import { UserRole } from "../users/user-role.enum";
import { User } from "../users/entity/user.entity";
import { V1CacheStatusDto } from "./dto/v1/v1-cache-status.dto";
import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto";
import { V2UpdateDownloadUrlDto } from "./dto/v2/v2-update-download-url.dto";
import { V2GroupScheduleByNameDto } from "./dto/v2/v2-group-schedule-by-name.dto";
import { V2GroupScheduleDto } from "./dto/v2/v2-group-schedule.dto";
import { V2ScheduleGroupNamesDto } from "./dto/v2/v2-schedule-group-names.dto";
@ApiTags("v2/schedule")
@ApiBearerAuth()
@Controller({ path: "schedule", version: "2" })
@UseGuards(AuthGuard)
export class V2ScheduleController {
constructor(private readonly scheduleService: V2ScheduleService) {}
@ApiOperation({
summary: "Получение расписания",
tags: ["admin"],
})
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: V2ScheduleDto,
})
@ResultDto(V2ScheduleDto)
@AuthRoles([UserRole.ADMIN])
@CacheKey("v2-schedule")
@UseInterceptors(CacheInterceptor)
@HttpCode(HttpStatus.OK)
@Get()
async getSchedule(): Promise<V2ScheduleDto> {
return await this.scheduleService.getSchedule();
}
@ApiOperation({ summary: "Получение расписания группы" })
@ApiBody({ type: V2GroupScheduleByNameDto })
@ApiResponse({
status: HttpStatus.OK,
description: "Расписание получено успешно",
type: V2GroupScheduleDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Требуемая группа не найдена",
})
@ResultDto(V2GroupScheduleDto)
@HttpCode(HttpStatus.OK)
@Get("group")
async getGroupSchedule(
@Body() reqDto: V2GroupScheduleByNameDto,
@UserToken(UserPipe) user: User,
): Promise<V2GroupScheduleDto> {
return await this.scheduleService.getGroup(reqDto.name ?? user.group);
}
@ApiOperation({ summary: "Получение списка названий групп" })
@ApiResponse({
status: HttpStatus.OK,
description: "Список получен успешно",
type: V2ScheduleGroupNamesDto,
})
@ApiResponse({
status: HttpStatus.NOT_FOUND,
description: "Требуемая группа не найдена",
})
@ResultDto(V2ScheduleGroupNamesDto)
@CacheKey("v2-schedule-group-names")
@UseInterceptors(CacheInterceptor)
@HttpCode(HttpStatus.OK)
@Get("group-names")
async getGroupNames(): Promise<V2ScheduleGroupNamesDto> {
return await this.scheduleService.getGroupNames();
}
@ApiOperation({ summary: "Обновление основной страницы политехникума" })
@ApiResponse({
status: HttpStatus.OK,
description: "Данные обновлены успешно",
type: V2CacheStatusDto,
})
@ApiResponse({
status: HttpStatus.NOT_ACCEPTABLE,
description: "Передан некорректный код страницы",
})
@ResultDto(V2CacheStatusDto)
@HttpCode(HttpStatus.OK)
@Patch("update-download-url")
async updateDownloadUrl(
@Body() reqDto: V2UpdateDownloadUrlDto,
): Promise<V1CacheStatusDto> {
return await this.scheduleService.updateDownloadUrl(reqDto.url);
}
@ApiOperation({
summary: "Получение информации о кеше",
tags: ["cache"],
})
@ApiResponse({
status: HttpStatus.OK,
description: "Получение данных прошло успешно",
type: V2CacheStatusDto,
})
@ResultDto(V2CacheStatusDto)
@HttpCode(HttpStatus.OK)
@Get("cache-status")
getCacheStatus(): V2CacheStatusDto {
return this.scheduleService.getCacheStatus();
}
}

View File

@@ -0,0 +1,166 @@
import {
forwardRef,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { BasicXlsDownloader } from "./internal/xls-downloader/basic-xls-downloader";
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
import { plainToInstance } from "class-transformer";
import { ScheduleReplacerService } from "./schedule-replacer.service";
import { FirebaseAdminService } from "../firebase-admin/firebase-admin.service";
import { scheduleConstants } from "../contants";
import { V2ScheduleDto } from "./dto/v2/v2-schedule.dto";
import { V1ScheduleService } from "./v1-schedule.service";
import {
V2ScheduleParser,
V2ScheduleParseResult,
} from "./internal/schedule-parser/v2-schedule-parser";
import * as objectHash from "object-hash";
import { V2CacheStatusDto } from "./dto/v2/v2-cache-status.dto";
import { V2GroupScheduleDto } from "./dto/v2/v2-group-schedule.dto";
import { V2ScheduleGroupNamesDto } from "./dto/v2/v2-schedule-group-names.dto";
@Injectable()
export class V2ScheduleService {
readonly scheduleParser: V2ScheduleParser;
private cacheUpdatedAt: Date = new Date(0);
private cacheHash: string = "0000000000000000000000000000000000000000";
private scheduleUpdatedAt: Date = new Date(0);
constructor(
@Inject(CACHE_MANAGER) private readonly cacheManager: Cache,
private readonly scheduleReplacerService: ScheduleReplacerService,
private readonly firebaseAdminService: FirebaseAdminService,
@Inject(forwardRef(() => V1ScheduleService))
private readonly v1ScheduleService: V1ScheduleService,
) {
setInterval(async () => {
const now = new Date();
if (now.getHours() != 7 || now.getMinutes() != 30) return;
await this.firebaseAdminService.sendByTopic("common", {
android: {
priority: "high",
ttl: 60 * 60 * 1000,
},
data: {
type: "lessons-start",
},
});
}, 60000);
this.scheduleParser = new V2ScheduleParser(
new BasicXlsDownloader(),
this.scheduleReplacerService,
);
}
getCacheStatus(): V2CacheStatusDto {
return plainToInstance(V2CacheStatusDto, {
cacheHash: this.cacheHash,
cacheUpdateRequired:
(Date.now() - this.cacheUpdatedAt.valueOf()) / 1000 / 60 >=
scheduleConstants.cacheInvalidateDelay,
lastCacheUpdate: this.cacheUpdatedAt.valueOf(),
lastScheduleUpdate: this.scheduleUpdatedAt.valueOf(),
});
}
async getSourceSchedule(
silent: boolean = false,
): Promise<V2ScheduleParseResult> {
const schedule = await this.scheduleParser.getSchedule();
this.cacheUpdatedAt = new Date();
const oldHash = this.cacheHash;
this.cacheHash = objectHash.sha1(schedule.groups);
if (this.cacheHash !== oldHash) {
if (this.scheduleUpdatedAt.valueOf() !== 0 && !silent) {
await this.v1ScheduleService.refreshCache(true);
const isReplaced = await this.scheduleReplacerService.hasByEtag(
schedule.etag,
);
await this.firebaseAdminService.sendByTopic("common", {
data: {
type: "schedule-update",
replaced: isReplaced.toString(),
etag: schedule.etag,
},
});
}
this.scheduleUpdatedAt = new Date();
}
return schedule;
}
async getSchedule(): Promise<V2ScheduleDto> {
const sourceSchedule = await this.getSourceSchedule();
return {
updatedAt: this.cacheUpdatedAt,
groups: sourceSchedule.groups,
updatedGroups: sourceSchedule.updatedGroups,
};
}
async getGroup(group: string): Promise<V2GroupScheduleDto> {
const schedule = await this.getSourceSchedule();
if (schedule.groups[group] === undefined) {
throw new NotFoundException(
"Группы с таким названием не существует!",
);
}
return {
updatedAt: this.cacheUpdatedAt,
group: schedule.groups[group],
updated: schedule.updatedGroups[group] ?? [],
};
}
async getGroupNames(): Promise<V2ScheduleGroupNamesDto> {
const schedule = await this.getSourceSchedule();
const names: Array<string> = [];
for (const groupName in schedule.groups) names.push(groupName);
return plainToInstance(V2ScheduleGroupNamesDto, {
names: names,
});
}
async updateDownloadUrl(
url: string,
silent: boolean = false,
): Promise<V2CacheStatusDto> {
await this.scheduleParser.getXlsDownloader().setDownloadUrl(url);
await this.v1ScheduleService.scheduleParser
.getXlsDownloader()
.setDownloadUrl(url);
if (!silent) {
await this.refreshCache(false);
await this.v1ScheduleService.refreshCache(true);
}
return this.getCacheStatus();
}
async refreshCache(silent: boolean = false) {
if (!silent) {
await this.cacheManager.reset();
await this.v1ScheduleService.refreshCache(true);
}
await this.getSourceSchedule(silent);
}
}