The pure-JS @zxing/library locator is weak on real ЧЗ codes (decoded 1/4 of the test photos with correct grayscale input; libdmtx and zxing-wasm read 3/4 — the 4th is physically undecodable). Symptom: scanner takes very long to even find a code, 2 of 4 never read. - Bundle zxing-wasm reader as an IIFE (frontend/lib/zxing-wasm.js, global ZXingWASM) + zxing_reader.wasm; drop frontend/lib/zxing.js. Init fetches the .wasm as an arrayBuffer and hands it over as wasmBinary (no streaming/MIME/CDN). - ChZ.zxReady()/ChZ.decodeImageData() (chzn.js): decode an ImageData -> raw GS1 bytes (result.bytes keeps the GS separators) with tryHarder/tryRotate/tryInvert. - Scanner (main.js) rewritten to a canvas loop: crop the central ROI, upscale to 540px, decode each frame — full-frame search was the slow/unreliable part. Photo path decodes the picked image the same way. Not yet verified on-device (WebView WASM instantiation); decoder logic validated in a Node harness against the 4 sample photos.
309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
// ---------------------------------------------------------------------------
|
|
// Честный знак (CRPT) integration.
|
|
//
|
|
// Scan a product's ЧЗ DataMatrix -> resolve via mobile.api.crpt.ru -> pull КБЖУ
|
|
// from the National Catalog card -> prefill the "new product" form.
|
|
//
|
|
// Auth: the CRPT anonymous-session endpoint (/authorize/anonymous) is gated by
|
|
// Google Play Integrity (bound to the ЧЗ app package) so BalDav cannot mint its
|
|
// own session. Instead a session is exported from the ЧЗ app on a rooted device
|
|
// with tools/chzn_export.sh and imported here as a JSON file. The session's sid
|
|
// (48h) is kept alive indefinitely via /authorize/refresh (NOT integrity-gated).
|
|
//
|
|
// All requests go through the Rust `chzn_http` command (returns {status,body})
|
|
// so there is no CORS and we can see 204/401/427 status codes.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
let ChZ = (function () {
|
|
let LS_KEY = "chzn_session";
|
|
let DEFAULT_BASE = "https://mobile.api.crpt.ru";
|
|
let DEFAULT_EXPIRES = 172800000; // 48h fallback
|
|
|
|
function invoke(url, method, body) {
|
|
return window.__TAURI__.core.invoke("chzn_http", { url, method, body: body || null });
|
|
}
|
|
|
|
// Returns {status:int, body:string} or throws.
|
|
async function raw(url, method, body) {
|
|
let envelope = await invoke(url, method, body);
|
|
let parsed = JSON.parse(envelope);
|
|
return parsed;
|
|
}
|
|
|
|
function getSession() {
|
|
try { return JSON.parse(localStorage.getItem(LS_KEY)) || null; } catch { return null; }
|
|
}
|
|
function setSession(s) {
|
|
localStorage.setItem(LS_KEY, JSON.stringify(s));
|
|
}
|
|
function hasSession() {
|
|
let s = getSession();
|
|
return !!(s && s.userId && s.sid && s.refreshToken && s.instanceId && s.deviceId);
|
|
}
|
|
function baseUrl() {
|
|
let s = getSession();
|
|
return (s && s.baseUrl) || DEFAULT_BASE;
|
|
}
|
|
|
|
// Import an exported chzn_session.json (text). Returns the stored session.
|
|
function importFromText(text) {
|
|
let d = JSON.parse(text);
|
|
let need = ["userId", "sid", "refreshToken", "instanceId", "deviceId"];
|
|
for (let k of need) {
|
|
if (d[k] === undefined || d[k] === null || d[k] === "") {
|
|
throw new Error("в файле нет поля " + k);
|
|
}
|
|
}
|
|
let s = {
|
|
source: "chestny_znak",
|
|
baseUrl: d.baseUrl || DEFAULT_BASE,
|
|
userId: d.userId,
|
|
sid: d.sid,
|
|
refreshToken: d.refreshToken,
|
|
instanceId: d.instanceId,
|
|
deviceId: String(d.deviceId),
|
|
sidObtainedAt: d.sidObtainedAt || Date.now(),
|
|
expiresInMs: d.expiresInMs || DEFAULT_EXPIRES,
|
|
};
|
|
setSession(s);
|
|
return s;
|
|
}
|
|
|
|
function sidExpired(s) {
|
|
let exp = s.expiresInMs || DEFAULT_EXPIRES;
|
|
let got = s.sidObtainedAt || 0;
|
|
return Date.now() >= got + exp - 120000; // refresh 2 min early
|
|
}
|
|
|
|
// POST /mobile/authorize/refresh -> new sid. Updates stored session.
|
|
async function refresh() {
|
|
let s = getSession();
|
|
if (!s) throw new Error("нет сессии Честного знака");
|
|
let body = JSON.stringify({
|
|
instanceId: s.instanceId,
|
|
refresh_token: s.refreshToken,
|
|
device_id: String(s.deviceId),
|
|
client: "MOBILE",
|
|
});
|
|
let r = await raw(baseUrl() + "/mobile/authorize/refresh", "POST", body);
|
|
if (r.status !== 200) throw new Error("refresh HTTP " + r.status);
|
|
let j = JSON.parse(r.body);
|
|
if (!j.sid) throw new Error("refresh без sid: " + r.body.slice(0, 120));
|
|
s.sid = j.sid;
|
|
if (j.refreshToken) s.refreshToken = j.refreshToken;
|
|
s.expiresInMs = j.expiresIn || s.expiresInMs || DEFAULT_EXPIRES;
|
|
s.sidObtainedAt = Date.now();
|
|
setSession(s);
|
|
return s;
|
|
}
|
|
|
|
// Ensure a fresh sid before an authed call.
|
|
async function ensureSid() {
|
|
let s = getSession();
|
|
if (!s) throw new Error("нет сессии Честного знака — импортируйте chzn_session.json");
|
|
if (sidExpired(s)) return await refresh();
|
|
return s;
|
|
}
|
|
|
|
function looksLikeAuthError(r) {
|
|
if (r.status === 401 || r.status === 403) return true;
|
|
if (r.status === 400 && /"formErrors"[\s\S]*"(userId|sid)"/.test(r.body || "")) return true;
|
|
return false;
|
|
}
|
|
|
|
// Authenticated POST with userId/sid in the query string; refreshes once on auth error.
|
|
async function authedPost(path, body) {
|
|
let s = await ensureSid();
|
|
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
|
|
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
|
|
let r = await raw(mk(s), "POST", body);
|
|
if (looksLikeAuthError(r)) {
|
|
s = await refresh();
|
|
r = await raw(mk(s), "POST", body);
|
|
}
|
|
return r;
|
|
}
|
|
async function authedGet(path) {
|
|
let s = await ensureSid();
|
|
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
|
|
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
|
|
let r = await raw(mk(s), "GET");
|
|
if (looksLikeAuthError(r)) {
|
|
s = await refresh();
|
|
r = await raw(mk(s), "GET");
|
|
}
|
|
return r;
|
|
}
|
|
|
|
// POST /mobile/check with a GS1 code string. Returns parsed check JSON.
|
|
async function check(code) {
|
|
let r = await authedPost("/mobile/check", JSON.stringify({ code }));
|
|
if (r.status !== 200) throw new Error("check HTTP " + r.status + ": " + (r.body || "").slice(0, 160));
|
|
return JSON.parse(r.body);
|
|
}
|
|
|
|
// Optional bonus: GET /mobile/nutrition/info -> vitamins/minerals. Often 204.
|
|
async function nutritionInfo(checkId, gtin) {
|
|
try {
|
|
let r = await authedGet("/mobile/nutrition/info?checkId=" + encodeURIComponent(checkId) +
|
|
">in=" + encodeURIComponent(gtin));
|
|
if (r.status !== 200 || !r.body) return null;
|
|
return JSON.parse(r.body);
|
|
} catch { return null; }
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// GS1 code normalisation.
|
|
// A GS1 DataMatrix is 01<gtin14>21<serial><GS>93<crypto>. The GS (0x1D)
|
|
// separator between the variable-length AI 21 and AI 93 is REQUIRED for
|
|
// /mobile/check to resolve. Different decoders present it differently, so:
|
|
// - strip a leading symbology id (]d2, ]C1, {FNC1}) and leading GS chars;
|
|
// - if the string already contains a GS, keep it;
|
|
// - otherwise insert a GS before the last "93".
|
|
// ------------------------------------------------------------------
|
|
let GS = String.fromCharCode(0x1d);
|
|
function normalizeCode(rawStr) {
|
|
let s = String(rawStr || "");
|
|
s = s.replace(/^\]d2/, "").replace(/^\]C1/, "").replace(/^\]Q3/, "");
|
|
s = s.replace(/^\{FNC1\}/i, "").replace(/\{GS\}/gi, GS);
|
|
s = s.replace(new RegExp("^" + GS + "+"), "");
|
|
if (s.indexOf(GS) === -1) {
|
|
let m = s.match(/^01(\d{14})21(.+)$/);
|
|
if (m) {
|
|
let tail = m[2];
|
|
let idx = tail.lastIndexOf("93");
|
|
if (idx > 0) {
|
|
s = "01" + m[1] + "21" + tail.slice(0, idx) + GS + tail.slice(idx);
|
|
}
|
|
}
|
|
}
|
|
return s;
|
|
}
|
|
function extractGtin(code) {
|
|
let m = String(code || "").match(/^01(\d{14})/);
|
|
return m ? m[1] : null;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// Parse the National Catalog card (check.catalogData[0].good_attrs) into a
|
|
// product prefill object. КБЖУ live in the group "Энергетическая ценность,
|
|
// белки, жиры и углеводы" but attr names vary by producer
|
|
// ("Белки" vs "Белки, количество", etc.), so match by prefix within the group.
|
|
// ------------------------------------------------------------------
|
|
function num(v) {
|
|
if (v === undefined || v === null) return null;
|
|
let n = parseFloat(String(v).replace(",", ".").replace(/[^\d.\-]/g, ""));
|
|
return isFinite(n) ? n : null;
|
|
}
|
|
function parseCard(chk) {
|
|
let cd = (chk.catalogData || [])[0] || {};
|
|
let attrs = cd.good_attrs || [];
|
|
let inEnergy = (a) => (a.attr_group_name || "").indexOf("Энергетическая") === 0;
|
|
let find = (pred) => attrs.find(pred);
|
|
|
|
let kcalAttr = find(a => inEnergy(a) && /Энергетическая ценность/i.test(a.attr_name) &&
|
|
/ккал/i.test((a.attr_value_type || "") + " " + a.attr_name));
|
|
let proteinAttr = find(a => inEnergy(a) && /^Белки/i.test(a.attr_name));
|
|
let fatAttr = find(a => inEnergy(a) && /^Жиры/i.test(a.attr_name));
|
|
let carbsAttr = find(a => inEnergy(a) && /^Углеводы/i.test(a.attr_name));
|
|
let compAttr = find(a => /^Состав$/i.test(a.attr_name) || (a.attr_group_name === "Состав"));
|
|
let weightAttr = find(a => /вес нетто|Заявленный объ[её]м/i.test(a.attr_name));
|
|
|
|
let name = cd.good_name || chk.productName || "";
|
|
let prefill = {
|
|
name: name,
|
|
kcal_per_100g: kcalAttr ? num(kcalAttr.attr_value) : null,
|
|
protein_per_100g: proteinAttr ? num(proteinAttr.attr_value) : null,
|
|
fat_per_100g: fatAttr ? num(fatAttr.attr_value) : null,
|
|
carbs_per_100g: carbsAttr ? num(carbsAttr.attr_value) : null,
|
|
composition: compAttr ? String(compAttr.attr_value).trim() : null,
|
|
net_weight_g: weightAttr ? num(weightAttr.attr_value) : null,
|
|
gtin: extractGtin(chk.code) || (chk.codeResolveData || {}).gtin || null,
|
|
source: "chzn",
|
|
micros: {},
|
|
};
|
|
return prefill;
|
|
}
|
|
|
|
// Map ЧЗ vitamin/mineral names to product table columns (unit matches column).
|
|
let MICRO_MAP = {
|
|
"Кальций": "calcium_mg", "Фосфор": "phosphorus_mg", "Железо": "iron_mg",
|
|
"Магний": "magnesium_mg", "Калий": "potassium_mg", "Натрий": "sodium_mg",
|
|
"Цинк": "zinc_mg", "Медь": "copper_mg", "Селен": "selenium_mcg",
|
|
"Витамин A": "vit_a_mcg", "Витамин C": "vit_c_mg", "Витамин D": "vit_d_mcg",
|
|
"Витамин E": "vit_e_mg", "Витамин B1": "vit_b1_mg", "Витамин B2": "vit_b2_mg",
|
|
"Витамин B6": "vit_b6_mg", "Витамин B12": "vit_b12_mcg", "Фолиевая кислота": "folate_mcg",
|
|
};
|
|
function mergeMicros(prefill, nut) {
|
|
if (!nut || !Array.isArray(nut.nutrition)) return prefill;
|
|
for (let blk of nut.nutrition) {
|
|
for (let c of (blk.components || [])) {
|
|
if (c.key !== "vitaminsAndMinerals") continue;
|
|
for (let d of (c.details || [])) {
|
|
for (let row of (d.rows || [])) {
|
|
let col = MICRO_MAP[row.name];
|
|
let val = row.calculationValue;
|
|
if (col && val != null) prefill.micros[col] = val;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return prefill;
|
|
}
|
|
|
|
// Full resolve: GS1 code (raw from scanner) -> prefill object.
|
|
async function resolve(rawCode) {
|
|
let code = normalizeCode(rawCode);
|
|
let chk = await check(code);
|
|
if (chk.codeFounded === false) {
|
|
let e = new Error("код не найден в Честном знаке");
|
|
e.notFound = true;
|
|
throw e;
|
|
}
|
|
let prefill = parseCard(chk);
|
|
let cid = chk.id;
|
|
let gtin = prefill.gtin;
|
|
if (cid && gtin) {
|
|
let nut = await nutritionInfo(cid, gtin);
|
|
mergeMicros(prefill, nut);
|
|
}
|
|
return prefill;
|
|
}
|
|
|
|
// ------------------------------------------------------------------
|
|
// DataMatrix decoding via bundled zxing-wasm (ZXing-C++). Far better at
|
|
// LOCATING codes than the pure-JS port. The .wasm is fetched once as an
|
|
// arrayBuffer and handed to the module (wasmBinary) — no streaming/MIME issues.
|
|
// ------------------------------------------------------------------
|
|
let _zxReadyP = null;
|
|
function zxReady() {
|
|
if (_zxReadyP) return _zxReadyP;
|
|
_zxReadyP = (async () => {
|
|
if (typeof ZXingWASM === "undefined") throw new Error("ZXingWASM не загружен (пересобери приложение)");
|
|
let buf = await (await fetch("lib/zxing_reader.wasm")).arrayBuffer();
|
|
await ZXingWASM.prepareZXingModule({ overrides: { wasmBinary: new Uint8Array(buf) }, fireImmediately: true });
|
|
return true;
|
|
})();
|
|
return _zxReadyP;
|
|
}
|
|
|
|
// Decode one ImageData -> raw GS1 code string (with GS 0x1D), or null.
|
|
async function decodeImageData(imageData, formats) {
|
|
let res = await ZXingWASM.readBarcodes(imageData, {
|
|
formats: formats || ["DataMatrix"],
|
|
tryHarder: true, tryRotate: true, tryInvert: true, maxNumberOfSymbols: 1,
|
|
});
|
|
if (!res || !res.length || !res[0].isValid) return null;
|
|
let r = res[0];
|
|
// raw payload from bytes keeps the GS separators; fall back to text
|
|
if (r.bytes && r.bytes.length) return String.fromCharCode.apply(null, r.bytes);
|
|
return r.text || null;
|
|
}
|
|
|
|
return {
|
|
getSession, setSession, hasSession, importFromText, refresh, ensureSid,
|
|
check, nutritionInfo, normalizeCode, extractGtin, parseCard, resolve,
|
|
zxReady, decodeImageData,
|
|
_raw: raw,
|
|
};
|
|
})();
|