Обход бана по IP за частые обращения к сайту политехникума за счёт скачивания кода страницы на стороне клиентов.

This commit is contained in:
2024-09-16 15:12:29 +04:00
parent 60294f79cd
commit a6c84302b2
6 changed files with 153 additions and 37 deletions

View File

@@ -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;
} }

View File

@@ -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,
}); });
} }

View File

@@ -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;

View File

@@ -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;
} }

View File

@@ -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);
} }
} }

View File

@@ -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();
}
} }