mirror of
https://github.com/n08i40k/schedule-parser-next.git
synced 2025-12-06 17:57:45 +03:00
Обход бана по IP за частые обращения к сайту политехникума за счёт скачивания кода страницы на стороне клиентов.
This commit is contained in:
@@ -1,5 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsBase64,
|
||||||
|
IsBoolean,
|
||||||
IsDate,
|
IsDate,
|
||||||
IsEnum,
|
IsEnum,
|
||||||
IsNumber,
|
IsNumber,
|
||||||
@@ -240,6 +242,14 @@ export class ScheduleDto {
|
|||||||
})
|
})
|
||||||
@Type(() => Object)
|
@Type(() => Object)
|
||||||
lastChangedDays: Array<Array<number>>;
|
lastChangedDays: Array<Array<number>>;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
"Требуется ли пользовательское обновление ссылки для скачивания расписания",
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
updateRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class GroupScheduleRequestDto extends PickType(GroupDto, ["name"]) {}
|
export class GroupScheduleRequestDto extends PickType(GroupDto, ["name"]) {}
|
||||||
@@ -269,4 +279,21 @@ export class GroupScheduleDto extends OmitType(ScheduleDto, [
|
|||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
lastChangedDays: Array<number>;
|
lastChangedDays: Array<number>;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
"Требуется ли пользовательское обновление ссылки для скачивания расписания",
|
||||||
|
})
|
||||||
|
@IsBoolean()
|
||||||
|
updateRequired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SiteMainPageDto {
|
||||||
|
@ApiProperty({
|
||||||
|
example: "<div></div>",
|
||||||
|
description: "Код страницы политехникума для скачивания",
|
||||||
|
})
|
||||||
|
@IsBase64()
|
||||||
|
mainPage: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export class ScheduleParseResult {
|
|||||||
etag: string;
|
etag: string;
|
||||||
groups: Array<GroupDto>;
|
groups: Array<GroupDto>;
|
||||||
affectedDays: Array<Array<number>>;
|
affectedDays: Array<Array<number>>;
|
||||||
|
updateRequired: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ScheduleParser {
|
export class ScheduleParser {
|
||||||
@@ -114,6 +115,10 @@ export class ScheduleParser {
|
|||||||
return { daySkeletons: days, groupSkeletons: groups };
|
return { daySkeletons: days, groupSkeletons: groups };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getXlsDownloader(): XlsDownloaderBase {
|
||||||
|
return this.xlsDownloader;
|
||||||
|
}
|
||||||
|
|
||||||
async getSchedule(
|
async getSchedule(
|
||||||
forceCached: boolean = false,
|
forceCached: boolean = false,
|
||||||
): Promise<ScheduleParseResult> {
|
): Promise<ScheduleParseResult> {
|
||||||
@@ -121,6 +126,15 @@ export class ScheduleParser {
|
|||||||
|
|
||||||
const downloadData = await this.xlsDownloader.downloadXLS();
|
const downloadData = await this.xlsDownloader.downloadXLS();
|
||||||
|
|
||||||
|
if (downloadData.updateRequired && downloadData.etag.length === 0) {
|
||||||
|
return {
|
||||||
|
updateRequired: true,
|
||||||
|
groups: [],
|
||||||
|
etag: "",
|
||||||
|
affectedDays: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
!downloadData.new &&
|
!downloadData.new &&
|
||||||
this.lastResult &&
|
this.lastResult &&
|
||||||
@@ -241,6 +255,7 @@ export class ScheduleParser {
|
|||||||
etag: downloadData.etag,
|
etag: downloadData.etag,
|
||||||
groups: groups,
|
groups: groups,
|
||||||
affectedDays: this.getAffectedDays(this.lastResult?.groups, groups),
|
affectedDays: this.getAffectedDays(this.lastResult?.groups, groups),
|
||||||
|
updateRequired: downloadData.updateRequired,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,35 +5,39 @@ import {
|
|||||||
} from "./xls-downloader.base";
|
} from "./xls-downloader.base";
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
import { JSDOM } from "jsdom";
|
import { JSDOM } from "jsdom";
|
||||||
|
import { NotAcceptableException } from "@nestjs/common";
|
||||||
|
|
||||||
export class BasicXlsDownloader extends XlsDownloaderBase {
|
export class BasicXlsDownloader extends XlsDownloaderBase {
|
||||||
cache: XlsDownloaderResult | null = null;
|
cache: XlsDownloaderResult | null = null;
|
||||||
|
preparedData: { downloadLink: string; updateDate: string } | null = null;
|
||||||
|
private lastUpdate: number = 0;
|
||||||
|
|
||||||
private async getDOM(): Promise<JSDOM> {
|
private async getDOM(preparedData: any): Promise<JSDOM | null> {
|
||||||
const response = await axios.get(this.url);
|
try {
|
||||||
|
return new JSDOM(atob(preparedData), {
|
||||||
if (response.status !== 200) {
|
|
||||||
throw new Error(`Не удалось получить данные с основной страницы!
|
|
||||||
Статус код: ${response.status}
|
|
||||||
${response.statusText}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return new JSDOM(response.data, {
|
|
||||||
url: this.url,
|
url: this.url,
|
||||||
contentType: "text/html",
|
contentType: "text/html",
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
"Передан некорректный код страницы",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private parseData(dom: JSDOM): {
|
private parseData(dom: JSDOM): {
|
||||||
downloadLink: string;
|
downloadLink: string;
|
||||||
updateDate: string;
|
updateDate: string;
|
||||||
} {
|
} {
|
||||||
|
try {
|
||||||
const scheduleBlock = dom.window.document.getElementById("cont-i");
|
const scheduleBlock = dom.window.document.getElementById("cont-i");
|
||||||
if (scheduleBlock === null)
|
if (scheduleBlock === null)
|
||||||
|
// noinspection ExceptionCaughtLocallyJS
|
||||||
throw new Error("Не удалось найти блок расписаний!");
|
throw new Error("Не удалось найти блок расписаний!");
|
||||||
|
|
||||||
const schedules = scheduleBlock.getElementsByTagName("div");
|
const schedules = scheduleBlock.getElementsByTagName("div");
|
||||||
if (schedules === null || schedules.length === 0)
|
if (schedules === null || schedules.length === 0)
|
||||||
|
// noinspection ExceptionCaughtLocallyJS
|
||||||
throw new Error("Не удалось найти строку с расписанием!");
|
throw new Error("Не удалось найти строку с расписанием!");
|
||||||
|
|
||||||
const poltavskaya = schedules[0];
|
const poltavskaya = schedules[0];
|
||||||
@@ -46,6 +50,12 @@ ${response.statusText}`);
|
|||||||
downloadLink: link.href,
|
downloadLink: link.href,
|
||||||
updateDate: updateDate,
|
updateDate: updateDate,
|
||||||
};
|
};
|
||||||
|
} catch (exception) {
|
||||||
|
console.error(exception);
|
||||||
|
throw new NotAcceptableException(
|
||||||
|
"Передан некорректный код страницы",
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCachedXLS(): Promise<XlsDownloaderResult | null> {
|
public async getCachedXLS(): Promise<XlsDownloaderResult | null> {
|
||||||
@@ -56,6 +66,17 @@ ${response.statusText}`);
|
|||||||
return this.cache;
|
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> {
|
public async downloadXLS(): Promise<XlsDownloaderResult> {
|
||||||
if (
|
if (
|
||||||
this.cacheMode === XlsDownloaderCacheMode.HARD &&
|
this.cacheMode === XlsDownloaderCacheMode.HARD &&
|
||||||
@@ -63,12 +84,18 @@ ${response.statusText}`);
|
|||||||
)
|
)
|
||||||
return this.getCachedXLS();
|
return this.getCachedXLS();
|
||||||
|
|
||||||
const dom = await this.getDOM();
|
if (!this.preparedData) {
|
||||||
const parseData = this.parseData(dom);
|
return {
|
||||||
|
updateRequired: true,
|
||||||
|
etag: "",
|
||||||
|
new: true,
|
||||||
|
fileData: new ArrayBuffer(1),
|
||||||
|
updateDate: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// FIX-ME: Что такое Annotator и почему он выдаёт пустое предупреждение?
|
|
||||||
// noinspection Annotator
|
// noinspection Annotator
|
||||||
const response = await axios.get(parseData.downloadLink, {
|
const response = await axios.get(this.preparedData.downloadLink, {
|
||||||
responseType: "arraybuffer",
|
responseType: "arraybuffer",
|
||||||
});
|
});
|
||||||
if (response.status !== 200) {
|
if (response.status !== 200) {
|
||||||
@@ -79,12 +106,13 @@ ${response.statusText}`);
|
|||||||
|
|
||||||
const result: XlsDownloaderResult = {
|
const result: XlsDownloaderResult = {
|
||||||
fileData: response.data.buffer,
|
fileData: response.data.buffer,
|
||||||
updateDate: parseData.updateDate,
|
updateDate: this.preparedData.updateDate,
|
||||||
etag: response.headers["etag"],
|
etag: response.headers["etag"],
|
||||||
new:
|
new:
|
||||||
this.cacheMode === XlsDownloaderCacheMode.NONE
|
this.cacheMode === XlsDownloaderCacheMode.NONE
|
||||||
? true
|
? true
|
||||||
: this.cache?.etag !== response.headers["etag"],
|
: this.cache?.etag !== response.headers["etag"],
|
||||||
|
updateRequired: this.isUpdateRequired(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result;
|
if (this.cacheMode !== XlsDownloaderCacheMode.NONE) this.cache = result;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export type XlsDownloaderResult = {
|
|||||||
updateDate: string;
|
updateDate: string;
|
||||||
etag: string;
|
etag: string;
|
||||||
new: boolean;
|
new: boolean;
|
||||||
|
updateRequired: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum XlsDownloaderCacheMode {
|
export enum XlsDownloaderCacheMode {
|
||||||
@@ -21,6 +22,10 @@ export abstract class XlsDownloaderBase {
|
|||||||
|
|
||||||
public abstract getCachedXLS(): Promise<XlsDownloaderResult | null>;
|
public abstract getCachedXLS(): Promise<XlsDownloaderResult | null>;
|
||||||
|
|
||||||
|
public abstract isUpdateRequired(): boolean;
|
||||||
|
|
||||||
|
public abstract setPreparedData(preparedData: string): Promise<void>;
|
||||||
|
|
||||||
public getCacheMode(): XlsDownloaderCacheMode {
|
public getCacheMode(): XlsDownloaderCacheMode {
|
||||||
return this.cacheMode;
|
return this.cacheMode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,10 +14,12 @@ import {
|
|||||||
GroupScheduleRequestDto,
|
GroupScheduleRequestDto,
|
||||||
ScheduleDto,
|
ScheduleDto,
|
||||||
ScheduleGroupsDto,
|
ScheduleGroupsDto,
|
||||||
|
SiteMainPageDto,
|
||||||
} from "../dto/schedule.dto";
|
} from "../dto/schedule.dto";
|
||||||
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
import { ResultDto } from "../utility/validation/class-validator.interceptor";
|
||||||
import {
|
import {
|
||||||
ApiExtraModels,
|
ApiExtraModels,
|
||||||
|
ApiNotAcceptableResponse,
|
||||||
ApiNotFoundResponse,
|
ApiNotFoundResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
ApiOperation,
|
||||||
@@ -38,8 +40,8 @@ export class ScheduleController {
|
|||||||
@ResultDto(ScheduleDto)
|
@ResultDto(ScheduleDto)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get("get")
|
@Get("get")
|
||||||
getSchedule(): Promise<ScheduleDto> {
|
async getSchedule(): Promise<ScheduleDto> {
|
||||||
return this.scheduleService.getSchedule();
|
return await this.scheduleService.getSchedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(GroupScheduleDto)
|
@ApiExtraModels(GroupScheduleDto)
|
||||||
@@ -55,10 +57,10 @@ export class ScheduleController {
|
|||||||
@ResultDto(GroupScheduleDto)
|
@ResultDto(GroupScheduleDto)
|
||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Post("get-group")
|
@Post("get-group")
|
||||||
getGroupSchedule(
|
async getGroupSchedule(
|
||||||
@Body() groupDto: GroupScheduleRequestDto,
|
@Body() groupDto: GroupScheduleRequestDto,
|
||||||
): Promise<GroupScheduleDto> {
|
): Promise<GroupScheduleDto> {
|
||||||
return this.scheduleService.getGroup(groupDto.name);
|
return await this.scheduleService.getGroup(groupDto.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ApiExtraModels(ScheduleGroupsDto)
|
@ApiExtraModels(ScheduleGroupsDto)
|
||||||
@@ -75,6 +77,24 @@ export class ScheduleController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@Get("get-group-names")
|
@Get("get-group-names")
|
||||||
async getGroupNames(): Promise<ScheduleGroupsDto> {
|
async getGroupNames(): Promise<ScheduleGroupsDto> {
|
||||||
return this.scheduleService.getGroupNames();
|
return await this.scheduleService.getGroupNames();
|
||||||
|
}
|
||||||
|
|
||||||
|
@ApiExtraModels(SiteMainPageDto)
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "Обновление данных основной страницы политехникума",
|
||||||
|
tags: ["schedule"],
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ description: "Данные обновлены успешно" })
|
||||||
|
@ApiNotAcceptableResponse({
|
||||||
|
description: "Передан некорректный код страницы",
|
||||||
|
})
|
||||||
|
@ResultDto(null)
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@Post("update-site-main-page")
|
||||||
|
async updateSiteMainPage(
|
||||||
|
@Body() siteMainPageDto: SiteMainPageDto,
|
||||||
|
): Promise<void> {
|
||||||
|
return await this.scheduleService.updateSiteMainPage(siteMainPageDto);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
import {
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from "@nestjs/common";
|
||||||
import {
|
import {
|
||||||
ScheduleParser,
|
ScheduleParser,
|
||||||
ScheduleParseResult,
|
ScheduleParseResult,
|
||||||
@@ -10,6 +15,7 @@ import {
|
|||||||
GroupScheduleDto,
|
GroupScheduleDto,
|
||||||
ScheduleDto,
|
ScheduleDto,
|
||||||
ScheduleGroupsDto,
|
ScheduleGroupsDto,
|
||||||
|
SiteMainPageDto,
|
||||||
} from "../dto/schedule.dto";
|
} from "../dto/schedule.dto";
|
||||||
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
|
import { Cache, CACHE_MANAGER } from "@nestjs/cache-manager";
|
||||||
import { instanceToPlain } from "class-transformer";
|
import { instanceToPlain } from "class-transformer";
|
||||||
@@ -38,6 +44,11 @@ export class ScheduleService {
|
|||||||
schedule.groups,
|
schedule.groups,
|
||||||
) as Array<GroupDto>;
|
) as Array<GroupDto>;
|
||||||
|
|
||||||
|
if (schedule.updateRequired && schedule.etag.length === 0)
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
"Отсутствует начальная ссылка на скачивание!",
|
||||||
|
);
|
||||||
|
|
||||||
return schedule;
|
return schedule;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -66,6 +77,7 @@ export class ScheduleService {
|
|||||||
groups: ScheduleService.toObject(sourceSchedule.groups),
|
groups: ScheduleService.toObject(sourceSchedule.groups),
|
||||||
etag: sourceSchedule.etag,
|
etag: sourceSchedule.etag,
|
||||||
lastChangedDays: this.lastChangedDays,
|
lastChangedDays: this.lastChangedDays,
|
||||||
|
updateRequired: sourceSchedule.updateRequired,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -84,6 +96,7 @@ export class ScheduleService {
|
|||||||
group: schedule.groups[group],
|
group: schedule.groups[group],
|
||||||
etag: schedule.etag,
|
etag: schedule.etag,
|
||||||
lastChangedDays: this.lastChangedDays[group] ?? [],
|
lastChangedDays: this.lastChangedDays[group] ?? [],
|
||||||
|
updateRequired: schedule.updateRequired,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,4 +120,12 @@ export class ScheduleService {
|
|||||||
|
|
||||||
return groupNames;
|
return groupNames;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateSiteMainPage(siteMainPageDto: SiteMainPageDto): Promise<void> {
|
||||||
|
await this.scheduleParser
|
||||||
|
.getXlsDownloader()
|
||||||
|
.setPreparedData(siteMainPageDto.mainPage);
|
||||||
|
|
||||||
|
await this.cacheManager.reset();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user