Skip to content

Commit c73e2b2

Browse files
committed
Initial commit
0 parents  commit c73e2b2

File tree

12 files changed

+813
-0
lines changed

12 files changed

+813
-0
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
.userData
2+
.idea
3+
node_modules
4+
out-inc-state-*.pdf

.prettierrc

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"printWidth": 90,
3+
"tabWidth": 4,
4+
"singleQuote": true,
5+
"trailingComma": "none",
6+
"bracketSpacing": false
7+
}

package.json

+25
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
{
2+
"name": "3-ndfl-fill-dividents",
3+
"version": "1.0.0",
4+
"description": "",
5+
"main": "index.js",
6+
"scripts": {
7+
"start": "node ./src/index.ts"
8+
},
9+
"author": "",
10+
"license": "ISC",
11+
"dependencies": {
12+
"currency-codes": "^2.1.0",
13+
"i18n-iso-countries": "^7.2.0",
14+
"node-fetch": "^2.0.0",
15+
"pdf2json": "^2.0.0",
16+
"puppeteer": "^13.1.2",
17+
"ts-node": "^10.4.0"
18+
},
19+
"devDependencies": {
20+
"@types/node": "^17.0.13",
21+
"@types/node-fetch": "^3.0.3",
22+
"prettier": "^2.5.1",
23+
"typescript": "^4.5.5"
24+
}
25+
}

src/app.ts

+94
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
import {TinkoffForeignTaxReportParser} from './reportParser';
2+
import {EntryValidatory} from './validator';
3+
import {ReportEnricher} from './reportEnricher';
4+
import {NalogRuReportMaker} from './reportMaker';
5+
import {NalogRuPageController} from './nalogRuPageController';
6+
import {EnrichedTaxEntry, TaxEntry} from './entities';
7+
import path from 'path';
8+
import puppeteer from 'puppeteer';
9+
10+
export class App {
11+
constructor(private _fileName: string) {}
12+
13+
async run() {
14+
const items = await this._parseReport();
15+
const enrichedItems = await this._enrichItems(items);
16+
const browserController = new NalogRuPageController();
17+
try {
18+
const page = await this._preparePage(browserController);
19+
await this._makeReport(page, enrichedItems);
20+
} catch (e: any) {
21+
console.log('[❌] Something went wrong...');
22+
console.log(`[❌] Error details: ${e.message}`);
23+
console.log('[❌] Screenshot is saved');
24+
console.log('[❌] Press Enter to close browser');
25+
await this._readEnter();
26+
await browserController.screenshot(path.join(process.cwd(), 'error.png'));
27+
} finally {
28+
await browserController.finalize();
29+
}
30+
}
31+
32+
private async _parseReport() {
33+
console.log('[🚀] Parsing tax report...');
34+
const report = new TinkoffForeignTaxReportParser(this._fileName);
35+
const items = await report.getTaxEntries();
36+
const itemsValid = items.every((i) => EntryValidatory.isValid(i));
37+
if (!itemsValid) {
38+
throw new Error('Invalid items found');
39+
}
40+
console.log(`[✅] Done! ${items.length} parsed`);
41+
return items;
42+
}
43+
44+
private async _enrichItems(items: TaxEntry[]) {
45+
console.log(
46+
`[🚀] Enriching items (loading company names). This may take a while...`
47+
);
48+
const enricher = new ReportEnricher();
49+
const enrichedItems = await enricher.enrichItems(items);
50+
console.log(`[✅] Done!`);
51+
return enrichedItems;
52+
}
53+
54+
private async _preparePage(browserController: NalogRuPageController) {
55+
console.log('[❗️] After pressing Enter, browser will be started...');
56+
console.log(
57+
'[❗️] Login with any suitable method, when return back and press Enter once again'
58+
);
59+
await this._readEnter();
60+
console.log('[🚀] Loading browser to make report...');
61+
const page = await browserController.getNalogRuPage();
62+
console.log(
63+
'[🖐] Please login to your Nalog.RU account, when go back here and press Enter'
64+
);
65+
await this._readEnter();
66+
67+
return page;
68+
}
69+
70+
private async _makeReport(page: puppeteer.Page, items: EnrichedTaxEntry[]) {
71+
console.log('[🚀] Making report. Please wait, this will take a while...');
72+
const reportMaker = new NalogRuReportMaker(page);
73+
await reportMaker.makeReport(items);
74+
console.log(`[✅] Done! Draft URL: ${page.url()}`);
75+
}
76+
77+
private async _readEnter() {
78+
const stdin = process.stdin;
79+
return new Promise<void>((res) => {
80+
const handler = (key: Buffer) => {
81+
if (key.toString() === '\r') {
82+
stdin.setRawMode(false);
83+
stdin.pause();
84+
stdin.off('data', handler);
85+
res();
86+
}
87+
};
88+
stdin.setRawMode(true);
89+
stdin.resume();
90+
stdin.setEncoding('utf8');
91+
stdin.on('data', handler);
92+
});
93+
}
94+
}

src/entities/index.ts

+19
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface TaxEntry {
2+
dateFix: Date;
3+
datePay: Date;
4+
isin: string;
5+
country: string;
6+
count: number;
7+
cost: number;
8+
comission: number;
9+
sumBeforeTax: number;
10+
tax: number;
11+
sumAfterTax: number;
12+
currency: string;
13+
}
14+
15+
export interface EnrichedTaxEntry extends TaxEntry {
16+
countryCode: number;
17+
currencyCode: number;
18+
name: string;
19+
}

src/index.js

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
require('ts-node/register');
2+
const {App} = require('./app');
3+
const path = require('path');
4+
5+
const app = new App(path.join(process.cwd(), process.argv[2]));
6+
app.run();

src/nalogRuPageController/index.ts

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import puppeteer from 'puppeteer';
2+
import path from 'path';
3+
import fs from 'fs';
4+
5+
const LK_URL = 'https://lkfl2.nalog.ru/lkfl/login';
6+
7+
export class NalogRuPageController {
8+
private _browser: puppeteer.Browser | undefined;
9+
private _page: puppeteer.Page | undefined;
10+
11+
async getNalogRuPage(): Promise<puppeteer.Page> {
12+
const browser = await this._createBrowser();
13+
const page = await browser.newPage();
14+
await page.setViewport({
15+
width: 1024,
16+
height: 768
17+
});
18+
this._page = page;
19+
await Promise.all([this._page.waitForNavigation(), this._page.goto(LK_URL)]);
20+
return page;
21+
}
22+
23+
async screenshot(path: string) {
24+
await this._page?.screenshot({
25+
type: 'png',
26+
path
27+
});
28+
}
29+
30+
async finalize() {
31+
await this._page?.close();
32+
await this._browser?.close();
33+
}
34+
35+
private async _createBrowser(): Promise<puppeteer.Browser> {
36+
const userDataDir = path.join(process.cwd(), '.userData');
37+
if (!fs.existsSync(userDataDir)) {
38+
fs.mkdirSync(userDataDir);
39+
}
40+
this._browser = await puppeteer.launch({
41+
headless: false,
42+
slowMo: 10,
43+
userDataDir
44+
});
45+
return this._browser;
46+
}
47+
}

src/reportEnricher/index.ts

+99
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
import {EnrichedTaxEntry, TaxEntry} from '../entities';
2+
import countries from 'i18n-iso-countries';
3+
// @ts-ignore
4+
import ru from 'i18n-iso-countries/langs/ru';
5+
import currencyCodes from 'currency-codes';
6+
// @ts-ignore
7+
import fetch from 'node-fetch';
8+
9+
countries.registerLocale(ru);
10+
11+
function delay(ms: number) {
12+
return new Promise((res) => setTimeout(res, ms));
13+
}
14+
15+
export class ReportEnricher {
16+
private _cache: Record<string, string> = {};
17+
18+
async enrichItems(items: TaxEntry[]): Promise<EnrichedTaxEntry[]> {
19+
const result: EnrichedTaxEntry[] = [];
20+
await this._preloadIsinCache(items);
21+
for (const item of items) {
22+
result.push(await this._enrichItem(item));
23+
}
24+
return result;
25+
}
26+
27+
private async _preloadIsinCache(items: TaxEntry[]) {
28+
const uniqueIsin = [...new Set(items.map((i) => i.isin))];
29+
const batches = uniqueIsin.reduce(
30+
(batches: Array<string[]>, isin) => {
31+
const lastBatch = batches[batches.length - 1];
32+
if (lastBatch.length < 10) {
33+
lastBatch.push(isin);
34+
} else {
35+
batches.push([isin]);
36+
}
37+
return batches;
38+
},
39+
[[]]
40+
);
41+
for (const batch of batches) {
42+
this._cache = {
43+
...this._cache,
44+
...(await this._preloadBatch(batch))
45+
};
46+
}
47+
}
48+
49+
private async _preloadBatch(batch: string[]): Promise<Record<string, string>> {
50+
let response;
51+
const batchResult: Record<string, string> = {};
52+
while (true) {
53+
response = await fetch('https://api.openfigi.com/v3/mapping', {
54+
method: 'POST',
55+
headers: {
56+
'Content-Type': 'application/json'
57+
},
58+
body: JSON.stringify(
59+
batch.map((isin) => ({idType: 'ID_ISIN', idValue: isin}))
60+
)
61+
});
62+
if (response.status === 429) {
63+
console.warn('[⏱] Rate-limited, sleeping...');
64+
await delay(10000);
65+
continue;
66+
}
67+
break;
68+
}
69+
const result = await response.json();
70+
for (let i = 0; i < batch.length; i++) {
71+
const {
72+
data: [{name}]
73+
} = result[i];
74+
batchResult[batch[i]] = name;
75+
}
76+
return batchResult;
77+
}
78+
79+
private _getName(item: TaxEntry): string {
80+
return this._cache[item.isin];
81+
}
82+
83+
private async _enrichItem(item: TaxEntry): Promise<EnrichedTaxEntry> {
84+
const alpha2 = countries.getAlpha2Code(item.country, 'ru');
85+
if (!alpha2) {
86+
throw new Error(`Failed to detect alpha-2 code for country ${item.country}`);
87+
}
88+
const currencyCode = currencyCodes.code(item.currency)?.number;
89+
if (!currencyCode) {
90+
throw new Error(`Failed to detect code for currency ${item.currency}`);
91+
}
92+
return {
93+
...item,
94+
countryCode: +countries.alpha2ToNumeric(alpha2),
95+
currencyCode: +currencyCode,
96+
name: this._getName(item)
97+
};
98+
}
99+
}

0 commit comments

Comments
 (0)