chore: init

This commit is contained in:
2026-01-29 16:41:55 +02:00
commit b26a659d0c
19 changed files with 587 additions and 0 deletions

8
.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
root = true
[*]
charset = utf-8
indent_style = tab
indent_size = 4
insert_final_newline = true
trim_trailing_whitespace = true

34
.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

7
.prettierrc Normal file
View File

@@ -0,0 +1,7 @@
{
"trailingComma": "none",
"tabWidth": 4,
"useTabs": true,
"semi": true,
"singleQuote": false
}

1
AGENTS.md Normal file
View File

@@ -0,0 +1 @@
Do not use "Fake Axios". Only production APIs, even in tests.

12
README.md Normal file
View File

@@ -0,0 +1,12 @@
# Atruna
Šis ir **NEOFICIĀLS** E-Klases API wrapperis.
Šis projekts **nav jebkādā veidā saistīts ar** SIA "Izglītības sistēmas" vai
E-Klasi un tās operatoriem. Visi zīmoli un pakalpojumu nosaukumi pieder to
attiecīgajiem īpašniekiem.
PROGRAMMATŪRA TIEK NODROŠINĀTA **"KĀ IR"**, BEZ JEBKĀDĀM GARANTIJĀM. ES
NEUZŅEMOS ATBILDĪBU JA TAVU KONTU NOBLOĶĒ.
Tu pats esi atbildīgs par to, kā šo lieto!

78
bun.lock Normal file
View File

@@ -0,0 +1,78 @@
{
"lockfileVersion": 1,
"configVersion": 1,
"workspaces": {
"": {
"name": "eklase-api-wrapper",
"dependencies": {
"axios": "^1.13.2",
"playwright-core": "^1.58.0",
},
"devDependencies": {
"@types/bun": "latest",
},
"peerDependencies": {
"typescript": "^5",
},
},
},
"packages": {
"@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
"@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="],
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"axios": ["axios@1.13.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA=="],
"bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
"call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="],
"combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="],
"delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
"es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
"es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="],
"es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="],
"follow-redirects": ["follow-redirects@1.15.11", "", {}, "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ=="],
"form-data": ["form-data@4.0.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
"get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="],
"gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="],
"has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="],
"has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="],
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
"math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="],
"mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"playwright-core": ["playwright-core@1.58.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw=="],
"proxy-from-env": ["proxy-from-env@1.1.0", "", {}, "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
}
}

29
jsconfig.json Normal file
View File

@@ -0,0 +1,29 @@
{
"compilerOptions": {
// Environment setup & latest features
"lib": ["ESNext"],
"target": "ESNext",
"module": "Preserve",
"moduleDetection": "force",
"jsx": "react-jsx",
"allowJs": true,
// Bundler mode
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"noEmit": true,
// Best practices
"strict": true,
"skipLibCheck": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
// Some stricter flags (disabled by default)
"noUnusedLocals": false,
"noUnusedParameters": false,
"noPropertyAccessFromIndexSignature": false
}
}

23
package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "eklase-api-wrapper",
"version": "0.1.0",
"main": "dist/index.js",
"dependencies": {
"axios": "^1.13.2",
"playwright-core": "^1.58.0"
},
"description": "TypeScript Bun wrapper for the e-klase authentication API with tests.",
"license": "MIT",
"scripts": {
"build": "bun build",
"test": "bun test"
},
"types": "dist/index.d.ts",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

23
src/Client/index.ts Normal file
View File

@@ -0,0 +1,23 @@
import { EklaseAuthClient } from "../util/AuthClient";
import { Evaluation, EvaluationDetails, LessonTime, UserInfo } from "./types";
export class APIClient extends EklaseAuthClient {
async getUser(): Promise<UserInfo> {
return await this.get<UserInfo>("https://family.e-klase.lv/api/user")
}
async getEvaluations(): Promise<Evaluation[]> {
return await this.get<Evaluation[]>("https://family.e-klase.lv/api/evaluations")
}
async getEvaluationsDetails(evaluationId: number): Promise<EvaluationDetails> {
return await this.get<EvaluationDetails>(`https://family.e-klase.lv/api/evaluations/${evaluationId}`)
}
async getLessonTimes(): Promise<LessonTime[]> {
return await this.get<LessonTime[]>("https://family.e-klase.lv/api/lesson-times")
}
}

150
src/Client/types.ts Normal file
View File

@@ -0,0 +1,150 @@
export type UserInfo = {
firstName: string;
lastName: string;
personType: "Student";
personTypeName: "skolēns" | string;
personTypeId: number;
school: {
id: number;
tenantId: string;
name: string;
regionId: number;
},
class: {
id: number;
name: string;
level: number;
},
studyYearId: number;
activeStudyYears: {
id: number;
name: `${number}./${number}.` | string;
isCurrent: boolean
}[],
userPublicId: string;
username: string;
/** phone # without +371 */
phoneNumber: string;
isArchived: boolean;
userSettings: Record<string, unknown>;
/** ģimenes komplekts */
premiumSubscription?: {
isActive?: boolean,
endDate?: string,
hasDiscount?: boolean,
isActiveTillEndOfYear?: boolean
}
}
export type Evaluation = {
id: number,
/** bad grade: 1-3; (1-4 if professional) */
value: string,
/** iso 8601 */
timeCreated: string,
/** class name e.g. math */
disciplineName: string,
isNonAttendance: boolean
lesson?: {
/** iso 8601 */
date: string,
type?: {
name: string,
abbreviation: string,
/** ltid_Test = PD */
lessonTypeId: string
},
isTest: boolean,
/** html-like rich text markup */
subject?: boolean
},
isWeighted: boolean,
isNew: boolean,
hasComments: boolean,
editedEvaluation?: {
/** bad grade: 1-3; (1-4 if professional) */
value: string,
isNonAttendance: boolean
}
}
export type LessonTime = {
name: string,
from: `${number}:${number}`
to: `${number}:${number}`
}
export type EvaluationDetails = {
evaluation: EvaluationDetailsDetailed;
lesson: EvaluationLessonDetails;
editedEvaluations: EditedEvaluation[];
statistics: EvaluationStatistics;
};
export type EvaluationDetailsDetailed = {
id: number;
/** bad grade: 1-3; (1-4 if professional) */
value: string;
/** Vārds Uzvārds */
authorName: string;
/** iso 8601 */
timeCreated: string;
testNotes: string;
};
export type EvaluationLessonDetails = {
type: LessonType;
/** iso 8601 midnight */
lessonDate: string;
lessonSubject: LessonSubject;
disciplineName: string;
testId: number;
lessonNumberInDiary: number;
creation: LessonAudit;
lastModification: LessonModification;
};
export type LessonType = {
name: string;
abbreviation: string;
/** e.g. ltid_Test */
lessonTypeId: string;
};
export type LessonSubject = {
/** html / rich text */
text: string;
attachments: LessonAttachment[];
};
export type LessonAttachment = Record<string, unknown>;
export type LessonAudit = {
authorName: string;
/** iso 8601 */
timeCreated: string;
};
export type LessonModification = {
authorName: string;
/** iso 8601 */
timeModified: string;
};
export type EditedEvaluation = {
value: string;
authorName: string;
/** iso 8601 */
timeCreated: string;
/** e.g. Student_Improved */
reasonForEdit: string;
};
export type EvaluationStatistics = {
chartItems: number[];
/** 1st, 2nd, 3rd and so on */
studentPlace: number;
indexOfStudentEvaluation: number;
averageClassEvaluation: string;
};

2
src/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from "./Client";
export * from "./util/NameUtil";

43
src/login/index.ts Normal file
View File

@@ -0,0 +1,43 @@
import { sleep } from "bun";
import { getBrowserContext } from "./playwright";
export const LOGIN_URL = "https://family.e-klase.lv/";
export async function loginAndGetToken({
username,
password
}: {
username: string;
password: string;
}): Promise<{
expiry: Date;
token: string;
}> {
const ctx = await getBrowserContext();
const page = await ctx.newPage();
await page.setViewportSize({ width: 1920, height: 1080 });
await page.goto(LOGIN_URL);
await sleep(2000);
await page.locator("#username").fill(username, { force: true });
await page.locator("#password").fill(password, { force: true });
await page.locator("#login-button").click();
const req = await page.waitForRequest(
(request) =>
request.method() === "GET" &&
request.url() === "https://family.e-klase.lv/api/user"
);
const authHeader = req.headers()["authorization"];
await ctx.close();
return {
token: authHeader,
expiry: new Date(Date.now() + 15 * 60 * 1000)
};
}

22
src/login/playwright.ts Normal file
View File

@@ -0,0 +1,22 @@
import { Browser, BrowserContext, chromium } from "playwright-core";
const userAgents = [
"Mozilla/5.0 (X11; Linux x86_64; rv:146.0) Gecko/20100101 Firefox/146.0"
];
let _globalBrowserCtx: Browser;
export async function getBrowserContext(): Promise<BrowserContext> {
if (!_globalBrowserCtx) {
_globalBrowserCtx = await chromium.launch({
executablePath:
Bun.which("google-chrome") ||
Bun.which("chrome") ||
Bun.which("google-chrome-stable")!,
headless: true
});
}
return await _globalBrowserCtx.newContext({
userAgent: userAgents[Math.floor(Math.random() * userAgents.length)]
});
}

67
src/util/AuthClient.ts Normal file
View File

@@ -0,0 +1,67 @@
import { loginAndGetToken } from "../login";
export type AuthClientOptions = {
username: string;
password: string;
};
export type TokenResponse = {
expiry: Date;
token: string;
};
export class EklaseAuthClient {
#username: string;
#password: string;
#token?: TokenResponse;
constructor(options: AuthClientOptions) {
this.#username = options.username;
this.#password = options.password;
}
async getToken(): Promise<TokenResponse> {
if (!this.#token || new Date() > this.#token.expiry) {
const r = await loginAndGetToken({
username: this.#username,
password: this.#password
});
this.#token = r;
return r;
}
return this.#token;
}
async apiRequest(
input: RequestInfo,
init: RequestInit = {}
): Promise<Response> {
const tokenResponse = await this.getToken();
const headers = new Headers(init.headers ?? {});
if (!headers.has("authorization")) {
headers.set("authorization", tokenResponse.token);
}
return fetch(input, {
...init,
headers
});
}
async get<T>(
input: RequestInfo,
init: RequestInit = {}
): Promise<T> {
const tokenResponse = await this.getToken();
const headers = new Headers(init.headers ?? {});
if (!headers.has("authorization")) {
headers.set("authorization", tokenResponse.token);
}
const r = await fetch(input, {
...init,
headers
});
return await r.json() as T;
}
}

27
src/util/Mutex.ts Normal file
View File

@@ -0,0 +1,27 @@
export class Mutex {
private promise: Promise<void> = Promise.resolve();
private resolve: () => void = () => {};
async withMutex<T>(f: () => Promise<T>): Promise<T> {
this.lock();
let r;
try {
r = await f();
} catch (e_) {
this.unlock();
throw new Error();
}
this.unlock();
return r;
}
async lock(): Promise<void> {
const oldPromise = this.promise;
this.promise = new Promise((resolve) => (this.resolve = resolve));
await oldPromise;
}
unlock(): void {
this.resolve();
}
}

10
src/util/NameUtil.ts Normal file
View File

@@ -0,0 +1,10 @@
/**
* Normalizes a name from the Latvian Government form (LastName FirstName) or (LastName-OtherLastName FirstName)
*/
export function normalizeName(name: string): {
firstName: string;
lastName: string;
} {
const [lastName, firstName] = name.split(" ");
return { lastName, firstName };
}

View File

@@ -0,0 +1,16 @@
import { test, expect } from "bun:test";
import { APIClient } from "../src/Client";
test.todo("Authenticated API request", async () => {
const client = new APIClient({
username: process.env.USERNAME!,
password: process.env.PASSWORD!
});
const token = await client.getToken();
expect(typeof token.token).toBe("string");
console.log(token);
const user = await client.apiRequest("https://family.e-klase.lv/api/user");
console.log(await user.json());
});

21
tests/NameUtil.test.ts Normal file
View File

@@ -0,0 +1,21 @@
import { test, expect } from "bun:test";
import { normalizeName } from "../src";
// LV/EN placeholder names
test.each([
// https://en.wikipedia.org/wiki/List_of_placeholder_names#Latvian
{
fn: "Andris",
ln: "Paraudziņš"
},
// https://en.wikipedia.org/wiki/List_of_placeholder_names#English
{
fn: "John",
ln: "Doe"
}
])("Full government name normalization info FN/LN", (v) => {
// why last name first?
const x = normalizeName(`${v.ln} ${v.fn}`);
expect(x.firstName).toBe(v.fn);
expect(x.lastName).toBe(v.ln);
});

14
tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"declaration": true,
"outDir": "dist",
"strict": true,
"esModuleInterop": true
},
"include": ["src", "tests"]
}