├── .husky └── pre-commit ├── .npmrc ├── mainLogo.png ├── public ├── mainLogo.png └── icons │ ├── icon16.ico │ ├── icon16.png │ ├── icon32.ico │ ├── icon32.png │ ├── icon48.ico │ ├── icon48.png │ ├── icon128.ico │ └── icon128.png ├── screenshotes ├── 5ka.png ├── auchan.png ├── kuper.png ├── magnit.png ├── samokat.png ├── eda_yandex.png ├── perekrestok.png └── delivyery_yandex.png ├── src ├── vite-env.d.ts ├── content │ ├── kuper.ts │ ├── lenta.ts │ ├── metro.ts │ ├── ozon.ts │ ├── auchan.ts │ ├── perekrestok.ts │ ├── pyaterochka.ts │ ├── lavka.ts │ ├── magnit.ts │ ├── samokat.ts │ ├── samberi.ts │ └── delivery_club.ts ├── strategies │ ├── __tests__ │ │ ├── MetroStrategy.test.ts │ │ ├── LavkaStrategy.test.ts │ │ ├── OzonStrategy.test.ts │ │ ├── AuchanStrategy.test.ts │ │ ├── MagnitStrategy.test.ts │ │ ├── SamberiStrategy.test.ts │ │ ├── SamokatStrategy.test.ts │ │ ├── PerekrestokStrategy.test.ts │ │ ├── PyaterochkaStrategy.test.ts │ │ ├── DeliveryClubStrategy.test.ts │ │ ├── KuperStrategy.test.ts │ │ ├── generalTest.ts │ │ └── LentaStrategy.test.ts │ ├── __test_data__ │ │ ├── auchan │ │ │ └── card_no_weight.json │ │ ├── magnit │ │ │ ├── card1.json │ │ │ ├── card2.json │ │ │ ├── card3.json │ │ │ ├── card5.json │ │ │ └── card4.json │ │ ├── loadTestData.ts │ │ ├── metro │ │ │ ├── card1.json │ │ │ ├── card3.json │ │ │ └── card2.json │ │ ├── lenta │ │ │ └── card_no_weight.json │ │ ├── perekrestok │ │ │ ├── card4.json │ │ │ ├── card3.json │ │ │ ├── card2.json │ │ │ └── card1.json │ │ ├── delivery-club │ │ │ ├── card1.json │ │ │ ├── card4.json │ │ │ ├── card2.json │ │ │ └── card3.json │ │ ├── samberi │ │ │ ├── card2.json │ │ │ ├── card3.json │ │ │ ├── card4.json │ │ │ └── card1.json │ │ ├── pyaterochka │ │ │ ├── card3.json │ │ │ ├── card2.json │ │ │ ├── card1.json │ │ │ └── card4.json │ │ ├── samokat │ │ │ ├── card1.json │ │ │ ├── card2.json │ │ │ ├── card4.json │ │ │ └── card3.json │ │ └── lavka │ │ │ ├── card4.json │ │ │ ├── card3.json │ │ │ ├── card2.json │ │ │ └── card1.json │ ├── index.ts │ ├── OzonStrategy.ts │ ├── MagnitStrategy.ts │ ├── DeliveryClubStrategy.ts │ ├── MetroStrategy.ts │ ├── SamokatStrategy.ts │ ├── KuperStrategy.ts │ ├── AuchanStrategy.ts │ ├── LavkaStrategy.ts │ ├── PerekrestokStrategy.ts │ ├── PyaterochkaStrategy.ts │ ├── SamberiStrategy.ts │ └── LentaStrategy.ts ├── types │ ├── IStrategy.test.ts │ └── IStrategy.ts ├── core │ ├── ParserStrategy.ts │ └── BaseParser.ts ├── background │ └── background.ts └── utils │ └── converters.ts ├── .eslintignore ├── manifests ├── chrome.json ├── firefox.json └── base.json ├── vitest.setup.ts ├── tsconfig.node.json ├── .prettierignore ├── vitest.config.ts ├── tsconfig.json ├── .prettierrc ├── .eslintrc.json ├── vite.config.mjs ├── .github └── workflows │ ├── pr-checks.yml │ └── build-and-release.yml ├── package.json ├── .gitignore └── info.md /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | engine-strict=true 2 | save-exact=true 3 | -------------------------------------------------------------------------------- /mainLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/mainLogo.png -------------------------------------------------------------------------------- /public/mainLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/mainLogo.png -------------------------------------------------------------------------------- /screenshotes/5ka.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/5ka.png -------------------------------------------------------------------------------- /public/icons/icon16.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon16.ico -------------------------------------------------------------------------------- /public/icons/icon16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon16.png -------------------------------------------------------------------------------- /public/icons/icon32.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon32.ico -------------------------------------------------------------------------------- /public/icons/icon32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon32.png -------------------------------------------------------------------------------- /public/icons/icon48.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon48.ico -------------------------------------------------------------------------------- /public/icons/icon48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon48.png -------------------------------------------------------------------------------- /screenshotes/auchan.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/auchan.png -------------------------------------------------------------------------------- /screenshotes/kuper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/kuper.png -------------------------------------------------------------------------------- /screenshotes/magnit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/magnit.png -------------------------------------------------------------------------------- /public/icons/icon128.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon128.ico -------------------------------------------------------------------------------- /public/icons/icon128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/public/icons/icon128.png -------------------------------------------------------------------------------- /screenshotes/samokat.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/samokat.png -------------------------------------------------------------------------------- /screenshotes/eda_yandex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/eda_yandex.png -------------------------------------------------------------------------------- /screenshotes/perekrestok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/perekrestok.png -------------------------------------------------------------------------------- /screenshotes/delivyery_yandex.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SyrnikovPavel/zaKilo-extension/HEAD/screenshotes/delivyery_yandex.png -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | interface ImportMeta { 4 | readonly env: ImportMetaEnv; 5 | } 6 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # Игнорируемые пути 2 | dist/ 3 | node_modules/ 4 | ext-dist/ 5 | .*.js 6 | *.md 7 | vitest.config.ts 8 | vitest.setup.ts 9 | -------------------------------------------------------------------------------- /manifests/chrome.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": ["tabs", "scripting"], 3 | "background": { 4 | "service_worker": "src/background/background.js" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/content/kuper.ts: -------------------------------------------------------------------------------- 1 | import { BaseParser } from "../core/BaseParser"; 2 | import { KuperStrategy } from "../strategies"; 3 | 4 | (function (): void { 5 | const parser = new BaseParser(new KuperStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/content/lenta.ts: -------------------------------------------------------------------------------- 1 | import { BaseParser } from "../core/BaseParser"; 2 | import { LentaStrategy } from "../strategies"; 3 | 4 | (function (): void { 5 | const parser = new BaseParser(new LentaStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/content/metro.ts: -------------------------------------------------------------------------------- 1 | import { BaseParser } from "../core/BaseParser"; 2 | import { MetroStrategy } from "../strategies"; 3 | 4 | (function boot(): void { 5 | const parser = new BaseParser(new MetroStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/content/ozon.ts: -------------------------------------------------------------------------------- 1 | import { OzonStrategy } from "../strategies"; 2 | import { BaseParser } from "../core/BaseParser"; 3 | 4 | (function boot(): void { 5 | const parser = new BaseParser(new OzonStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /vitest.setup.ts: -------------------------------------------------------------------------------- 1 | import { expect, afterEach } from "vitest"; 2 | import { cleanup } from "@testing-library/react"; 3 | import "@testing-library/jest-dom"; 4 | 5 | // Очистка после каждого теста 6 | afterEach(() => { 7 | cleanup(); 8 | }); 9 | -------------------------------------------------------------------------------- /src/content/auchan.ts: -------------------------------------------------------------------------------- 1 | import { AuchanStrategy } from "../strategies"; 2 | import { BaseParser } from "../core/BaseParser"; 3 | 4 | (function boot(): void { 5 | const parser = new BaseParser(new AuchanStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/content/perekrestok.ts: -------------------------------------------------------------------------------- 1 | import { BaseParser } from "@/core/BaseParser"; 2 | import { PerekrestokStrategy } from "@/strategies"; 3 | 4 | (function boot(): void { 5 | const parser = new BaseParser(new PerekrestokStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/content/pyaterochka.ts: -------------------------------------------------------------------------------- 1 | import { BaseParser } from "@/core/BaseParser"; 2 | import { PyaterochkaStrategy } from "@/strategies"; 3 | 4 | (function boot(): void { 5 | const parser = new BaseParser(new PyaterochkaStrategy()); 6 | parser.init(); 7 | })(); 8 | -------------------------------------------------------------------------------- /src/content/lavka.ts: -------------------------------------------------------------------------------- 1 | import { LavkaStrategy } from "../strategies"; 2 | import { BaseParser } from "../core/BaseParser"; 3 | 4 | // автозапуск 5 | (function boot(): void { 6 | const parser = new BaseParser(new LavkaStrategy()); 7 | parser.init(); 8 | })(); 9 | -------------------------------------------------------------------------------- /src/content/magnit.ts: -------------------------------------------------------------------------------- 1 | // Автозапуск 2 | import { MagnitStrategy } from "../strategies"; 3 | import { BaseParser } from "../core/BaseParser"; 4 | 5 | (function (): void { 6 | const parser = new BaseParser(new MagnitStrategy()); 7 | parser.init(); 8 | })(); 9 | -------------------------------------------------------------------------------- /src/content/samokat.ts: -------------------------------------------------------------------------------- 1 | import { SamokatStrategy } from "@/strategies"; 2 | import { BaseParser } from "@/core/BaseParser"; 3 | 4 | // Автозапуск 5 | (function (): void { 6 | const parser = new BaseParser(new SamokatStrategy()); 7 | parser.init(); 8 | })(); 9 | -------------------------------------------------------------------------------- /src/content/samberi.ts: -------------------------------------------------------------------------------- 1 | import { SamberiStrategy } from "../strategies"; 2 | import { BaseParser } from "../core/BaseParser"; 3 | 4 | // автозапуск 5 | (function boot(): void { 6 | const parser = new BaseParser(new SamberiStrategy()); 7 | parser.init(); 8 | })(); 9 | -------------------------------------------------------------------------------- /src/content/delivery_club.ts: -------------------------------------------------------------------------------- 1 | import { DeliveryClubStrategy } from "../strategies"; 2 | import { BaseParser } from "../core/BaseParser"; 3 | 4 | // автозапуск 5 | (function boot(): void { 6 | const parser = new BaseParser(new DeliveryClubStrategy()); 7 | parser.init(); 8 | })(); 9 | -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "composite": true, 4 | "skipLibCheck": true, 5 | "module": "ESNext", 6 | "moduleResolution": "bundler", 7 | "allowSyntheticDefaultImports": true, 8 | "paths": { 9 | "@/*": ["src/*"] 10 | } 11 | }, 12 | "include": ["vite.config.mjs"] 13 | } 14 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # игнорировать node_modules и сборку 2 | node_modules 3 | dist 4 | ext-dist 5 | build 6 | 7 | # игнорировать логи и кэш 8 | *.log 9 | .cache 10 | coverage 11 | 12 | # IDE и системные файлы 13 | .vscode 14 | .idea 15 | .DS_Store 16 | 17 | # статичные ассеты (если не нужно форматировать) 18 | public/**/*.svg 19 | public/**/*.png 20 | -------------------------------------------------------------------------------- /manifests/firefox.json: -------------------------------------------------------------------------------- 1 | { 2 | "permissions": ["tabs", "activeTab", "scripting"], 3 | "background": { 4 | "scripts": ["src/background/background.js"], 5 | "persistent": false 6 | }, 7 | "browser_specific_settings": { 8 | "gecko": { 9 | "id": "zakilo@syrnikovpavel.ru", 10 | "strict_min_version": "109.0" 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vitest/config"; 2 | import { resolve } from "path"; 3 | 4 | export default defineConfig({ 5 | resolve: { 6 | alias: { 7 | "@": resolve(__dirname, "./src"), 8 | }, 9 | }, 10 | test: { 11 | environment: "jsdom", 12 | globals: true, 13 | setupFiles: ["./vitest.setup.ts"], 14 | }, 15 | }); 16 | -------------------------------------------------------------------------------- /src/strategies/__tests__/MetroStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { MetroStrategy } from "@/strategies"; 2 | import { describe } from "vitest"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "./generalTest"; 5 | 6 | describe("MetroStrategy", () => { 7 | const strategy = new MetroStrategy(); 8 | const testCards = loadAllTestCards("metro"); 9 | testCards.forEach(generalCardTest(strategy)); 10 | }); 11 | -------------------------------------------------------------------------------- /src/strategies/__tests__/LavkaStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { LavkaStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("LavkaStrategy", () => { 7 | const strategy = new LavkaStrategy(); 8 | const testCards = loadAllTestCards("lavka"); 9 | const currentCardTest = generalCardTest(strategy); 10 | testCards.forEach(currentCardTest); 11 | }); 12 | -------------------------------------------------------------------------------- /src/strategies/__tests__/OzonStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { OzonStrategy } from "@/strategies"; 2 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 3 | import { describe } from "vitest"; 4 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 5 | 6 | describe("OzonStrategy", () => { 7 | const strategy = new OzonStrategy(); 8 | const testCards = loadAllTestCards("ozon"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /src/strategies/__tests__/AuchanStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { AuchanStrategy } from "@/strategies"; 2 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 3 | import { describe } from "vitest"; 4 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 5 | 6 | describe("AuchanStrategy", () => { 7 | const strategy = new AuchanStrategy(); 8 | const testCards = loadAllTestCards("auchan"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /src/strategies/__tests__/MagnitStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { MagnitStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("MagnitStrategy", () => { 7 | const strategy = new MagnitStrategy(); 8 | const testCards = loadAllTestCards("magnit"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /src/strategies/__tests__/SamberiStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { SamberiStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("SamberiStrategy", () => { 7 | const strategy = new SamberiStrategy(); 8 | const testCards = loadAllTestCards("samberi"); 9 | const currentCardTest = generalCardTest(strategy); 10 | testCards.forEach(currentCardTest); 11 | }); 12 | -------------------------------------------------------------------------------- /src/strategies/__tests__/SamokatStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { SamokatStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("SamokatStrategy", () => { 7 | const strategy = new SamokatStrategy(); 8 | const testCards = loadAllTestCards("samokat"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /src/strategies/__tests__/PerekrestokStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { PerekrestokStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("PerekrestokStrategy", () => { 7 | const strategy = new PerekrestokStrategy(); 8 | const testCards = loadAllTestCards("perekrestok"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /src/strategies/__tests__/PyaterochkaStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { PyaterochkaStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("PyaterochkaStrategy", () => { 7 | const strategy = new PyaterochkaStrategy(); 8 | const testCards = loadAllTestCards("pyaterochka"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /src/strategies/__tests__/DeliveryClubStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { DeliveryClubStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { generalCardTest } from "@/strategies/__tests__/generalTest"; 5 | 6 | describe("DeliveryClubStrategy", () => { 7 | const strategy = new DeliveryClubStrategy(); 8 | const testCards = loadAllTestCards("delivery-club"); 9 | 10 | const currentCardTest = generalCardTest(strategy); 11 | testCards.forEach(currentCardTest); 12 | }); 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "module": "ESNext", 5 | "moduleResolution": "node", 6 | "strict": true, 7 | "esModuleInterop": true, 8 | "skipLibCheck": true, 9 | "forceConsistentCasingInFileNames": true, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "noEmit": true, 13 | "jsx": "preserve", 14 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 15 | "baseUrl": ".", 16 | "paths": { 17 | "@/*": ["src/*"] 18 | } 19 | }, 20 | "include": ["src/**/*.ts", "src/**/*.tsx"], 21 | "exclude": ["node_modules"] 22 | } 23 | -------------------------------------------------------------------------------- /src/types/IStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { isNoneUnitLabel, isUnitLabel, type NoneUnitLabel, type UnitLabel } from "./IStrategy"; 2 | 3 | describe("IStrategy", () => { 4 | it("should check NoneUnitLabel", () => { 5 | const unitLabel: NoneUnitLabel = { 6 | unitLabel: null, 7 | multiplier: null, 8 | } as NoneUnitLabel; 9 | 10 | expect(isNoneUnitLabel(unitLabel)).toBe(true); 11 | }); 12 | 13 | it("should check UnitLabel", () => { 14 | const unitLabel: UnitLabel = { 15 | unitLabel: "kg", 16 | multiplier: 1, 17 | } as UnitLabel; 18 | expect(isUnitLabel(unitLabel)).toBe(true); 19 | }); 20 | }); -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 120, 3 | "tabWidth": 2, 4 | "useTabs": false, 5 | "semi": true, 6 | "singleQuote": false, 7 | "jsxSingleQuote": false, 8 | "trailingComma": "all", 9 | "bracketSpacing": true, 10 | "jsxBracketSameLine": false, 11 | "arrowParens": "always", 12 | "endOfLine": "lf", 13 | "htmlWhitespaceSensitivity": "css", 14 | "embeddedLanguageFormatting": "auto", 15 | "proseWrap": "preserve", 16 | "quoteProps": "as-needed", 17 | "overrides": [ 18 | { 19 | "files": "*.json", 20 | "options": { "tabWidth": 2, "printWidth": 120 } 21 | }, 22 | { 23 | "files": ["*.yml", "*.yaml"], 24 | "options": { "tabWidth": 2 } 25 | }, 26 | { 27 | "files": ["*.md", "*.mdx"], 28 | "options": { "proseWrap": "always" } 29 | } 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/auchan/card_no_weight.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
\"Зарядное
Зарядное устройство универсальное
1290 ₽
", 3 | "expectedParsedPrice": 1290, 4 | 5 | "expectedNoneUnitPrice": { 6 | "text": "Нет инф.", 7 | "styles": { 8 | "display": "inline-block", 9 | "marginLeft": "0.5em", 10 | "color": "rgb(0, 0, 0)", 11 | "background": "var(--accent-color,rgba(0, 69, 198, 0.13)) var(--accent-color,rgba(0, 69, 198, 0.13))", 12 | "padding": "2px 6px", 13 | "borderRadius": "4px", 14 | "fontWeight": "900", 15 | "fontSize": "calc(0.95vw)" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "es2021": true 6 | }, 7 | "parser": "@typescript-eslint/parser", 8 | "parserOptions": { 9 | "ecmaVersion": "latest", 10 | "sourceType": "module", 11 | "project": "./tsconfig.json" 12 | }, 13 | "plugins": ["@typescript-eslint", "vitest"], 14 | "extends": ["eslint:recommended", "plugin:@typescript-eslint/recommended"], 15 | "rules": { 16 | "@typescript-eslint/no-unused-vars": "warn", 17 | "@typescript-eslint/no-explicit-any": "warn", 18 | "@typescript-eslint/consistent-type-imports": "error", 19 | "@typescript-eslint/ban-ts-comment": "warn", 20 | "semi": ["error", "always"], 21 | "no-console": "warn", 22 | "vitest/no-focused-tests": "error", 23 | "vitest/no-identical-title": "error" 24 | }, 25 | "overrides": [ 26 | { 27 | "files": ["**/*.test.ts"], 28 | "env": { 29 | "node": true 30 | } 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /src/strategies/index.ts: -------------------------------------------------------------------------------- 1 | import { LentaStrategy } from "@/strategies/LentaStrategy"; 2 | import { AuchanStrategy } from "./AuchanStrategy"; 3 | import { DeliveryClubStrategy } from "./DeliveryClubStrategy"; 4 | import { KuperStrategy } from "./KuperStrategy"; 5 | import { LavkaStrategy } from "./LavkaStrategy"; 6 | import { MagnitStrategy } from "./MagnitStrategy"; 7 | import { MetroStrategy } from "./MetroStrategy"; 8 | import { OzonStrategy } from "./OzonStrategy"; 9 | import { PerekrestokStrategy } from "./PerekrestokStrategy"; 10 | import { PyaterochkaStrategy } from "./PyaterochkaStrategy"; 11 | import { SamberiStrategy } from "./SamberiStrategy"; 12 | import { SamokatStrategy } from "./SamokatStrategy"; 13 | 14 | export { 15 | AuchanStrategy, 16 | DeliveryClubStrategy, 17 | KuperStrategy, 18 | LavkaStrategy, 19 | LentaStrategy, 20 | MagnitStrategy, 21 | MetroStrategy, 22 | OzonStrategy, 23 | PerekrestokStrategy, 24 | PyaterochkaStrategy, 25 | SamberiStrategy, 26 | SamokatStrategy, 27 | }; 28 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/magnit/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
\"Product
Яблоки Гала 1 кг
99.90 ₽
", 3 | "expectedParsedPrice": 99.9, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 1 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "100", 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "block", 13 | "color": "rgb(0, 0, 0)", 14 | "backgroundColor": "rgb(230, 245, 239)", 15 | "padding": "2px 6px 2px 0.5px", 16 | "borderRadius": "4px", 17 | "fontWeight": "600", 18 | "fontSize": "14px", 19 | "marginBottom": "4px" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/magnit/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
\"Product
Печенье овсяное 250 г
79.90 ₽
", 3 | "expectedParsedPrice": 79.9, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 4 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "320", 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "block", 13 | "color": "rgb(0, 0, 0)", 14 | "backgroundColor": "rgb(230, 245, 239)", 15 | "padding": "2px 6px 2px 0.5px", 16 | "borderRadius": "4px", 17 | "fontWeight": "600", 18 | "fontSize": "14px", 19 | "marginBottom": "4px" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/magnit/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
\"Product
Молоко Домик в деревне
89.90 ₽
", 3 | "expectedParsedPrice": 89.9, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 шт", 6 | "multiplier": 1 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "90", 10 | "label": "1 шт", 11 | "styles": { 12 | "display": "block", 13 | "color": "rgb(0, 0, 0)", 14 | "backgroundColor": "rgb(230, 245, 239)", 15 | "padding": "2px 6px 2px 0.5px", 16 | "borderRadius": "4px", 17 | "fontWeight": "600", 18 | "fontSize": "14px", 19 | "marginBottom": "4px" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/magnit/card5.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
\"Product
Сок апельсиновый 950 мл
129.90 ₽
", 3 | "expectedParsedPrice": 129.9, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 1.053 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "137", 10 | "label": "1 л", 11 | "styles": { 12 | "display": "block", 13 | "color": "rgb(0, 0, 0)", 14 | "backgroundColor": "rgb(230, 245, 239)", 15 | "padding": "2px 6px 2px 0.5px", 16 | "borderRadius": "4px", 17 | "fontWeight": "600", 18 | "fontSize": "14px", 19 | "marginBottom": "4px" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/magnit/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
\"Product
Сыр Российский 300 г
199.90 ₽
", 3 | "expectedParsedPrice": 199.9, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 3.333 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "666", 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "block", 13 | "color": "rgb(0, 0, 0)", 14 | "backgroundColor": "rgb(230, 245, 239)", 15 | "padding": "2px 6px 2px 0.5px", 16 | "borderRadius": "4px", 17 | "fontWeight": "600", 18 | "fontSize": "14px", 19 | "marginBottom": "4px" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /manifests/base.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "заКило", 3 | "version": "1.0.3", 4 | "manifest_version": 3, 5 | "description": "Всё по справедливой цене: сравнивайте стоимость за единицу прямо в каталоге доставки", 6 | "permissions": ["tabs", "scripting"], 7 | "host_permissions": [ 8 | "*://*.ozon.ru/*", 9 | "*://*.auchan.ru/*", 10 | "*://market-delivery.yandex.ru/*", 11 | "*://eda.yandex.ru/*", 12 | "*://lavka.yandex.ru/*", 13 | "*://kuper.ru/*", 14 | "*://lenta.com/*", 15 | "*://magnit.ru/*", 16 | "*://*.perekrestok.ru/*", 17 | "*://shop.samberi.com/*", 18 | "*://5ka.ru/*", 19 | "*://samokat.ru/*", 20 | "*://online.metro-cc.ru/*" 21 | ], 22 | "action": { 23 | "default_icon": { 24 | "16": "icons/icon16.png", 25 | "48": "icons/icon48.png", 26 | "128": "icons/icon128.png" 27 | } 28 | }, 29 | "icons": { 30 | "16": "icons/icon16.png", 31 | "48": "icons/icon48.png", 32 | "128": "icons/icon128.png" 33 | }, 34 | "web_accessible_resources": [ 35 | { 36 | "resources": ["src/content/*.js"], 37 | "matches": [""] 38 | } 39 | ] 40 | } 41 | -------------------------------------------------------------------------------- /vite.config.mjs: -------------------------------------------------------------------------------- 1 | import { merge } from "lodash-es"; 2 | import { defineConfig } from "vite"; 3 | import webExtension from "vite-plugin-web-extension"; 4 | 5 | export default defineConfig(({ mode }) => { 6 | const target = mode; 7 | return { 8 | plugins: [ 9 | webExtension({ 10 | manifest: () => merge(require("./manifests/base.json"), require(`./manifests/${target}.json`)), 11 | additionalInputs: [ 12 | "src/content/auchan.ts", 13 | "src/content/delivery_club.ts", 14 | "src/content/kuper.ts", 15 | "src/content/lavka.ts", 16 | "src/content/lenta.ts", 17 | "src/content/magnit.ts", 18 | "src/content/ozon.ts", 19 | "src/content/perekrestok.ts", 20 | "src/content/pyaterochka.ts", 21 | "src/content/samberi.ts", 22 | "src/content/samokat.ts", 23 | "src/content/metro.ts", 24 | ], 25 | browser: target, 26 | }), 27 | ], 28 | build: { 29 | outDir: `dist/${target}`, 30 | }, 31 | resolve: { 32 | alias: { 33 | "@": "/src", 34 | }, 35 | }, 36 | }; 37 | }); 38 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/loadTestData.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | 4 | /** 5 | * Load test data for a specific strategy 6 | * @param strategyName - The name of the strategy (e.g., 'auchan') 7 | * @param cardName - The name of the card file without extension (e.g., 'card1') 8 | * @returns The test data from the JSON file 9 | */ 10 | export function loadTestCard(strategyName: string, cardName: string) { 11 | const filePath = path.resolve(__dirname, strategyName, `${cardName}.json`); 12 | const fileContent = fs.readFileSync(filePath, "utf-8"); 13 | return JSON.parse(fileContent); 14 | } 15 | 16 | /** 17 | * Load all test cards for a specific strategy 18 | * @param strategyName - The name of the strategy (e.g., 'auchan') 19 | * @returns An array of test data objects 20 | */ 21 | export function loadAllTestCards(strategyName: string) { 22 | const directoryPath = path.resolve(__dirname, strategyName); 23 | const files = fs.readdirSync(directoryPath).filter((file) => file.endsWith(".json")); 24 | 25 | return files.map((file) => { 26 | const filePath = path.resolve(directoryPath, file); 27 | const fileContent = fs.readFileSync(filePath, "utf-8"); 28 | return JSON.parse(fileContent); 29 | }); 30 | } 31 | -------------------------------------------------------------------------------- /src/types/IStrategy.ts: -------------------------------------------------------------------------------- 1 | import type { Tagged } from "type-fest"; 2 | export type UnitLabel = Tagged< 3 | { 4 | unitLabel: string; 5 | multiplier: number; 6 | }, 7 | "UnitLabel" 8 | >; 9 | 10 | export type NoneUnitLabel = Tagged< 11 | { 12 | unitLabel: null; 13 | multiplier: null; 14 | }, 15 | "NoneUnitLabel" 16 | >; 17 | 18 | export const isUnitLabel = (value: UnitLabel | NoneUnitLabel): value is UnitLabel => { 19 | return (value as UnitLabel).unitLabel !== null; 20 | }; 21 | 22 | export const isNoneUnitLabel = (value: UnitLabel | NoneUnitLabel): value is NoneUnitLabel => { 23 | return (value as NoneUnitLabel).unitLabel === null; 24 | }; 25 | 26 | 27 | export interface IStrategy { 28 | strategyName: string /* */; 29 | selectors: { 30 | card: string; 31 | price: string; 32 | discountPrice?: string; 33 | name: string; 34 | unitPrice: string; 35 | volume?: string; 36 | renderRoot?: string; 37 | priceUnit?: string; 38 | }; 39 | 40 | getCardSelector(): string; 41 | parsePrice(cardEl: HTMLElement): number; 42 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel; 43 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void; 44 | renderNoneUnitPrice(cardEl: HTMLElement): void; 45 | shouldProcess(cardEl: HTMLElement): boolean; 46 | process(cardEl: HTMLElement): void; 47 | log(...args: any[]): void; 48 | } -------------------------------------------------------------------------------- /src/strategies/__test_data__/metro/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Окорок ягненка Мясо есть! Халяль охлажденный, ~1кг", 3 | "price": 2499, 4 | "priceUnit": "/кг", 5 | "oldPrice": null, 6 | "discount": null, 7 | "url": "/products/okorok-yagnenka-myaso-est-halyal-ohlazhdennyj-1kg", 8 | "image": "https://cdn.metro-cc.ru/ru/ru_pim_561457001001_01.png", 9 | "html": "", 10 | "expectedParsedPrice": 2499, 11 | "expectedParsedQuantity": { 12 | "multiplier": 1, 13 | "unitLabel": "1 кг" 14 | }, 15 | "expectedUnitPrice": { 16 | "price": "2499", 17 | "label": "1 кг", 18 | "styles": {} 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/metro/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 2545, 4 | "expectedParsedQuantity": { 5 | "multiplier": 2.5, 6 | "unitLabel": "1 кг" 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "6363", 10 | "label": "1 кг", 11 | "styles": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/metro/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 769, 4 | "expectedParsedQuantity": { 5 | "multiplier": 1.6666666666666667, 6 | "unitLabel": "1 кг" 7 | }, 8 | "expectedUnitPrice": { 9 | "price": "1282", 10 | "label": "1 кг", 11 | "styles": {} 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/strategies/__tests__/KuperStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { describe } from "vitest"; 2 | import { KuperStrategy } from "@/strategies"; 3 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 4 | import { roundNumber } from "@/utils/converters"; 5 | 6 | describe("KuperStrategy", () => { 7 | const strategy = new KuperStrategy(); 8 | const testCards = loadAllTestCards("kuper"); 9 | 10 | testCards.forEach((testCard: any, index: number) => { 11 | describe(`Card ${index + 1}`, () => { 12 | // Create a DOM element from the HTML in the test data 13 | const cardEl = document.createElement("div"); 14 | cardEl.innerHTML = testCard.html; 15 | 16 | it("should parse price correctly", () => { 17 | const price = strategy.parsePrice(cardEl); 18 | expect(roundNumber(price, 1)).toBe(testCard.expectedParsedPrice); 19 | }); 20 | 21 | it("should render unit price correctly", () => { 22 | const { price, label, styles: expectedStyles } = testCard.expectedUnitPrice; 23 | 24 | const calculatedPrice = testCard.expectedParsedPrice * testCard.expectedParsedQuantity.multiplier; 25 | strategy.renderUnitPrice(cardEl, calculatedPrice, label); 26 | 27 | const renderedUnitPrice = cardEl.querySelector('[data-testid="unit-price"]'); 28 | expect(renderedUnitPrice).toBeTruthy(); 29 | expect(renderedUnitPrice?.textContent).toBe(`${price}\u2009₽ за ${label}`); 30 | 31 | const styles = window.getComputedStyle(renderedUnitPrice as HTMLElement); 32 | 33 | // Check that all expected styles are applied 34 | Object.entries(expectedStyles).forEach(([property, value]) => { 35 | expect(styles[property as any]).toBe(value); 36 | }); 37 | }); 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /.github/workflows/pr-checks.yml: -------------------------------------------------------------------------------- 1 | name: PR Checks 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**.md" 7 | - "docs/**" 8 | - "screenshots/**" 9 | 10 | jobs: 11 | test: 12 | name: Test and Lint 13 | runs-on: ubuntu-latest 14 | timeout-minutes: 15 15 | 16 | strategy: 17 | matrix: 18 | node-version: [18.x, 20.x] 19 | 20 | steps: 21 | - name: Checkout code 22 | uses: actions/checkout@v4 23 | 24 | - name: Setup Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: "npm" 29 | 30 | - name: Install dependencies 31 | run: npm ci 32 | 33 | - name: Type check 34 | run: npm run type-check 35 | 36 | - name: Lint check 37 | run: npm run lint 38 | 39 | - name: Run tests 40 | run: npm run test:ci 41 | 42 | build-test: 43 | name: Build Test 44 | runs-on: ubuntu-latest 45 | timeout-minutes: 10 46 | needs: test 47 | 48 | strategy: 49 | matrix: 50 | browser: [chrome, firefox] 51 | 52 | steps: 53 | - name: Checkout code 54 | uses: actions/checkout@v4 55 | 56 | - name: Setup Node.js 57 | uses: actions/setup-node@v4 58 | with: 59 | node-version: "18.x" 60 | cache: "npm" 61 | 62 | - name: Install dependencies 63 | run: npm ci 64 | 65 | - name: Build for ${{ matrix.browser }} 66 | run: npm run build:${{ matrix.browser }} 67 | 68 | - name: Test packaging for ${{ matrix.browser }} 69 | run: npm run pack:${{ matrix.browser }} 70 | 71 | - name: Upload build artifacts 72 | uses: actions/upload-artifact@v4 73 | with: 74 | name: build-test-${{ matrix.browser }} 75 | path: ext-dist/zaKilo-${{ matrix.browser }}-*.zip 76 | retention-days: 1 77 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/lenta/card_no_weight.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 389.99, 4 | "expectedParsedQuantity": { 5 | "unitLabel": null, 6 | "multiplier": null 7 | }, 8 | "expectedNoneUnitPrice": { 9 | "text": "Нет инф.", 10 | "styles": { 11 | "display": "inline-block", 12 | "marginLeft": "0.4em", 13 | "marginRight": "0.4em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px", 16 | "borderRadius": "4px", 17 | "backgroundColor": "var(--accent-color,rgba(0, 69, 198, 0.13))", 18 | "fontWeight": "900", 19 | "fontSize": "calc(0.7vw)" 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/perekrestok/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    120 ₽
    Сыр Российский 50%, 200г
    200 г
  • ", 3 | "expectedParsedPrice": 120, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 5 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 600, 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/perekrestok/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    60 ₽
    Хлеб Бородинский нарезной, 350г
    350 г
  • ", 3 | "expectedParsedPrice": 60, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 2.857 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 171, 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/core/ParserStrategy.ts: -------------------------------------------------------------------------------- 1 | import { isNoneUnitLabel, isUnitLabel, type IStrategy, type NoneUnitLabel, type UnitLabel } from "@/types/IStrategy"; 2 | 3 | const isDev = import.meta.env.DEV; 4 | 5 | export abstract class ParserStrategy implements IStrategy { 6 | public strategyName: string; 7 | public selectors: IStrategy["selectors"]; 8 | 9 | protected constructor() { 10 | this.strategyName = "unknown"; 11 | this.selectors = { 12 | card: "", 13 | price: "", 14 | name: "", 15 | unitPrice: "", 16 | }; 17 | } 18 | 19 | public getCardSelector(): string { 20 | if (!this.selectors.card) throw new Error("card selector not defined"); 21 | return this.selectors.card; 22 | } 23 | 24 | public shouldProcess(cardEl: HTMLElement): boolean { 25 | const selectorsSet = this.selectors?.card && this.selectors?.price && this.selectors?.name; 26 | 27 | if (!selectorsSet) { 28 | throw new Error(`selectors not set for ${this.strategyName} strategy`); 29 | } 30 | 31 | return Boolean( 32 | cardEl.querySelector(this.selectors.price!) && 33 | cardEl.querySelector(this.selectors.name!) && 34 | !cardEl.querySelector(this.selectors.unitPrice!), 35 | ); 36 | } 37 | 38 | process(cardEl: HTMLElement): void { 39 | this.log("processing card", cardEl); 40 | const price = this.parsePrice(cardEl); 41 | this.log("parsed price", price); 42 | 43 | const parsedQuantity: UnitLabel | NoneUnitLabel = this.parseQuantity(cardEl); 44 | 45 | if (isNoneUnitLabel(parsedQuantity) ) { 46 | this.log("none information about quantity"); 47 | this.renderNoneUnitPrice(cardEl); 48 | } 49 | 50 | if (isUnitLabel(parsedQuantity)) { 51 | const { unitLabel, multiplier } = parsedQuantity; 52 | this.log("parsed quantity", { unitLabel, multiplier }); 53 | 54 | const unitPrice = price * multiplier; 55 | this.log("calculated unit price", unitPrice); 56 | this.renderUnitPrice(cardEl, unitPrice, unitLabel); 57 | } 58 | } 59 | 60 | log(...args: unknown[]): void { 61 | if (isDev) { 62 | console.log(`[${this.strategyName}]`, ...args); 63 | } 64 | } 65 | 66 | abstract parsePrice(cardEl: HTMLElement): number; 67 | abstract parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel; 68 | abstract renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void; 69 | abstract renderNoneUnitPrice(cardEl: HTMLElement): void; 70 | } 71 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/delivery-club/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    56 ₽
    Бифилайф яблоко-банан с 8 месяцев 2.5 % Зелёная Линия, 210 мл
    210 мл
  • ", 3 | "expectedParsedPrice": 56, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 4.762 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 267, 10 | "label": "1 л", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/delivery-club/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    56 ₽
    Бифилайф яблоко-банан с 8 месяцев 2.5 % Зелёная Линия, 210 мл
    210 мл
  • ", 3 | "expectedParsedPrice": 56, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 4.762 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 267, 10 | "label": "1 л", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/perekrestok/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    80 ₽
    Яйцо куриное пищевое столовое С1 Пр!ст, 10шт 600г
    600 г
  • ", 3 | "expectedParsedPrice": 80, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 1.667 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 133, 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/perekrestok/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    100 ₽
    Молоко пастеризованное Домик в деревне 2.5% 930 мл
    930 мл
  • ", 3 | "expectedParsedPrice": 100, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 1.075 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 108, 10 | "label": "1 л", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/delivery-club/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    –21%
    980 ₽1  253 ₽
    Помидоры Черри сладкие 500г
    500 г
  • ", 3 | "expectedParsedPrice": 980, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 2 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 1960, 10 | "label": "1 кг", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/__tests__/generalTest.ts: -------------------------------------------------------------------------------- 1 | import type { UnitLabel } from "@/types/IStrategy"; 2 | import { isNoneUnitLabel, isUnitLabel, type IStrategy } from "@/types/IStrategy"; 3 | import { roundNumber } from "@/utils/converters"; 4 | import { describe, expect, it } from "vitest"; 5 | 6 | export const generalCardTest = (strategy: IStrategy) => (testCard: any, index: number) => { 7 | describe(`Card ${index + 1}`, () => { 8 | const cardEl = document.createElement("div"); 9 | cardEl.innerHTML = testCard.html; 10 | 11 | const quantity = strategy.parseQuantity(cardEl); 12 | 13 | if (isNoneUnitLabel(quantity)) { 14 | it("should render 'no info' label correctly when weight not found", () => { 15 | strategy.renderNoneUnitPrice(cardEl); 16 | 17 | const renderedUnitPrice = cardEl.querySelector('[data-testid="unit-price"]'); 18 | expect(renderedUnitPrice).toBeTruthy(); 19 | expect(renderedUnitPrice?.textContent).toBe("Нет инф."); 20 | 21 | const styles = window.getComputedStyle(renderedUnitPrice as HTMLElement); 22 | 23 | Object.entries(testCard.expectedNoneUnitPrice.styles).forEach(([property, value]) => { 24 | expect(styles[property as keyof CSSStyleDeclaration]).toBe(value); 25 | }); 26 | }); 27 | } 28 | 29 | if (isUnitLabel(quantity)) { 30 | it("should parse quantity correctly", () => { 31 | const quantity = strategy.parseQuantity(cardEl) as UnitLabel; 32 | expect(roundNumber(quantity.multiplier, 3)).toEqual(roundNumber(testCard.expectedParsedQuantity.multiplier, 3)); 33 | expect(quantity.unitLabel).toEqual(testCard.expectedParsedQuantity.unitLabel); 34 | }); 35 | 36 | it("should parse price correctly", () => { 37 | const price = strategy.parsePrice(cardEl); 38 | expect(roundNumber(price, 1)).toBe(testCard.expectedParsedPrice); 39 | }); 40 | 41 | it("should render unit price correctly", () => { 42 | const { price, label, styles: expectedStyles } = testCard.expectedUnitPrice; 43 | 44 | const calculatedPrice = testCard.expectedParsedPrice * testCard.expectedParsedQuantity.multiplier; 45 | strategy.renderUnitPrice(cardEl, calculatedPrice, label); 46 | 47 | const renderedUnitPrice = cardEl.querySelector('[data-testid="unit-price"]'); 48 | expect(renderedUnitPrice).toBeTruthy(); 49 | expect(renderedUnitPrice?.textContent).toBe(`${price}\u2009₽ за ${label}`); 50 | 51 | const styles = window.getComputedStyle(renderedUnitPrice as HTMLElement); 52 | // Check that all expected styles are applied 53 | Object.entries(expectedStyles).forEach(([property, value]) => { 54 | expect(styles[property as keyof CSSStyleDeclaration]).toBe(value); 55 | }); 56 | }); 57 | } 58 | }); 59 | }; 60 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zakilo-extension", 3 | "version": "1.0.9", 4 | "description": "Расширение для сравнения цен на продуктов в онлайн-магазинах", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/SyrnikovPavel/zaKilo-extension.git" 8 | }, 9 | "keywords": [ 10 | "browser extension", 11 | "price comparison" 12 | ], 13 | "main": "index.js", 14 | "engines": { 15 | "node": ">=18.0.0" 16 | }, 17 | "scripts": { 18 | "dev:chrome": "vite --mode=chrome", 19 | "dev:firefox": "vite --mode=firefox", 20 | "build:chrome": "vite build --mode=chrome", 21 | "build:firefox": "vite build --mode=firefox", 22 | "prepack:firefox": "npm run lint && npm run test:ci && npm run build:firefox", 23 | "prepack:chrome": "npm run lint && npm run test:ci && npm run build:chrome", 24 | "pack:firefox": "web-ext build --source-dir=dist/firefox --artifacts-dir=ext-dist --overwrite-dest --filename=zaKilo-firefox-{version}.zip", 25 | "pack:chrome": "web-ext build --source-dir=dist/chrome --artifacts-dir=ext-dist --overwrite-dest --filename=zaKilo-chrome-{version}.zip", 26 | "prepare": "husky", 27 | "type-check": "tsc --noEmit", 28 | "lint": "eslint . --ext .ts,.tsx", 29 | "lint:fix": "eslint . --ext .ts,.tsx --fix", 30 | "test": "vitest", 31 | "test:ci": "vitest run", 32 | "test:coverage": "vitest run --coverage", 33 | "verify": "npm run type-check && npm run lint && npm run test" 34 | }, 35 | "author": "", 36 | "license": "ISC", 37 | "devDependencies": { 38 | "@testing-library/dom": "^9.3.4", 39 | "@testing-library/jest-dom": "^6.4.2", 40 | "@testing-library/react": "^14.2.1", 41 | "@types/chrome": "^0.0.319", 42 | "@types/convert-units": "2.3.11", 43 | "@types/firefox-webext-browser": "^120.0.4", 44 | "@types/jest": "^29.5.12", 45 | "@types/node": "^22.15.3", 46 | "@types/testing-library__jest-dom": "^5.14.9", 47 | "@typescript-eslint/eslint-plugin": "6.15.0", 48 | "@typescript-eslint/parser": "6.15.0", 49 | "eslint-plugin-vitest": "0.3.20", 50 | "husky": "^9.1.7", 51 | "jsdom": "^24.1.3", 52 | "lint-staged": "^15.5.1", 53 | "lodash-es": "^4.17.21", 54 | "prettier": "3.5.3", 55 | "type-fest": "4.41.0", 56 | "typescript": "^5.3.3", 57 | "vite": "^6.3.3", 58 | "vite-plugin-web-extension": "^4.4.3", 59 | "vitest": "3.1.3", 60 | "web-ext": "^8.6.0" 61 | }, 62 | "lint-staged": { 63 | "*.{js,json,md}": "prettier --write", 64 | "src/**/*.ts": [ 65 | "eslint --fix", 66 | "vitest related --run" 67 | ] 68 | }, 69 | "resolutions": { 70 | "lodash-es": "^4.17.21" 71 | }, 72 | "dependencies": { 73 | "convert-units": "2.3.4" 74 | }, 75 | "optionalDependencies": { 76 | "@rollup/rollup-win32-x64-msvc": "4.44.0" 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | environment.yml 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | .parcel-cache 87 | 88 | # Next.js build output 89 | .next 90 | out 91 | 92 | # Nuxt.js build / generate output 93 | .nuxt 94 | dist 95 | 96 | # Gatsby files 97 | .cache/ 98 | # Comment in the public line in if your project uses Gatsby and not Next.js 99 | # https://nextjs.org/blog/next-9-1#public-directory-support 100 | # public 101 | 102 | # vuepress build output 103 | .vuepress/dist 104 | 105 | # vuepress v2.x temp and cache directory 106 | .temp 107 | 108 | # vitepress build output 109 | **/.vitepress/dist 110 | 111 | # vitepress cache directory 112 | **/.vitepress/cache 113 | 114 | # Docusaurus cache and generated files 115 | .docusaurus 116 | 117 | # Serverless directories 118 | .serverless/ 119 | 120 | # FuseBox cache 121 | .fusebox/ 122 | 123 | # DynamoDB Local files 124 | .dynamodb/ 125 | 126 | # TernJS port file 127 | .tern-port 128 | 129 | # Stores VSCode versions used for testing VSCode extensions 130 | .vscode-test 131 | 132 | # yarn v2 133 | .yarn/cache 134 | .yarn/unplugged 135 | .yarn/build-state.yml 136 | .yarn/install-state.gz 137 | .pnp.* 138 | 139 | # Специфичные для проекта 140 | 141 | .idea 142 | .vscode 143 | 144 | .aider* 145 | 146 | ext-dist 147 | -------------------------------------------------------------------------------- /src/strategies/__tests__/LentaStrategy.test.ts: -------------------------------------------------------------------------------- 1 | import { LentaStrategy } from "@/strategies"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { isNoneUnitLabel, isUnitLabel } from "@/types/IStrategy"; 4 | import { roundNumber } from "@/utils/converters"; 5 | import { describe, expect, it } from "vitest"; 6 | import { loadAllTestCards } from "../__test_data__/loadTestData"; 7 | 8 | describe("LentaStrategy", () => { 9 | const strategy = new LentaStrategy(); 10 | const testCards = loadAllTestCards("lenta"); 11 | 12 | testCards.forEach((testCard: any, index: number) => { 13 | describe(`Card ${index + 1}`, () => { 14 | const cardEl = document.createElement("div"); 15 | cardEl.innerHTML = testCard.html; 16 | 17 | const quantity: NoneUnitLabel | UnitLabel = strategy.parseQuantity(cardEl); 18 | 19 | if (isNoneUnitLabel(quantity)) { 20 | it("should render 'no info' label correctly when weight not found", () => { 21 | strategy.renderNoneUnitPrice(cardEl); 22 | 23 | const renderedUnitPrice: HTMLElement = cardEl.querySelector('[data-testid="unit-price"]') as HTMLElement; 24 | expect(renderedUnitPrice).toBeTruthy(); 25 | expect(renderedUnitPrice?.textContent).toBe("Нет инф."); 26 | }); 27 | } 28 | 29 | 30 | if (isUnitLabel(quantity)) { 31 | 32 | 33 | it("should parse price correctly", () => { 34 | const price = strategy.parsePrice(cardEl); 35 | expect(roundNumber(price, 1)).toBe(testCard.expectedParsedPrice); 36 | }); 37 | 38 | it("should render unit price correctly", () => { 39 | const { price, label, styles: expectedStyles } = testCard.expectedUnitPrice; 40 | 41 | const calculatedPrice = testCard.expectedParsedPrice * testCard.expectedParsedQuantity.multiplier; 42 | strategy.renderUnitPrice(cardEl, calculatedPrice, label); 43 | 44 | const renderedUnitPrice: HTMLElement = cardEl.querySelector('[data-testid="unit-price"]') as HTMLElement; 45 | expect(renderedUnitPrice).toBeTruthy(); 46 | expect(renderedUnitPrice?.textContent).toBe(`${price}\u2009₽/${label}`); 47 | 48 | const styles = window.getComputedStyle(renderedUnitPrice as HTMLElement); 49 | 50 | // Check that all expected styles are applied 51 | Object.entries(expectedStyles).forEach(([property, value]) => { 52 | expect(styles[property as keyof CSSStyleDeclaration]).toBe(value); 53 | }); 54 | 55 | const priceAndButtons = cardEl.querySelector(".price-and-buttons"); 56 | const priceBlock = priceAndButtons?.querySelector(".product-price"); 57 | const buttonBlock = priceAndButtons?.querySelector(".ng-star-inserted:has(lu-counter-first-add)"); 58 | 59 | expect(priceBlock?.nextElementSibling).toBe(renderedUnitPrice); 60 | expect(renderedUnitPrice?.nextElementSibling).toBe(buttonBlock); 61 | }); 62 | } 63 | }); 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/background/background.ts: -------------------------------------------------------------------------------- 1 | interface SiteConfig { 2 | match: string[]; 3 | script: string; 4 | } 5 | 6 | const api = typeof browser !== "undefined" ? browser : chrome; 7 | 8 | const siteMap: SiteConfig[] = [ 9 | { 10 | match: ["*://*.ozon.ru/*"], 11 | script: "src/content/ozon.js", 12 | }, 13 | { 14 | match: ["*://*.auchan.ru/*"], 15 | script: "src/content/auchan.js", 16 | }, 17 | { 18 | match: ["*://market-delivery.yandex.ru/*"], 19 | script: "src/content/delivery_club.js", 20 | }, 21 | { 22 | match: ["*://eda.yandex.ru/*"], 23 | script: "src/content/delivery_club.js", 24 | }, 25 | { 26 | match: ["*://lavka.yandex.ru/*"], 27 | script: "src/content/lavka.js", 28 | }, 29 | { 30 | match: ["*://kuper.ru/*"], 31 | script: "src/content/kuper.js", 32 | }, 33 | { 34 | match: ["*://lenta.com/*"], 35 | script: "src/content/lenta.js", 36 | }, 37 | { 38 | match: ["*://magnit.ru/*"], 39 | script: "src/content/magnit.js", 40 | }, 41 | { 42 | match: ["*://*.perekrestok.ru/*"], 43 | script: "src/content/perekrestok.js", 44 | }, 45 | { 46 | match: ["*://shop.samberi.com/*"], 47 | script: "src/content/samberi.js", 48 | }, 49 | { 50 | match: ["*://5ka.ru/*"], 51 | script: "src/content/pyaterochka.js", 52 | }, 53 | { 54 | match: ["*://samokat.ru/*"], 55 | script: "src/content/samokat.js", 56 | }, 57 | { 58 | match: ["*://online.metro-cc.ru/*"], 59 | script: "src/content/metro.js", 60 | }, 61 | ]; 62 | 63 | function matches(url: string, patterns: string[]): boolean { 64 | return patterns.some((p) => new RegExp("^" + p.replace(/\*/g, ".*") + "$").test(url)); 65 | } 66 | 67 | // инъекция с учётом Firefox 68 | async function inject(tabId: number, files: string[]): Promise { 69 | if (api.scripting && api.scripting.executeScript) { 70 | await api.scripting.executeScript({ target: { tabId }, files }); 71 | } else { 72 | for (const file of files) { 73 | await api.tabs.executeScript(tabId, { file }); 74 | } 75 | } 76 | } 77 | 78 | api.tabs.onUpdated.addListener((tabId: number, changeInfo, tab): void => { 79 | if (changeInfo.status !== "complete" || !tab.url) return; 80 | for (const site of siteMap) { 81 | if (matches(tab.url, site.match)) { 82 | inject(tabId, [site.script]) 83 | .then(() => console.log(`Injected ${site.script} into ${tab.url}`)) 84 | .catch((err) => console.error("Injection failed:", err)); 85 | break; 86 | } 87 | } 88 | }); 89 | 90 | api.runtime.onInstalled.addListener((details) => { 91 | const currentVersion = api.runtime.getManifest().version; 92 | 93 | if (details.reason === "install") { 94 | // Первая установка 95 | api.tabs.create({ url: "https://zakilo.syrnikovpavel.ru/" }); 96 | } else if (details.reason === "update") { 97 | // Обновление: показываем changelog только для нужных версий 98 | switch (currentVersion) { 99 | case "1.0.2": 100 | api.tabs.create({ url: "https://zakilo.syrnikovpavel.ru/1.0.2" }); 101 | break; 102 | case "1.0.3": 103 | api.tabs.create({ url: "https://zakilo.syrnikovpavel.ru/1.0.3" }); 104 | break; 105 | default: 106 | // Для остальных версий ничего не открываем 107 | break; 108 | } 109 | } 110 | }); 111 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samberi/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    \n\t\t
    \n\t
    \n\t\t\n\t\t(6\t\t\t)\n\t
    \n\t\n\t\t\n\n\t\t\t\t\t\n\t\t\t\t\n\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t
    \n\n\t\n\t
    \n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t149.99 \t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tВ корзину\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\n\t
    ", 3 | "expectedParsedPrice": 150.0, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 2.127659574468085 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 319, 10 | "label": "1 кг", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samberi/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    \n\t\t
    \n\t
    \n\t\t\n\t\t(0\t\t\t)\n\t
    \n\t\n\t\t\n\n\t\t\t\t\t\n\t\t\t\t\n\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t
    \n\n\t\n\t
    \n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t127.99 \t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tВ корзину\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t
    ", 3 | "expectedParsedPrice": 128.0, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 1.0 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 128, 10 | "label": "1 л", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samberi/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    \n\t\t
    \n\t
    \n\t\t\n\t\t(0\t\t\t)\n\t
    \n\t\n\t\t\n\n\t\t\t\t\t\n\t\t\t\t\n\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t
    \n\n\t\n\t
    \n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t67.99 \t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tВ корзину\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\t
    ", 3 | "expectedParsedPrice": 68.0, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 5.0 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 340, 10 | "label": "1 л", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/OzonStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class OzonStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Ozon"; 9 | this.selectors = { 10 | card: '[class*="tile-root"]', 11 | price: '[class*="tsHeadline500Medium"]', 12 | name: '[class*="tsBody500Medium"]', 13 | unitPrice: '[data-testid="unit-price"]', 14 | }; 15 | } 16 | 17 | parsePrice(cardEl: HTMLElement): number { 18 | const priceString = cardEl.querySelector(this.selectors.price)?.textContent; 19 | this.log("parsed price text", priceString); 20 | const num = priceString?.replace(/[^\d,.]/g, "").replace(",", ".") ?? ""; 21 | const v = parseFloat(num); 22 | if (isNaN(v)) throw new Error("cannot parse price: " + priceString); 23 | return v; 24 | } 25 | 26 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 27 | const nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() ?? ""; 28 | const regex = /([\d.,]+)\s*(г|гр|кг|мл|л|шт)/i; 29 | const match = nameText.match(regex); 30 | 31 | if (!match) return { unitLabel: null, multiplier: null } as NoneUnitLabel; 32 | 33 | const value = parseFloat(match[1].replace(",", ".")); 34 | const unit = match[2].toLowerCase(); 35 | return getConvertedUnit(value, unit); 36 | } 37 | 38 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 39 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 40 | 41 | if (!wrapper) { 42 | throw new Error("wrapper for price not found"); 43 | } 44 | 45 | const fz = "calc(0.95vw)"; 46 | wrapper.style.fontSize = fz; 47 | // @ts-expect-error 48 | wrapper.parentElement.style.fontSize = fz; 49 | 50 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el: Element) => el.remove()); 51 | 52 | const span = document.createElement("span"); 53 | span.setAttribute("data-testid", "unit-price"); 54 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 55 | Object.assign(span.style, { 56 | display: "inline-block", 57 | marginLeft: "0.5em", 58 | color: "#000", 59 | background: "var(--accent-color, #00C66A20)", 60 | padding: "2px 6px", 61 | borderRadius: "4px", 62 | fontWeight: "900", 63 | fontSize: fz, 64 | }); 65 | wrapper.appendChild(span); 66 | } 67 | 68 | renderNoneUnitPrice(cardEl: HTMLElement): void { 69 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 70 | 71 | if (!wrapper) { 72 | throw new Error("wrapper for price not found"); 73 | } 74 | 75 | const fz = "calc(0.95vw)"; 76 | wrapper.style.fontSize = fz; 77 | // @ts-expect-error 78 | wrapper.parentElement.style.fontSize = fz; 79 | 80 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el: Element) => el.remove()); 81 | 82 | const span = document.createElement("span"); 83 | span.setAttribute("data-testid", "unit-price"); 84 | span.textContent = "Нет инф."; 85 | Object.assign(span.style, { 86 | display: "inline-block", 87 | marginLeft: "0.5em", 88 | color: "#000", 89 | background: "var(--accent-color,rgba(0, 69, 198, 0.13))", 90 | padding: "2px 6px", 91 | borderRadius: "4px", 92 | fontWeight: "900", 93 | fontSize: fz, 94 | }); 95 | wrapper.appendChild(span); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/core/BaseParser.ts: -------------------------------------------------------------------------------- 1 | import { type IStrategy } from "@/types/IStrategy"; 2 | 3 | export class BaseParser { 4 | private strategy: IStrategy; 5 | private cards: HTMLElement[]; 6 | private scrollTimeoutId: number | null; 7 | private mutationObserver: MutationObserver | null; 8 | private intervalId: number | null; 9 | 10 | constructor(strategy: IStrategy) { 11 | this.strategy = strategy; 12 | this.cards = []; 13 | this.scrollTimeoutId = null; 14 | this.mutationObserver = null; 15 | this.intervalId = null; 16 | this.onScroll = this.onScroll.bind(this); 17 | this.onMutation = this.onMutation.bind(this); 18 | } 19 | 20 | init(): void { 21 | this.strategy.log("=== BaseParser.init ==="); 22 | if (document.readyState === "loading") { 23 | document.addEventListener("DOMContentLoaded", () => this.start()); 24 | } else { 25 | this.start(); 26 | } 27 | } 28 | 29 | start(): void { 30 | this.runAll("init"); 31 | this.setupMutationObserver(); 32 | this.setupScrollListener(); 33 | this.setupInterval(5000); 34 | } 35 | 36 | private _selectCards(): void { 37 | const selector = this.strategy.getCardSelector(); 38 | this.cards = Array.from(document.querySelectorAll(selector)); 39 | this.strategy.log(`found ${this.cards.length} cards`); 40 | } 41 | 42 | runAll(source: string): void { 43 | this._selectCards(); 44 | if (!this.cards.length) return; 45 | this.cards.filter((el) => this.strategy.shouldProcess(el)).forEach((el) => this.tryProcess(el, source)); 46 | } 47 | 48 | tryProcess(el: HTMLElement, source: string): void { 49 | this.strategy.log(`tryProcess [${source}]`); 50 | try { 51 | this.strategy.process(el); 52 | this.strategy.log(`✓ [${source}] success`); 53 | } catch (err) { 54 | this.strategy.log(`✗ [${source}] error:`, err instanceof Error ? err.message : String(err)); 55 | } 56 | } 57 | 58 | setupMutationObserver(): void { 59 | this.mutationObserver = new MutationObserver(this.onMutation); 60 | this.mutationObserver.observe(document.body, { 61 | childList: true, 62 | subtree: true, 63 | }); 64 | this.strategy.log("MutationObserver started"); 65 | } 66 | 67 | private onMutation(mutations: MutationRecord[]): void { 68 | mutations.forEach((m) => { 69 | m.addedNodes.forEach((node) => { 70 | if (!(node instanceof HTMLElement)) return; 71 | if (node.matches(this.strategy.getCardSelector())) { 72 | this.tryProcess(node, "MO"); 73 | } 74 | node 75 | .querySelectorAll(this.strategy.getCardSelector()) 76 | .forEach((el) => this.tryProcess(el as HTMLElement, "MO")); 77 | }); 78 | }); 79 | } 80 | 81 | setupScrollListener(): void { 82 | window.addEventListener("scroll", this.onScroll, { passive: true }); 83 | this.strategy.log("Scroll listener started"); 84 | } 85 | 86 | private onScroll(): void { 87 | if (this.scrollTimeoutId !== null) { 88 | clearTimeout(this.scrollTimeoutId); 89 | } 90 | this.scrollTimeoutId = window.setTimeout(() => this.runAll("scroll"), 300); 91 | } 92 | 93 | setupInterval(ms: number): void { 94 | this.intervalId = window.setInterval(() => this.runAll("interval"), ms); 95 | this.strategy.log(`Interval runAll every ${ms}ms`); 96 | } 97 | 98 | destroy(): void { 99 | if (this.mutationObserver) this.mutationObserver.disconnect(); 100 | if (this.scrollTimeoutId !== null) clearTimeout(this.scrollTimeoutId); 101 | if (this.intervalId !== null) clearInterval(this.intervalId); 102 | window.removeEventListener("scroll", this.onScroll); 103 | this.strategy.log("BaseParser destroyed"); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/strategies/MagnitStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class MagnitStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Magnit"; 9 | this.selectors = { 10 | card: '[class*="unit-catalog-product-preview"]', 11 | price: [ 12 | '[class*="unit-catalog-product-preview-prices__sale"] span', 13 | '[class*="unit-catalog-product-preview-prices__regular"] span', 14 | ].join(","), 15 | name: '[class*="unit-catalog-product-preview-title"]', 16 | unitPrice: '[data-testid="unit-price"]', 17 | }; 18 | } 19 | 20 | shouldProcess(cardEl: Element): boolean { 21 | return ( 22 | (cardEl.querySelector(this.selectors.price) && 23 | cardEl.querySelector(this.selectors.name) && 24 | !cardEl.querySelector(this.selectors.unitPrice)) || 25 | false 26 | ); 27 | } 28 | 29 | parsePrice(cardEl: HTMLElement): number { 30 | const priceString = cardEl.querySelector(this.selectors.price)?.textContent; 31 | const num = priceString?.replace(/[^\d.,]/g, "").replace(",", ".") ?? ""; 32 | const v = parseFloat(num); 33 | if (isNaN(v)) throw new Error("Cannot parse price: " + priceString); 34 | return v; 35 | } 36 | 37 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 38 | const nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() ?? ""; 39 | const s = nameText.trim().toLowerCase().replace(",", "."); 40 | const match = s.match(/([\d]+(?:\.\d+)?)\s*(г|гр|кг|мл|л|шт)\.?/i); 41 | if (!match) { 42 | return { unitLabel: "1 шт", multiplier: 1 } as UnitLabel; 43 | } 44 | const value = parseFloat(match[1]); 45 | const unit = match[2]; 46 | return getConvertedUnit(value, unit); 47 | } 48 | 49 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 50 | const priceContainer = cardEl.querySelector('[class*="unit-catalog-product-preview-prices"]'); 51 | if (!priceContainer) throw new Error("Price container not found"); 52 | 53 | priceContainer.querySelectorAll(this.selectors.unitPrice).forEach((el: Element) => el.remove()); 54 | 55 | const displayValue = unitPrice < 20 ? roundNumber(unitPrice, 2).toString() : roundNumber(unitPrice, 0).toString(); 56 | 57 | const span = document.createElement("span"); 58 | span.setAttribute("data-testid", "unit-price"); 59 | span.textContent = `${displayValue}\u2009₽ за ${unitLabel}`; 60 | Object.assign(span.style, { 61 | display: "block", 62 | color: "rgb(0,0,0)", 63 | backgroundColor: "rgb(230,245,239)", 64 | padding: "2px 6px 2px 0.5px", 65 | borderRadius: "4px", 66 | fontWeight: "600", 67 | fontSize: "14px", 68 | marginBottom: "4px", 69 | }); 70 | 71 | priceContainer.prepend(span); 72 | } 73 | 74 | renderNoneUnitPrice(cardEl: HTMLElement): void { 75 | const priceContainer = cardEl.querySelector('[class*="unit-catalog-product-preview-prices"]'); 76 | if (!priceContainer) throw new Error("Price container not found"); 77 | 78 | priceContainer.querySelectorAll(this.selectors.unitPrice).forEach((el: Element) => el.remove()); 79 | 80 | const span = document.createElement("span"); 81 | span.setAttribute("data-testid", "unit-price"); 82 | span.textContent = "Нет инф."; 83 | Object.assign(span.style, { 84 | display: "block", 85 | color: "rgb(0,0,0)", 86 | backgroundColor: "var(--accent-color,rgba(0, 69, 198, 0.13))", 87 | padding: "2px 6px 2px 0.5px", 88 | borderRadius: "4px", 89 | fontWeight: "600", 90 | fontSize: "14px", 91 | marginBottom: "4px", 92 | }); 93 | 94 | priceContainer.prepend(span); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/pyaterochka/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 140, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 шт", 6 | "multiplier": 0.1 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 14, 10 | "label": "1 шт", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/DeliveryClubStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class DeliveryClubStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "DeliveryClub"; 9 | this.selectors = { 10 | card: ['li[data-carousel-item="true"]', 'li.DesktopGoodsList_item', ".DesktopGoodsList_list li", 'div[data-testid="product-card-root"]'].join( 11 | ",", 12 | ), 13 | price: '.p1jdj7iy span', 14 | name: '.nsawvb6', 15 | unitPrice: '[data-testid="product-card-unit-price"]', 16 | volume: '.wpsxpb7' 17 | }; 18 | } 19 | 20 | parsePrice(cardEl: HTMLElement): number { 21 | const priceEl = cardEl.querySelector(this.selectors.price); 22 | const priceString = priceEl?.textContent; 23 | this.log("parsed price text", priceString); 24 | const cleaned = priceString?.replace(/\s| | /g, "").replace("₽", "") ?? ""; 25 | const v = parseFloat(cleaned); 26 | if (isNaN(v)) throw new Error("Invalid price: " + priceString); 27 | return v; 28 | } 29 | 30 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 31 | const qtyText = cardEl.querySelector(this.selectors.volume as string)?.textContent?.trim() ?? ""; 32 | const s = qtyText.toLowerCase().replace(",", ".").trim(); 33 | const m = s.match(/([\d.]+)\s*([^\s\d]+)/); 34 | if (!m) throw new Error("Invalid quantity: " + qtyText); 35 | const num = parseFloat(m[1]); 36 | const unit = m[2]; 37 | return getConvertedUnit(num, unit); 38 | } 39 | 40 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 41 | const priceEl = cardEl.querySelector(this.selectors.price); 42 | if (!priceEl) throw new Error("Price element not found"); 43 | 44 | const wrapper = priceEl.closest('div[aria-hidden="true"]'); 45 | if (!wrapper) throw new Error("Wrapper not found"); 46 | 47 | // Ищем уже существующий unitPrice-бейдж 48 | const existingUnitPrice = wrapper.querySelector('[data-testid="unit-price"]') as HTMLElement | null; 49 | 50 | const formattedText = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 51 | 52 | if (existingUnitPrice) { 53 | existingUnitPrice.textContent = formattedText; 54 | return; 55 | } 56 | 57 | const span = document.createElement("span"); 58 | span.className = priceEl.className; 59 | span.setAttribute("data-testid", "unit-price"); 60 | span.textContent = formattedText; 61 | 62 | Object.assign(span.style, { 63 | color: "#000", 64 | backgroundColor: "rgba(0, 198, 106, 0.1)", 65 | padding: "2px 6px 2px 0.5px", 66 | borderRadius: "0.25em", 67 | fontWeight: "500", 68 | }); 69 | 70 | wrapper.appendChild(span); 71 | } 72 | 73 | renderNoneUnitPrice(cardEl: HTMLElement): void { 74 | const priceEl = cardEl.querySelector(this.selectors.price); 75 | if (!priceEl) throw new Error("Price element not found"); 76 | 77 | const wrapper = priceEl.closest('div[aria-hidden="true"]'); 78 | if (!wrapper) throw new Error("Wrapper not found"); 79 | 80 | // Ищем уже существующий unitPrice-бейдж 81 | const existingUnitPrice = wrapper.querySelector('[data-testid="unit-price"]') as HTMLElement | null; 82 | 83 | if (existingUnitPrice) { 84 | existingUnitPrice.textContent = "Нет инф."; 85 | return; 86 | } 87 | 88 | const span = document.createElement("span"); 89 | span.className = priceEl.className; 90 | span.setAttribute("data-testid", "unit-price"); 91 | span.textContent = "Нет инф."; 92 | 93 | Object.assign(span.style, { 94 | color: "#000", 95 | backgroundColor: "var(--accent-color,rgba(0, 69, 198, 0.13))", 96 | padding: "2px 6px 2px 0.5px", 97 | borderRadius: "0.25em", 98 | fontWeight: "500", 99 | }); 100 | 101 | wrapper.appendChild(span); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/pyaterochka/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 150, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 1.053 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 158, 10 | "label": "1 л", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samberi/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    \n\t\t
    \n\t
    \n\t\t\n\t\t(6\t\t\t)\n\t
    \n\t\n\t\t\n\n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\n\t\t\t\t\t\t\t\n\t\t\t\t\n\t\t\n\t\t\t\t\t\n\t\t\t\n\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t
    \n\t\t\t
    \n\n\t\n\t
    \n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t199.99 \t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\tВ корзину\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\t\t\t\t\t\t\t\n\t\t\t\t\t\t\t\t\t\t\t\t\t\t\t\n\t\t\t\t\t\t
    \n\n\t\t\t\t\t
    \n\t\t\t\t\t
    \n\t\t\n\t
    ", 3 | "expectedParsedPrice": 200.0, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 10.0 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 2000.0, 10 | "label": "1 кг", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/MetroStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class MetroStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Metro"; 9 | this.selectors = { 10 | card: ".product-card__content", 11 | price: 'span.product-unit-prices__actual, div.price, [data-testid="price-value"]', 12 | name: '[class*="product-card-name__text"], a[href^="/products/"] span, [data-testid="product-name"]', 13 | unitPrice: '[data-testid="unit-price"]', 14 | priceUnit: ".product-price__unit", 15 | renderRoot: ".product-unit-prices", 16 | }; 17 | } 18 | 19 | shouldProcess(cardEl: Element): boolean { 20 | const hasPrice = cardEl.querySelector(this.selectors.price); 21 | const hasUnitPrice = cardEl.querySelector(this.selectors.unitPrice); 22 | 23 | return Boolean(hasPrice && !hasUnitPrice); 24 | } 25 | 26 | parsePrice(cardEl: HTMLElement): number { 27 | const priceEl = cardEl.querySelector(this.selectors.price); 28 | const priceString = priceEl?.textContent || ""; 29 | const num = priceString.replace(/[^\d.,]/g, "").replace(",", "."); 30 | const v = parseFloat(num); 31 | if (isNaN(v)) throw new Error("Не удалось распознать цену: " + priceString); 32 | return v; 33 | } 34 | 35 | parseQuantity(cardEl: HTMLElement): UnitLabel { 36 | const nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() || ""; 37 | 38 | const priceUnitEl = cardEl.querySelector(this.selectors.priceUnit!); 39 | const priceUnitText = priceUnitEl?.textContent?.toLowerCase() || ""; 40 | 41 | if (/(\/|)\s*кг/.test(priceUnitText)) return { unitLabel: "1 кг", multiplier: 1 } as UnitLabel; 42 | if (/(\/|)\s*л/.test(priceUnitText)) return { unitLabel: "1 л", multiplier: 1 } as UnitLabel; 43 | 44 | const regex = /([\d.,]+)\s*(г(?!од)|гр|кг|мл|л|шт)/i; 45 | const match = nameText.match(regex); 46 | 47 | if (!match) return { unitLabel: "1 шт", multiplier: 1 } as UnitLabel; 48 | const value = parseFloat(match[1].replace(",", ".")); 49 | const unit = match[2]; 50 | const result = getConvertedUnit(value, unit); 51 | if (result.unitLabel === null) { 52 | return { unitLabel: "1 шт", multiplier: 1 } as UnitLabel; 53 | } 54 | return result; 55 | } 56 | 57 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 58 | const renderRootEl = cardEl.querySelector(this.selectors.renderRoot!); 59 | if (!renderRootEl) return; 60 | 61 | renderRootEl.querySelectorAll(this.selectors.unitPrice).forEach((el) => el.remove()); 62 | 63 | const span = document.createElement("span"); 64 | span.setAttribute("data-testid", "unit-price"); 65 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 66 | 67 | Object.assign(span.style, { 68 | display: "inline-block", 69 | marginLeft: "0.5em", 70 | color: "#000", 71 | background: "var(--accent-color, #00C66A20)", 72 | padding: "2px 6px", 73 | borderRadius: "4px", 74 | fontWeight: "900", 75 | fontSize: "12px", 76 | }); 77 | 78 | renderRootEl.appendChild(span); 79 | } 80 | 81 | renderNoneUnitPrice(cardEl: HTMLElement): void { 82 | const renderRootEl = cardEl.querySelector(this.selectors.renderRoot!); 83 | if (!renderRootEl) return; 84 | 85 | renderRootEl.querySelectorAll(this.selectors.unitPrice).forEach((el) => el.remove()); 86 | 87 | const span = document.createElement("span"); 88 | span.setAttribute("data-testid", "unit-price"); 89 | span.textContent = "Нет инф."; 90 | 91 | Object.assign(span.style, { 92 | display: "inline-block", 93 | marginLeft: "0.5em", 94 | color: "#000", 95 | background: "var(--accent-color, #00C66A20)", 96 | padding: "2px 6px", 97 | borderRadius: "4px", 98 | fontWeight: "900", 99 | fontSize: "12px", 100 | }); 101 | 102 | renderRootEl.appendChild(span); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/strategies/SamokatStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class SamokatStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Samokat"; 9 | this.selectors = { 10 | card: "[class*=ProductCard_root]", 11 | price: "[class*=ProductCardActions_text] span span:last-child", 12 | name: "[class*=ProductCard_specification] span:first-child", 13 | unitPrice: '[data-testid="unit-price"]', 14 | renderRoot: "[class*=ProductCard_details]", 15 | }; 16 | } 17 | 18 | parsePrice(cardEl: HTMLElement): number { 19 | const priceString = this.selectors?.price ? cardEl.querySelector(this.selectors.price)?.textContent || "" : ""; 20 | this.log("parsed price text", priceString); 21 | const num = priceString?.replace(/[^\d,.]/g, "").replace(",", ".") ?? ""; 22 | const v = parseFloat(num); 23 | if (isNaN(v)) throw new Error("Цена не распознана: " + priceString); 24 | return v; 25 | } 26 | 27 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 28 | let nameText: string; 29 | if (this.selectors?.volume) { 30 | nameText = cardEl.querySelector(this.selectors.volume)?.textContent?.trim() ?? ""; 31 | } else { 32 | nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() ?? ""; 33 | } 34 | 35 | const s = nameText.toLowerCase().replace(/,/g, ".").trim(); 36 | const mulMatch = s.match(/^(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*([^\s\d]+)$/i); 37 | let total: number; 38 | let unit: string; 39 | if (mulMatch) { 40 | const count = parseFloat(mulMatch[1]); 41 | const per = parseFloat(mulMatch[2]); 42 | unit = mulMatch[3]; 43 | total = count * per; 44 | } else { 45 | const m = s.match(/([\d.]+)\s*([^\s\d]+)/); 46 | if (!m) return { unitLabel: "1 шт", multiplier: 1 } as UnitLabel; 47 | total = parseFloat(m[1]); 48 | unit = m[2]; 49 | } 50 | 51 | return getConvertedUnit(total, unit); 52 | } 53 | 54 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 55 | const details = cardEl.querySelector(this.selectors?.renderRoot || this.selectors.price); 56 | if (!details) throw new Error("Не найдены необходимые элементы"); 57 | 58 | const rawUnit = unitPrice; 59 | const calculatedUnitPrice = rawUnit < 20 ? roundNumber(rawUnit, 2) : roundNumber(rawUnit, 0); 60 | const displayPrice = rawUnit < 20 ? calculatedUnitPrice.toString() : calculatedUnitPrice; 61 | 62 | // Удаляем старые 63 | details.querySelectorAll(this.selectors.unitPrice).forEach((e) => e.remove()); 64 | 65 | const span = document.createElement("span"); 66 | span.setAttribute("data-testid", "unit-price"); 67 | span.textContent = `${displayPrice}\u2009₽ за ${unitLabel}`; 68 | Object.assign(span.style, { 69 | display: "inline-block", 70 | color: "#000", 71 | backgroundColor: "var(--accent-color, #00C66A20)", 72 | padding: "2px 6px", 73 | borderRadius: "4px", 74 | fontWeight: "600", 75 | fontSize: "15px", 76 | }); 77 | details.appendChild(span); 78 | } 79 | 80 | renderNoneUnitPrice(cardEl: HTMLElement): void { 81 | const details = cardEl.querySelector(this.selectors?.renderRoot || this.selectors.price); 82 | if (!details) throw new Error("Не найдены необходимые элементы"); 83 | 84 | // Удаляем старые 85 | details.querySelectorAll(this.selectors.unitPrice).forEach((e) => e.remove()); 86 | 87 | const span = document.createElement("span"); 88 | span.setAttribute("data-testid", "unit-price"); 89 | span.textContent = "Нет инф."; 90 | Object.assign(span.style, { 91 | display: "inline-block", 92 | color: "#000", 93 | backgroundColor: "var(--accent-color,rgba(0, 69, 198, 0.13))", 94 | padding: "2px 6px", 95 | borderRadius: "4px", 96 | fontWeight: "600", 97 | fontSize: "15px", 98 | }); 99 | details.appendChild(span); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/delivery-club/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
  • \"\"
    181 ₽
    Свекла, «Наша Ферма»
    1 кг
  • \"\"
    445 ₽
    Яйцо цесарки столовое, Ecodar, 6 шт., Россия
    306 г
  • ", 3 | "expectedParsedPrice": 181, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 1 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 181, 10 | "label": "1 кг", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 6px 2px 0.5px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/KuperStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class KuperStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Kuper"; 9 | this.selectors = { 10 | card: "[class*=ProductCardGridLayout]", 11 | name: "[data-qa$=_title]", 12 | volume: "[data-qa$=_volume]", 13 | price: "[class*=priceText]", 14 | unitPrice: '[data-testid="unit-price"]', 15 | }; 16 | } 17 | 18 | parsePrice(cardEl: HTMLElement): number { 19 | const priceString = cardEl.querySelector(this.selectors.price)?.textContent; 20 | const fallbackPriceRegex = /(\d+,\d+)/; 21 | const fallbackMatch = priceString?.match(fallbackPriceRegex); 22 | const fallbackTextPrice = fallbackMatch ? fallbackMatch[1].replace(",", ".") : null; 23 | const fallbackV = parseFloat(fallbackTextPrice ?? ""); 24 | if (isNaN(fallbackV)) throw new Error("Цена не распознана: " + priceString); 25 | return fallbackV; 26 | } 27 | 28 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 29 | const volumeText = this.selectors?.volume ? cardEl.querySelector(this.selectors.volume)?.textContent || "" : ""; 30 | const nameText = cardEl.querySelector(this.selectors.name)?.getAttribute("title") || ""; 31 | const volumeString = nameText || volumeText; 32 | 33 | this.log("volumeString", volumeText); 34 | this.log("nameText", nameText); 35 | 36 | const s = volumeString.trim().toLowerCase().replace(",", "."); 37 | const match = s.match(/([\d]+(?:\.\d+)?)\s*(г|гр|кг|мл|л|шт)\.?/i); 38 | 39 | if (match) { 40 | const totalText = match[1].replace(",", "."); 41 | const total = Number(totalText); 42 | if (isNaN(total)) throw new Error("Неверный формат числа: " + totalText); 43 | const unit = match[2]; 44 | 45 | this.log("Name: totalText, total, unit", totalText, total, unit); 46 | return getConvertedUnit(total, unit); 47 | } else { 48 | throw new Error("Обьем не распознан."); 49 | } 50 | } 51 | 52 | renderUnitPrice(cardEl: Element, unitPrice: number, unitLabel: string): void { 53 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 54 | if (!wrapper) throw new Error("Wrapper not found"); 55 | 56 | const fz = "calc(0.95vw)"; 57 | 58 | wrapper.style.fontSize = fz; 59 | // @ts-expect-error 60 | wrapper.parentElement.style.fontSize = fz; 61 | 62 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el: Element) => el.remove()); 63 | 64 | const span = document.createElement("span"); 65 | span.setAttribute("data-testid", "unit-price"); 66 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 67 | Object.assign(span.style, { 68 | display: "inline-block", 69 | marginLeft: "0.5em", 70 | color: "#000", 71 | background: "var(--accent-color, #00C66A20)", 72 | padding: "2px 6px", 73 | borderRadius: "4px", 74 | fontWeight: "900", 75 | fontSize: fz, 76 | }); 77 | wrapper.appendChild(span); 78 | } 79 | 80 | renderNoneUnitPrice(cardEl: Element): void { 81 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 82 | if (!wrapper) throw new Error("Wrapper not found"); 83 | 84 | const fz = "calc(0.95vw)"; 85 | 86 | wrapper.style.fontSize = fz; 87 | // @ts-expect-error 88 | wrapper.parentElement.style.fontSize = fz; 89 | 90 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el: Element) => el.remove()); 91 | 92 | const span = document.createElement("span"); 93 | span.setAttribute("data-testid", "unit-price"); 94 | span.textContent = "Нет инф."; 95 | Object.assign(span.style, { 96 | display: "inline-block", 97 | marginLeft: "0.5em", 98 | color: "#000", 99 | background: "var(--accent-color,rgba(0, 69, 198, 0.13))", 100 | padding: "2px 6px", 101 | borderRadius: "4px", 102 | fontWeight: "900", 103 | fontSize: fz, 104 | }); 105 | wrapper.appendChild(span); 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/strategies/AuchanStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | 6 | export class AuchanStrategy extends ParserStrategy { 7 | constructor() { 8 | super(); 9 | this.strategyName = "Auchan"; 10 | this.selectors = { 11 | card: '[class*="styles_productCard"][class*="styles_catalogListPage_item"],div[class*=digi-product]', 12 | price: '[class*="styles_productCardContentPanel_price"],[class*=digi-product__price]', 13 | name: '[class*="styles_productCardContentPanel_name"],[class*=digi-product__label]', 14 | unitPrice: '[data-testid="unit-price"]', 15 | volume: '[class*="productCardContentPanel_type"]', 16 | }; 17 | } 18 | 19 | parsePrice(cardEl: HTMLElement): number { 20 | const priceString = cardEl.querySelector(this.selectors.price)?.textContent; 21 | 22 | const num = priceString?.replace(/[^\d,.]/g, "").replace(",", ".") ?? ""; 23 | const v = parseFloat(num); 24 | if (isNaN(v)) throw new Error("cannot parse price: " + priceString); 25 | return v; 26 | } 27 | 28 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 29 | let nameText: string; 30 | 31 | nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() ?? ""; 32 | 33 | if (this.selectors?.volume) { 34 | const volumeText = cardEl.querySelector(this.selectors.volume)?.textContent?.trim() ?? ""; 35 | if (volumeText) { 36 | nameText = volumeText; 37 | } 38 | } 39 | 40 | if (this.selectors?.volume) { 41 | const volumeText = cardEl.querySelector(this.selectors.volume)?.textContent?.trim() ?? ""; 42 | if (volumeText) { 43 | nameText = volumeText; 44 | } 45 | } 46 | 47 | const regex = /([\d.,]+)\s*(г(?!од)|гр|кг|мл|л|шт)/i; 48 | const match = nameText.match(regex); 49 | if (!match) return { unitLabel: null, multiplier: null } as NoneUnitLabel; 50 | const value = parseFloat(match[1].replace(",", ".")); 51 | const unit = match[2].toLowerCase(); 52 | return getConvertedUnit(value, unit) as UnitLabel | NoneUnitLabel; 53 | } 54 | 55 | renderNoneUnitPrice(cardEl: HTMLElement): void { 56 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 57 | if (!wrapper) throw new Error("Wrapper not found"); 58 | 59 | const fz = "calc(0.95vw)"; 60 | 61 | wrapper.style.fontSize = fz; 62 | // @ts-expect-error 63 | wrapper.parentElement.style.fontSize = fz; 64 | 65 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el) => el.remove()); 66 | 67 | const span = document.createElement("span"); 68 | span.setAttribute("data-testid", "unit-price"); 69 | span.textContent = `Нет инф.`; 70 | Object.assign(span.style, { 71 | display: "inline-block", 72 | marginLeft: "0.5em", 73 | color: "#000", 74 | background: "var(--accent-color,rgba(0, 69, 198, 0.13))", 75 | padding: "2px 6px", 76 | borderRadius: "4px", 77 | fontWeight: "900", 78 | fontSize: fz, 79 | }); 80 | wrapper.appendChild(span); 81 | } 82 | 83 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 84 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 85 | if (!wrapper) throw new Error("Wrapper not found"); 86 | 87 | const fz = "calc(0.95vw)"; 88 | 89 | wrapper.style.fontSize = fz; 90 | // @ts-expect-error 91 | wrapper.parentElement.style.fontSize = fz; 92 | 93 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el) => el.remove()); 94 | 95 | const span = document.createElement("span"); 96 | span.setAttribute("data-testid", "unit-price"); 97 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 98 | Object.assign(span.style, { 99 | display: "inline-block", 100 | marginLeft: "0.5em", 101 | color: "#000", 102 | background: "var(--accent-color, #00C66A20)", 103 | padding: "2px 6px", 104 | borderRadius: "4px", 105 | fontWeight: "900", 106 | fontSize: fz, 107 | }); 108 | wrapper.appendChild(span); 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samokat/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    \"Молоко
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10Больше нет
    Молоко Домик в деревне, 3,2%, ультрапастеризованное
    925 мл·Теперь дешевле
    149 ₽
    ", 3 | "expectedParsedPrice": 149, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 1.081 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 161, 10 | "label": "1 л", 11 | "styles": { 12 | "display": "inline-block", 13 | "color": "rgb(0, 0, 0)", 14 | "padding": "2px 6px", 15 | "borderRadius": "4px", 16 | "fontWeight": "600", 17 | "fontSize": "15px" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samokat/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    \"Молоко
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10Больше нет
    Молоко Домик в деревне, 2,5%, ультрапастеризованное
    950 г·Теперь дешевле
    144 ₽
    ", 3 | "expectedParsedPrice": 144, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 1.053 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 152, 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "inline-block", 13 | "color": "rgb(0, 0, 0)", 14 | "padding": "2px 6px", 15 | "borderRadius": "4px", 16 | "fontWeight": "600", 17 | "fontSize": "15px" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samokat/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    −11%
    \"Сметана
    1
    2
    3
    4
    5
    6
    7
    8
    9Больше нет
    Сметана Домик в деревне, 25%
    300 г·Теперь дешевле
    145129 ₽
    ", 3 | "expectedParsedPrice": 129, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 3.333 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 430, 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "inline-block", 13 | "color": "rgb(0, 0, 0)", 14 | "padding": "2px 6px", 15 | "borderRadius": "4px", 16 | "fontWeight": "600", 17 | "fontSize": "15px" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/strategies/LavkaStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class LavkaStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Lavka"; 9 | this.selectors = { 10 | card: "[class*=p19kkpiw]", 11 | price: 12 | "[class*=phcb3a1] [class*=b15aiivf][style*='color'], [class*=t18stym3][class*=bw441np][class*=r88klks][style*='color']", 13 | name: "[class*=m12g4kzj]", 14 | unitPrice: '[data-testid="unit-price"]', 15 | }; 16 | } 17 | 18 | parsePrice(cardEl: HTMLElement): number { 19 | const priceElement = cardEl.querySelector(this.selectors.price); 20 | const priceString = priceElement?.textContent; 21 | 22 | if (!priceString) { 23 | throw new Error("Price string is empty or element not found"); 24 | } 25 | 26 | const normalizedSpaceString = priceString.replace(/\s| /g, " ").trim(); 27 | const match = normalizedSpaceString.match(/^([\d.,]+)/); 28 | 29 | if (match && match[1]) { 30 | const potentialPrice = match[1].replace(",", "."); 31 | this.log("parsed price text", potentialPrice); 32 | const v = parseFloat(potentialPrice); 33 | if (!isNaN(v)) { 34 | return v; 35 | } 36 | } 37 | 38 | throw new Error("Invalid price: " + priceString + " (normalized: " + normalizedSpaceString + ")"); 39 | } 40 | 41 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 42 | const nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() ?? ""; 43 | const s = nameText.toLowerCase().replace(",", ".").trim(); 44 | const m = s.match(/([\d.]+)\s*([^\s\d]+)/); 45 | if (!m) throw new Error("Invalid quantity: " + nameText); 46 | const num = parseFloat(m[1]); 47 | const unit = m[2]; 48 | return getConvertedUnit(num, unit); 49 | } 50 | 51 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 52 | const priceEl = cardEl.querySelector(this.selectors.price); 53 | if (!priceEl) throw new Error("Price element not found"); 54 | 55 | const wrapper = priceEl.closest('[aria-hidden="true"]'); 56 | if (!wrapper) throw new Error("Wrapper not found"); 57 | 58 | // Ищем уже существующий unitPrice-бейдж 59 | const existingUnitPrice = wrapper.querySelector('[data-testid="unit-price"]') as HTMLElement | null; 60 | 61 | const formattedText = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 62 | 63 | 64 | const styles = { 65 | color: "#000", 66 | backgroundColor: "rgba(0, 198, 106, 0.1)", 67 | padding: "2px 3px 2px 3px", 68 | borderRadius: "0.25em", 69 | fontWeight: "500", 70 | }; 71 | 72 | if (existingUnitPrice) { 73 | existingUnitPrice.textContent = formattedText; 74 | Object.assign(existingUnitPrice.style, styles); 75 | return; 76 | } 77 | 78 | const span = document.createElement("span"); 79 | span.className = priceEl.className; 80 | span.setAttribute("data-testid", "unit-price"); 81 | span.textContent = formattedText; 82 | 83 | Object.assign(span.style, styles); 84 | 85 | wrapper.appendChild(span); 86 | } 87 | 88 | renderNoneUnitPrice(cardEl: HTMLElement): void { 89 | const priceEl = cardEl.querySelector(this.selectors.price); 90 | if (!priceEl) throw new Error("Price element not found"); 91 | 92 | const wrapper = priceEl.closest('[aria-hidden="true"]'); 93 | if (!wrapper) throw new Error("Wrapper not found"); 94 | 95 | // Ищем уже существующий unitPrice-бейдж 96 | const existingUnitPrice = wrapper.querySelector('[data-testid="unit-price"]') as HTMLElement | null; 97 | 98 | if (existingUnitPrice) { 99 | existingUnitPrice.textContent = "Нет инф."; 100 | return; 101 | } 102 | 103 | const span = document.createElement("span"); 104 | span.className = priceEl.className; 105 | span.setAttribute("data-testid", "unit-price"); 106 | span.textContent = "Нет инф."; 107 | 108 | const styles = { 109 | color: "#000", 110 | backgroundColor: "rgba(0, 198, 106, 0.1)", 111 | padding: "2px 3px 2px 3px", 112 | borderRadius: "0.25em", 113 | fontWeight: "500", 114 | }; 115 | 116 | Object.assign(span.style, styles); 117 | 118 | wrapper.appendChild(span); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/lavka/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    Меда­льо­ны из филе утён­ка Озёр­ка

    Меда­льо­ны из филе утён­ка Озёр­ка 510 г

    Меда­льо­ны из филе утён­ка Озёр­ка
    425 ₽ вместо обычной цены 539 ₽539 ₽
    \"Медальоны
    −21%
    ", 3 | "expectedParsedPrice": 539, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 1.96078431372549 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 1057, 10 | "label": "1 кг", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 3px 2px 3px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/PerekrestokStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class PerekrestokStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Perekrestok"; 9 | this.selectors = { 10 | card: "[data-testid=product-card-root]", 11 | price: "[data-testid=product-card-price]", 12 | name: "[data-testid=product-card-name]", 13 | volume: "[data-testid=product-card-weight]", 14 | unitPrice: "[data-testid='unit-price']", 15 | }; 16 | } 17 | 18 | shouldProcess(cardEl: HTMLElement): boolean { 19 | return Boolean(cardEl.querySelector(this.selectors.price) && cardEl.querySelector(this.selectors.name)); 20 | } 21 | 22 | parsePrice(cardEl: HTMLElement): number { 23 | const priceString = cardEl.querySelector(this.selectors.price)?.textContent; 24 | this.log("parsed price text", priceString); 25 | const priceRegex = /(? el.remove()); 78 | 79 | const span = document.createElement("span"); 80 | span.setAttribute("data-testid", "unit-price"); 81 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 82 | Object.assign(span.style, { 83 | display: "inline-block", 84 | marginLeft: "0.5em", 85 | color: "#000", 86 | background: "var(--accent-color, #00C66A20)", 87 | padding: "2px 6px", 88 | borderRadius: "4px", 89 | fontWeight: "900", 90 | fontSize: fz, 91 | }); 92 | wrapper.append(span); 93 | } 94 | 95 | renderNoneUnitPrice(cardEl: HTMLElement): void { 96 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 97 | if (!wrapper) throw new Error("Не найден элемент для отображения цены"); 98 | 99 | const fz = "calc(0.95vw)"; 100 | 101 | wrapper.style.fontSize = fz; 102 | // @ts-expect-error 103 | wrapper.parentElement.style.fontSize = fz; 104 | 105 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el) => el.remove()); 106 | 107 | const span = document.createElement("span"); 108 | span.setAttribute("data-testid", "unit-price"); 109 | span.textContent = "Нет инф."; 110 | Object.assign(span.style, { 111 | display: "inline-block", 112 | marginLeft: "0.5em", 113 | color: "#000", 114 | background: "var(--accent-color,rgba(0, 69, 198, 0.13))", 115 | padding: "2px 6px", 116 | borderRadius: "4px", 117 | fontWeight: "900", 118 | fontSize: fz, 119 | }); 120 | wrapper.append(span); 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/pyaterochka/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 310, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 4.348 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 1348, 10 | "label": "1 кг", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/samokat/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    −11%
    \"Куриное
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10Больше нет
    Куриное яйцо Самокат, С1, коричневое, от кур свободного выгула
    10 шт.·С фермы
    185164 ₽
    ", 3 | "expectedParsedPrice": 164, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 шт", 6 | "multiplier": 0.1 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 16.4, 10 | "label": "1 шт", 11 | "styles": { 12 | "display": "inline-block", 13 | "color": "rgb(0, 0, 0)", 14 | "padding": "2px 6px", 15 | "borderRadius": "4px", 16 | "fontWeight": "600", 17 | "fontSize": "15px" 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/lavka/card3.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    Бре­цел с солё­ным мас­лом <notr>Из Лавки</notr>

    Бре­цел с солё­ным мас­лом Из Лавки 75 г

    Бре­цел с солё­ным мас­лом Из Лавки
    293 ₽ вместо обычной цены 419 ₽293 ₽419 ₽
    \"Брецел
    −30%
    ", 3 | "expectedParsedPrice": 293, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 кг", 6 | "multiplier": 13.33333333333333 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 3907, 10 | "label": "1 кг", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 3px 2px 3px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/PyaterochkaStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class PyaterochkaStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Pyaterochka"; 9 | this.selectors = { 10 | card: '[data-qa^="product-card-"], [class*="productFilterGrid_cardContainer"]', 11 | price: '[class*="priceContainer_totalContainer_"]', 12 | discountPrice: '[class*="priceContainer_discountContainer"]', 13 | name: '[class*="mainInformation_weight"]', 14 | unitPrice: '[data-testid="unit-price"]', 15 | }; 16 | } 17 | 18 | parsePrice(cardEl: HTMLElement): number { 19 | const discountPriceString = this.selectors?.discountPrice 20 | ? cardEl.querySelector(this.selectors.discountPrice)?.textContent || "" 21 | : ""; 22 | const regularPriceString = this.selectors?.price 23 | ? cardEl.querySelector(this.selectors.price)?.textContent || "" 24 | : ""; 25 | 26 | const priceString = discountPriceString || regularPriceString; 27 | 28 | this.log("parsed price text", priceString); 29 | const priceRegex = /(? el.remove()); 79 | 80 | const span = document.createElement("span"); 81 | span.setAttribute("data-testid", "unit-price"); 82 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 83 | Object.assign(span.style, { 84 | display: "inline-block", 85 | marginLeft: "0.5em", 86 | color: "#000", 87 | background: "var(--accent-color, #00C66A20)", 88 | padding: "2px 6px", 89 | borderRadius: "4px", 90 | fontWeight: "900", 91 | fontSize: fz, 92 | }); 93 | wrapper.append(span); 94 | } 95 | 96 | renderNoneUnitPrice(cardEl: HTMLElement): void { 97 | const wrapper = cardEl.querySelector(this.selectors.price)?.closest("div"); 98 | if (!wrapper) throw new Error("Не найден элемент для отображения цены"); 99 | 100 | const fz = "calc(0.95vw)"; 101 | 102 | wrapper.style.fontSize = fz; 103 | // @ts-expect-error неизвестно наличие стилей 104 | wrapper.parentElement.style.fontSize = fz; 105 | 106 | wrapper.querySelectorAll(this.selectors.unitPrice).forEach((el) => el.remove()); 107 | 108 | const span = document.createElement("span"); 109 | span.setAttribute("data-testid", "unit-price"); 110 | span.textContent = "Нет инф."; 111 | Object.assign(span.style, { 112 | display: "inline-block", 113 | marginLeft: "0.5em", 114 | color: "#000", 115 | background: "var(--accent-color,rgba(0, 69, 198, 0.13))", 116 | padding: "2px 6px", 117 | borderRadius: "4px", 118 | fontWeight: "900", 119 | fontSize: fz, 120 | }); 121 | wrapper.append(span); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/lavka/card2.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    Вода дет­ская Чер­но­го­лов­ка Бей­би

    Вода дет­ская Чер­но­го­лов­ка Бей­би 5 л

    Вода дет­ская Чер­но­го­лов­ка Бей­би
    97 ₽ вместо обычной цены 139 ₽97 ₽139 ₽
    \"Вода
    −30%
    ", 3 | "expectedParsedPrice": 97, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 0.2 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 19, 10 | "label": "1 шт", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 3px 2px 3px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/pyaterochka/card4.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "", 3 | "expectedParsedPrice": 120, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 шт", 6 | "multiplier": 0.1 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 12, 10 | "label": "1 шт", 11 | "styles": { 12 | "display": "inline-block", 13 | "marginLeft": "0.5em", 14 | "color": "rgb(0, 0, 0)", 15 | "padding": "2px 6px", 16 | "borderRadius": "4px", 17 | "fontWeight": "900", 18 | "fontSize": "calc(0.95vw)" 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/strategies/__test_data__/lavka/card1.json: -------------------------------------------------------------------------------- 1 | { 2 | "html": "
    Вода мине­раль­ная гази­ро­ван­ная <notr>Из Лавки</notr>

    Вода мине­раль­ная гази­ро­ван­ная Из Лавки 1 л

    Вода мине­раль­ная гази­ро­ван­ная Из Лавки
    48 ₽ вместо обычной цены 69 ₽48 ₽69 ₽
    \"Вода
    −30%
    ", 3 | "expectedParsedPrice": 48, 4 | "expectedParsedQuantity": { 5 | "unitLabel": "1 л", 6 | "multiplier": 1.0 7 | }, 8 | "expectedUnitPrice": { 9 | "price": 48, 10 | "label": "1 л", 11 | "styles": { 12 | "color": "rgb(0, 0, 0)", 13 | "backgroundColor": "rgba(0, 198, 106, 0.1)", 14 | "padding": "2px 3px 2px 3px", 15 | "borderRadius": "0.25em", 16 | "fontWeight": "500" 17 | } 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /.github/workflows/build-and-release.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | paths-ignore: 7 | - "**.md" 8 | - "docs/**" 9 | - "screenshots/**" 10 | 11 | permissions: 12 | contents: write 13 | actions: read 14 | 15 | jobs: 16 | build: 17 | name: Build Extensions 18 | runs-on: ubuntu-latest 19 | timeout-minutes: 15 20 | outputs: 21 | version: ${{ steps.package_version.outputs.version }} 22 | release_tag: ${{ steps.package_version.outputs.tag }} 23 | steps: 24 | - name: Checkout code 25 | uses: actions/checkout@v4 26 | with: 27 | token: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Setup Node.js 30 | uses: actions/setup-node@v4 31 | with: 32 | node-version: "18.x" 33 | cache: "npm" 34 | 35 | - name: Install dependencies 36 | run: npm ci 37 | 38 | - name: Bump version 39 | id: package_version 40 | run: | 41 | # Автоматический подъем patch версии (1.0.3 -> 1.0.4) 42 | NEW_VERSION=$(npm version patch --no-git-tag-version) 43 | NEW_VERSION=${NEW_VERSION#v} # убираем префикс 'v' 44 | 45 | echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT 46 | echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT 47 | echo "New version: $NEW_VERSION" 48 | 49 | - name: Commit version bump 50 | run: | 51 | git config --local user.email "action@github.com" 52 | git config --local user.name "GitHub Action" 53 | git add package.json package-lock.json 54 | git commit -m "🔖 Bump version to ${{ steps.package_version.outputs.version }}" 55 | git push 56 | 57 | - name: Run quality checks 58 | run: | 59 | npm run type-check 60 | npm run lint 61 | npm run test:ci 62 | 63 | - name: Build Chrome extension 64 | run: | 65 | npm run build:chrome 66 | npm run pack:chrome 67 | 68 | - name: Build Firefox extension 69 | run: | 70 | npm run build:firefox 71 | npm run pack:firefox 72 | 73 | # Отладочный шаг для просмотра содержимого директории 74 | - name: List ext-dist contents 75 | run: ls -la ext-dist 76 | 77 | - name: Upload Chrome artifact 78 | uses: actions/upload-artifact@v4 79 | with: 80 | name: chrome-extension 81 | # Используем точное имя файла из логов 82 | path: ext-dist/zakilo-chrome-*.zip 83 | if-no-files-found: error 84 | 85 | - name: Upload Firefox artifact 86 | uses: actions/upload-artifact@v4 87 | with: 88 | name: firefox-extension 89 | # Используем точное имя файла из логов 90 | path: ext-dist/zakilo-firefox-*.zip 91 | if-no-files-found: error 92 | 93 | release: 94 | name: Create Release 95 | runs-on: ubuntu-latest 96 | needs: build 97 | if: success() 98 | steps: 99 | - name: Checkout code 100 | uses: actions/checkout@v4 101 | 102 | - name: Prepare release artifacts directory 103 | run: mkdir -p ./release-artifacts 104 | 105 | - name: Download Chrome artifact 106 | uses: actions/download-artifact@v4 107 | with: 108 | name: chrome-extension 109 | path: ./release-artifacts 110 | 111 | - name: Download Firefox artifact 112 | uses: actions/download-artifact@v4 113 | with: 114 | name: firefox-extension 115 | path: ./release-artifacts 116 | 117 | - name: List release-artifacts contents 118 | run: ls -la ./release-artifacts 119 | 120 | - name: Generate release notes 121 | id: release_notes 122 | run: | 123 | cat << EOF > release_notes.md 124 | ## 🚀 zaKilo Extension v${{ needs.build.outputs.version }} 125 | 126 | ### 📦 Установка: 127 | - **Chrome**: Скачайте \`zakilo-chrome-${{ needs.build.outputs.version }}.zip\` 128 | - **Firefox**: Скачайте \`zakilo-firefox-${{ needs.build.outputs.version }}.zip\` 129 | 130 | ### 🛠️ Сборка: 131 | - Node.js версия: 18.x 132 | - Собрано: $(date -u '+%Y-%m-%d %H:%M:%S UTC') 133 | - Commit: ${{ github.sha }} 134 | EOF 135 | 136 | - name: Create Release 137 | uses: softprops/action-gh-release@v2 138 | with: 139 | tag_name: ${{ needs.build.outputs.release_tag }} 140 | name: zaKilo Extension ${{ needs.build.outputs.version }} 141 | body_path: release_notes.md 142 | draft: false 143 | prerelease: false 144 | files: | 145 | ./release-artifacts/zakilo-chrome-${{ needs.build.outputs.version }}.zip 146 | ./release-artifacts/zakilo-firefox-${{ needs.build.outputs.version }}.zip 147 | env: 148 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 149 | -------------------------------------------------------------------------------- /src/utils/converters.ts: -------------------------------------------------------------------------------- 1 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 2 | import type { Measure, Unit } from "convert-units"; 3 | import convert from "convert-units"; 4 | 5 | 6 | // Types 7 | type RussianUnit = keyof typeof rusToConvertUnit; 8 | type ConvertUnit = (typeof rusToConvertUnit)[RussianUnit]; 9 | type TargetUnit = Extract; 10 | type ConverssionsMeasures = Extract | "ea"; 11 | type MeasurableUnit = Exclude; 12 | 13 | // Error messages 14 | const ERROR_MESSAGES = { 15 | ZERO_VALUE: "Значение не может быть нулевым", 16 | NEGATIVE_VALUE: "Значение не может быть отрицательным", 17 | UNKNOWN_UNIT: (unit: string) => `Неизвестная единица: ${unit}`, 18 | UNKNOWN_MEASURE: (measure: string) => `Неизвестная категория единицы: ${measure}`, 19 | UNSUPPORTED_TARGET: (target: string) => `Неподдерживаемая целевая единица: ${target}`, 20 | } as const; 21 | 22 | // Constants 23 | const rusToConvertUnit = { 24 | г: "g", 25 | гр: "g", 26 | кг: "kg", 27 | мл: "ml", 28 | л: "l", 29 | шт: "ea", 30 | "шт.": "ea", 31 | } as const; 32 | 33 | const measureToTargetUnit: Record = { 34 | mass: "kg", 35 | volume: "l", 36 | ea: "ea", 37 | } as const; 38 | 39 | const targetUnitLabels: Record = { 40 | kg: "1 кг", 41 | l: "1 л", 42 | ea: "1 шт", 43 | } as const; 44 | 45 | /** 46 | * Rounds a number to the specified number of decimal places 47 | */ 48 | export function roundNumber(value: number, decimalPlaces: number = 2): number { 49 | const factor = Math.pow(10, decimalPlaces); 50 | const rounded = Math.round(Math.abs(value) * factor) / factor; 51 | return value < 0 ? -rounded : rounded; 52 | } 53 | 54 | /** 55 | * Converts a value from one unit to another with proper labeling 56 | * @param value - The numeric value to convert 57 | * @param unit - The source unit (in Russian) 58 | * @returns Converted unit with label and multiplier 59 | * @throws {Error} If conversion is not possible 60 | */ 61 | export function getConvertedUnit( 62 | value: number, 63 | unit: string 64 | ): UnitLabel | NoneUnitLabel { 65 | // Handle invalid values: zero or negative 66 | if (value <= 0) { 67 | return { 68 | unitLabel: null, 69 | multiplier: null, 70 | [Symbol.for("tag")]: { NoneUnitLabel: true } 71 | } as NoneUnitLabel; 72 | } 73 | 74 | // Normalize and validate unit 75 | const normalizedUnit = normalizeUnit(unit); 76 | if (!normalizedUnit) { 77 | return { 78 | unitLabel: null, 79 | multiplier: null, 80 | [Symbol.for("tag")]: { NoneUnitLabel: true } 81 | } as NoneUnitLabel; 82 | } 83 | 84 | try { 85 | // Convert supported units 86 | if (normalizedUnit === "ea") { 87 | return convertEachUnit(value); 88 | } 89 | return convertMeasurableUnit(value, normalizedUnit as MeasurableUnit); 90 | } catch { 91 | // Handle any conversion errors 92 | return { 93 | unitLabel: null, 94 | multiplier: null, 95 | [Symbol.for("tag")]: { NoneUnitLabel: true } 96 | } as NoneUnitLabel; 97 | } 98 | } 99 | 100 | /** 101 | * Normalizes Russian unit name to internal representation 102 | * @throws {Error} If unit is unknown 103 | */ 104 | function normalizeUnit(unit: string): ConvertUnit { 105 | const key = unit.trim().toLowerCase() as RussianUnit; 106 | const normalizedUnit = rusToConvertUnit[key]; 107 | 108 | if (!normalizedUnit) { 109 | throw new Error(ERROR_MESSAGES.UNKNOWN_UNIT(unit)); 110 | } 111 | 112 | return normalizedUnit; 113 | } 114 | 115 | /** 116 | * Gets target unit information for a given measure 117 | * @throws {Error} If measure is unknown 118 | */ 119 | function getTargetUnitInfo(measure: ConverssionsMeasures): { target: TargetUnit; label: string } { 120 | const target = measureToTargetUnit[measure]; 121 | 122 | if (!target) { 123 | throw new Error(ERROR_MESSAGES.UNKNOWN_MEASURE(measure)); 124 | } 125 | if (!(target in targetUnitLabels)) { 126 | throw new Error(ERROR_MESSAGES.UNSUPPORTED_TARGET(target)); 127 | } 128 | 129 | return { 130 | target: target as TargetUnit, 131 | label: targetUnitLabels[target as TargetUnit], 132 | }; 133 | } 134 | 135 | /** 136 | * Converts each unit (штуки) 137 | */ 138 | function convertEachUnit(value: number): UnitLabel { 139 | return { 140 | unitLabel: targetUnitLabels.ea, 141 | multiplier: 1 / value, 142 | [Symbol.for("tag")]: { UnitLabel: true } 143 | } as UnitLabel; 144 | } 145 | 146 | /** 147 | * Converts measurable units (mass, volume) 148 | */ 149 | function convertMeasurableUnit(value: number, unit: MeasurableUnit): UnitLabel { 150 | const desc = convert().describe(unit as Unit) as { measure: ConverssionsMeasures }; 151 | const { target, label } = getTargetUnitInfo(desc.measure); 152 | 153 | const convertedValue = convert(value) 154 | .from(unit as Unit) 155 | .to(target as Unit); 156 | 157 | return { 158 | unitLabel: label, 159 | multiplier: 1 / convertedValue, 160 | [Symbol.for("tag")]: { UnitLabel: true } 161 | } as UnitLabel; 162 | } -------------------------------------------------------------------------------- /src/strategies/SamberiStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class SamberiStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Samberi"; 9 | this.selectors = { 10 | card: "[class*=product-item-container]", 11 | price: "[class*=product-item-price-current]", 12 | name: "[class*=product-item-title] a", 13 | unitPrice: '[data-testid="unit-price"]', 14 | }; 15 | } 16 | 17 | parsePrice(cardEl: HTMLElement): number { 18 | const priceString = cardEl.querySelector(this.selectors.price)?.textContent; 19 | this.log("parsed price text", priceString); 20 | const cleaned = priceString?.replace(/\s| /g, "").replace("₽", "") ?? ""; 21 | const v = parseFloat(cleaned); 22 | if (isNaN(v)) throw new Error("Invalid price: " + priceString); 23 | return v; 24 | } 25 | 26 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 27 | const nameText = cardEl.querySelector(this.selectors.name)?.textContent?.trim() ?? ""; 28 | this.log("parsed name text", nameText); 29 | 30 | // Ищем все возможные форматы единиц измерения 31 | const patterns = [ 32 | // Формат "число + единица без пробела" (например, "100г", "1л") 33 | /(\d+(?:[.,]\d+)?)([а-яёa-z]+)(?:\s|$)/i, 34 | // Формат "число + пробел + единица" (например, "200 мл") 35 | /(\d+(?:[.,]\d+)?)\s+([а-яёa-z]+)(?:\s|$)/i, 36 | ]; 37 | 38 | // Ищем все совпадения во всех форматах 39 | let matches: RegExpMatchArray | null = null; 40 | let lastIndex = -1; 41 | 42 | for (const pattern of patterns) { 43 | const allMatches = nameText.matchAll(new RegExp(pattern, "gi")); 44 | for (const match of allMatches) { 45 | if (match.index! > lastIndex) { 46 | matches = match; 47 | lastIndex = match.index!; 48 | } 49 | } 50 | } 51 | 52 | if (!matches) throw new Error("Invalid quantity: " + nameText); 53 | 54 | const num = parseFloat(matches[1].replace(",", ".")); 55 | // Для процентов используем "г" как единицу измерения 56 | const unit = matches[2]?.toLowerCase() || "г"; 57 | 58 | // Если это процент, конвертируем в граммы (предполагаем, что это процент от 100г) 59 | if (matches[0].includes("%")) { 60 | return getConvertedUnit(num, "г"); 61 | } 62 | 63 | return getConvertedUnit(num, unit); 64 | } 65 | 66 | renderUnitPrice(cardEl: HTMLElement, unitPrice: number, unitLabel: string): void { 67 | const priceEl = cardEl.querySelector(this.selectors.price); 68 | if (!priceEl) throw new Error("Price element not found"); 69 | 70 | const wrapper = priceEl.closest("[class*=product-item-info-container]"); 71 | if (!wrapper) throw new Error("Wrapper not found"); 72 | 73 | // Ищем уже существующий unitPrice-бейдж 74 | const existingUnitPrice = wrapper.querySelector('[data-testid="unit-price"]') as HTMLElement | null; 75 | 76 | const formattedText = `${roundNumber(unitPrice, 0)}\u2009₽ за ${unitLabel}`; 77 | 78 | if (existingUnitPrice) { 79 | existingUnitPrice.textContent = formattedText; 80 | return; 81 | } 82 | 83 | const span = document.createElement("span"); 84 | span.className = "product-item-price-current"; 85 | span.setAttribute("data-testid", "unit-price"); 86 | span.textContent = formattedText; 87 | 88 | Object.assign(span.style, { 89 | color: "#000", 90 | backgroundColor: "rgba(0, 198, 106, 0.1)", 91 | padding: "2px 6px 2px 0.5px", 92 | borderRadius: "0.25em", 93 | fontWeight: "500", 94 | display: "block", 95 | marginTop: "4px", 96 | fontSize: "0.9em", 97 | }); 98 | 99 | wrapper.appendChild(span); 100 | } 101 | 102 | renderNoneUnitPrice(cardEl: HTMLElement): void { 103 | const priceEl = cardEl.querySelector(this.selectors.price); 104 | if (!priceEl) throw new Error("Price element not found"); 105 | 106 | const wrapper = priceEl.closest("[class*=product-item-info-container]"); 107 | if (!wrapper) throw new Error("Wrapper not found"); 108 | 109 | // Ищем уже существующий unitPrice-бейдж 110 | const existingUnitPrice = wrapper.querySelector('[data-testid="unit-price"]') as HTMLElement | null; 111 | 112 | if (existingUnitPrice) { 113 | existingUnitPrice.textContent = "Нет инф."; 114 | return; 115 | } 116 | 117 | const span = document.createElement("span"); 118 | span.className = "product-item-price-current"; 119 | span.setAttribute("data-testid", "unit-price"); 120 | span.textContent = "Нет инф."; 121 | 122 | Object.assign(span.style, { 123 | color: "#000", 124 | backgroundColor: "var(--accent-color,rgba(0, 69, 198, 0.13))", 125 | padding: "2px 6px 2px 0.5px", 126 | borderRadius: "0.25em", 127 | fontWeight: "500", 128 | display: "block", 129 | marginTop: "4px", 130 | fontSize: "0.9em", 131 | }); 132 | 133 | wrapper.appendChild(span); 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /info.md: -------------------------------------------------------------------------------- 1 | # Сайты и структура карточек 2 | 3 | ## Пятерочка 4 | 5 | **Карточка товара в поиске** 6 | ```html 7 |
    8 | ``` 9 | 10 | **Цена** 11 | ```html 12 |
    13 |
    14 |

    102

    99

    15 |
    16 |
    17 | ``` 18 | 19 | **Цена с акцией** 20 | ```html 21 |
    22 |
    23 |

    172

    99

    24 |
    25 |
    26 |

    134

    99

    27 |
    28 |
    29 | ``` 30 | 31 | **Вес** 32 | ```html 33 |

    970 мл

    34 | ``` 35 | 36 | **Итоговый вид цены** 37 | ```html 38 |
    39 |

    89

    99

    / 40 |

    96

    78

    ₽ за 1 л

    41 |
    42 | ``` 43 | 44 | **Элементы для обновления** 45 | ```html 46 |
    47 | ``` 48 | 49 | --- 50 | 51 | ## Delivery Club 52 | 53 | **Карточка товара в поиске** 54 | ```html 55 |
  • 56 | ``` 57 | 58 | **Цена** 59 | ```html 60 |
    170₽
    61 | ``` 62 | 63 | **Цена с акцией** 64 | ```html 65 |
    25₽30₽
    66 | ``` 67 | 68 | **Вес** 69 | ```html 70 |
    140 г
    71 | ``` 72 | 73 | **Итоговый вид цены** 74 | ```html 75 |
    76 | 38₽56₽ 77 | 54,28₽ за 1 кг 78 |
    79 | ``` 80 | 81 | **Элементы для обновления** 82 | ```html 83 |
    84 | ``` 85 | 86 | --- 87 | 88 | ## Перекресток 89 | 90 | **Карточка товара в поиске** 91 | ```html 92 |
    93 | ``` 94 | 95 | **Цена** 96 | ```html 97 |
    91,99₽
    98 | ``` 99 | 100 | **Цена с акцией** 101 | ```html 102 |
    59,99₽
    69,99₽
    103 | ``` 104 | 105 | **Вес** 106 | ```html 107 |
    400 мл
    108 | ``` 109 | 110 | **Элементы для обновления** 111 | ```html 112 |
    113 | ``` 114 | 115 | --- 116 | 117 | ## Ашан 118 | 119 | **Карточка товара в поиске** 120 | ```html 121 |
    122 | ``` 123 | 124 | **Цена** 125 | ```html 126 |
    56,99₽
    127 | ``` 128 | 129 | **Цена с акцией** 130 | ```html 131 |
    144,90₽
    176,99₽
    132 | ``` 133 | 134 | **Вес** (берётся из заголовка) 135 | ```html 136 |

    Молоко «Княгинино» 2,5% БЗМЖ, 930 г

    137 | ``` 138 | 139 | **Элементы для обновления** 140 | ```html 141 |
  • 200 | ``` 201 | 202 | --- 203 | 204 | ## Магнит 205 | 206 | **Карточка товара в поиске** 207 | ```html 208 |
    209 | ``` 210 | 211 | **Цена** 212 | ```html 213 |
    139.99₽
    214 | ``` 215 | 216 | **Цена с акцией** 217 | ```html 218 |
    53.99₽
    219 | ``` 220 | 221 | **Вес** 222 | ```html 223 |
    Молоко Домик в деревне 950г
    224 | ``` 225 | 226 | **Элементы для обновления** 227 | ```html 228 |
    229 | ``` 230 | 231 | -------------------------------------------------------------------------------- /src/strategies/LentaStrategy.ts: -------------------------------------------------------------------------------- 1 | import { ParserStrategy } from "@/core/ParserStrategy"; 2 | import type { NoneUnitLabel, UnitLabel } from "@/types/IStrategy"; 3 | import { getConvertedUnit, roundNumber } from "@/utils/converters"; 4 | 5 | export class LentaStrategy extends ParserStrategy { 6 | constructor() { 7 | super(); 8 | this.strategyName = "Lenta"; 9 | this.selectors = { 10 | card: ".product-card", 11 | name: "[automation-id='catProductName']", 12 | volume: ".product-position-price .price, .card-name_package", 13 | price: ".main-price", 14 | unitPrice: '[data-testid="unit-price"]', 15 | }; 16 | } 17 | 18 | parsePrice(cardEl: HTMLElement): number { 19 | const priceElement = cardEl.querySelector(this.selectors.price); 20 | if (!priceElement) throw new Error("Элемент цены не найден"); 21 | 22 | const priceText = priceElement.textContent?.trim() || ""; 23 | const priceMatch = priceText.match(/(\d+,\d+)/); 24 | 25 | if (!priceMatch) throw new Error("Цена не распознана: " + priceText); 26 | 27 | const price = parseFloat(priceMatch[1].replace(",", ".")); 28 | if (isNaN(price)) throw new Error("Неверный формат цены: " + priceText); 29 | 30 | return price; 31 | } 32 | 33 | parseQuantity(cardEl: HTMLElement): UnitLabel | NoneUnitLabel { 34 | const existing = cardEl.querySelector('[data-testid="unit-price"]'); 35 | if (existing) { 36 | const txt = existing.textContent?.trim() || ""; 37 | const m = txt.match(/(\d+)\s*₽\/(г|гр|кг|мл|л|шт)/i); 38 | if (m) { 39 | return getConvertedUnit(parseInt(m[1], 10), m[2].toLowerCase()); 40 | } 41 | } 42 | 43 | const pkgEl = cardEl.querySelector(".card-name_package"); 44 | if (pkgEl) { 45 | const pkgText = pkgEl.textContent?.trim() || ""; 46 | const mPkg = pkgText.match(/(\d+)(г|гр|кг|мл|л|шт)/i); 47 | if (mPkg) { 48 | return getConvertedUnit(parseInt(mPkg[1], 10), mPkg[2].toLowerCase()); 49 | } 50 | } 51 | 52 | const nameEl = cardEl.querySelector("[automation-id='catProductName']") as HTMLElement; 53 | if (nameEl) { 54 | const title = nameEl.getAttribute("title") || ""; 55 | const mTitle = title.match(/(\d+)(г|гр|кг|мл|л|шт)/i); 56 | if (mTitle) { 57 | return getConvertedUnit(parseInt(mTitle[1], 10), mTitle[2].toLowerCase()); 58 | } 59 | } 60 | 61 | const labelEl = cardEl.querySelector(".product-position-price .price"); 62 | if (labelEl) { 63 | const labelTxt = labelEl.textContent?.trim() || ""; 64 | const mLabel = labelTxt.match(/Цена за\s*(\d+)\s*(г|гр|кг|мл|л|шт)/i); 65 | if (mLabel) { 66 | return getConvertedUnit(parseInt(mLabel[1], 10), mLabel[2].toLowerCase()); 67 | } 68 | } 69 | return { 70 | unitLabel: null, 71 | multiplier: null, 72 | } as NoneUnitLabel; 73 | } 74 | 75 | renderUnitPrice(cardEl: Element, unitPrice: number, unitLabel: string): void { 76 | const priceWrapper = cardEl.querySelector(".price-and-buttons") as HTMLElement; 77 | if (!priceWrapper) { 78 | throw new Error("Wrapper цены не найден"); 79 | } 80 | 81 | priceWrapper.querySelectorAll('[data-testid="unit-price"]').forEach((el) => el.remove()); 82 | const span = document.createElement("span"); 83 | span.setAttribute("data-testid", "unit-price"); 84 | span.textContent = `${roundNumber(unitPrice, 0)}\u2009₽/${unitLabel}`; 85 | 86 | // 4. Добавить стили 87 | const fz = "calc(0.7vw)"; 88 | Object.assign(span.style, { 89 | display: "inline-block", 90 | marginLeft: "0.4em", 91 | marginRight: "0.4em", 92 | color: "rgb(0, 0, 0)", 93 | padding: "2px", 94 | borderRadius: "4px", 95 | backgroundColor: "rgb(230, 245, 239)", 96 | fontWeight: "900", 97 | fontSize: fz, 98 | }); 99 | 100 | // 5. Найти блок с основной ценой 101 | const priceBlock = priceWrapper.querySelector(".product-price"); 102 | if (!priceBlock) { 103 | throw new Error("Блок цены не найден"); 104 | } 105 | 106 | // 6. Вставить span сразу после .product-price 107 | priceBlock.after(span); 108 | } 109 | renderNoneUnitPrice(cardEl: Element): void { 110 | const priceWrapper = cardEl.querySelector(".price-and-buttons") as HTMLElement; 111 | if (!priceWrapper) { 112 | throw new Error("Wrapper цены не найден"); 113 | } 114 | 115 | priceWrapper.querySelectorAll('[data-testid="unit-price"]').forEach((el) => el.remove()); 116 | 117 | const span = document.createElement("span"); 118 | span.setAttribute("data-testid", "unit-price"); 119 | span.textContent = "Нет инф."; 120 | 121 | // Добавить стили 122 | const fz = "calc(0.7vw)"; 123 | Object.assign(span.style, { 124 | display: "inline-block", 125 | marginLeft: "0.4em", 126 | marginRight: "0.4em", 127 | color: "rgb(0, 0, 0)", 128 | padding: "2px", 129 | borderRadius: "4px", 130 | backgroundColor: "var(--accent-color,rgba(0, 69, 198, 0.13))", 131 | fontWeight: "900", 132 | fontSize: fz, 133 | }); 134 | 135 | // Найти блок с основной ценой 136 | const priceBlock = priceWrapper.querySelector(".product-price"); 137 | if (!priceBlock) { 138 | throw new Error("Блок цены не найден"); 139 | } 140 | 141 | // Вставить span сразу после .product-price 142 | priceBlock.after(span); 143 | } 144 | } 145 | --------------------------------------------------------------------------------