SPB Git

Repository Public

ingest.ts 22.8 KB 231 lines typescript

1// UQO Éval — Métré : pipeline d'ingestion des sources (discover → download → parse → normalize → classify → validate → dedupe → publish). SERVEUR seulement.2/**3 * Chaque document téléchargé est conservé tel quel (data/metre-files/_sources/<clé>/<hash>.<ext>)4 * avec URL, éditeur, date de récupération, licence, hash, version d'analyseur et5 * statut QA. Idempotence : même (source, hash) → document ignoré ; même6 * (source, identifiant externe) → observation ignorée (INSERT OR IGNORE).7 */8import { createHash, randomUUID } from "crypto";9import fs from "fs";10import path from "path";11import type Database from "better-sqlite3";12import { audit, metreFilesDir, nowIso } from "../db";13import { HttpError, type Ctx } from "../auth";14import { PERMITS_DATASET_URL, PERMITS_PARSER_VERSION, parsePermitStatsCsv, summarizePermitDetailCsv } from "./permits";15import { SCHEDULE_PARSER_VERSION, parseScheduleCsv } from "./schedule-import";16import { SEAO_DATASET_URL, SEAO_PARSER_VERSION, listWeeklyResources, parseSeaoPackage } from "./seao";1718const UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 14_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.1 Safari/605.1.15 UQO-Eval-Metre/1.0 (+https://www.uqo-eval.app)";19const FETCH_TIMEOUT_MS = 120_000;20const MAX_DOWNLOAD = 200 * 1024 * 1024;2122async function download(url: string): Promise<{ buf: Buffer; status: number; contentType: string | null }> {23  const ac = new AbortController();24  const t = setTimeout(() => ac.abort(), FETCH_TIMEOUT_MS);25  try {26    const r = await fetch(url, { headers: { "User-Agent": UA, Accept: "application/json, text/csv, */*" }, signal: ac.signal, redirect: "follow" });27    const ab = await r.arrayBuffer();28    if (ab.byteLength > MAX_DOWNLOAD) throw new Error("téléchargement trop volumineux");29    return { buf: Buffer.from(ab), status: r.status, contentType: r.headers.get("content-type") };30  } finally { clearTimeout(t); }31}3233function rawDir(sourceKey: string): string { const d = path.join(metreFilesDir(), "_sources", sourceKey); fs.mkdirSync(d, { recursive: true }); return d; }3435interface DocMeta { url: string | null; documentId: string | null; title: string; publisher: string; publicationDate: string | null; effectiveDate: string | null; license: string; parserVersion: string; ext: string }3637/** Enregistre un document brut ; renvoie null s'il existe déjà (même hash). */38function storeDocument(d: Database.Database, sourceKey: string, buf: Buffer, meta: DocMeta): { id: string; hash: string; rawPath: string } | null {39  const hash = createHash("sha256").update(buf).digest("hex");40  const exists = d.prepare("SELECT id FROM metre_source_documents WHERE source_key=? AND content_hash=?").get(sourceKey, hash) as { id: string } | undefined;41  if (exists) return null;42  const rawPath = path.join(rawDir(sourceKey), `${hash}.${meta.ext}`);43  if (!fs.existsSync(rawPath)) fs.writeFileSync(rawPath, buf);44  const id = randomUUID();45  d.prepare("INSERT INTO metre_source_documents(id,source_key,url,document_id,title,publisher,publication_date,effective_date,retrieved_at,license,content_hash,raw_path,bytes,parser_version,qa_status) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,'new')")46    .run(id, sourceKey, meta.url, meta.documentId, meta.title, meta.publisher, meta.publicationDate, meta.effectiveDate, nowIso(), meta.license, hash, rawPath, buf.length, meta.parserVersion);47  return { id, hash, rawPath };48}4950interface ObsInput { externalId: string; resourceCode?: string | null; description: string; originalUnit: string | null; originalAmount: number | null; normalizedUnit?: string | null; normalizedAmount?: number | null; includesMaterial?: boolean | null; includesLabour?: boolean | null; includesEquipment?: boolean | null; taxBasis?: string | null; geography?: string | null; regionCode?: string | null; buyer?: string | null; classificationScheme?: string | null; classificationCode?: string | null; classificationLabel?: string | null; dateBasis?: string | null; effectiveFrom?: string | null; effectiveTo?: string | null; extractionMethod: string; lineage: unknown; uncertaintyNote?: string | null; sourceUrl?: string | null }5152function insertObservations(d: Database.Database, sourceKey: string, documentId: string, evidenceClass: string, license: string, obs: ObsInput[]): { inserted: number; skipped: number } {53  const ins = d.prepare(`INSERT OR IGNORE INTO metre_price_observations(id,source_key,document_id,evidence_class,external_id,resource_code,original_description,original_unit,original_amount,normalized_unit,normalized_amount,currency,includes_material,includes_labour,includes_equipment,tax_basis,geography,region_code,buyer,classification_scheme,classification_code,classification_label,date_basis,effective_from,effective_to,license,extraction_method,lineage_json,verification_status,uncertainty_note,source_url)54    VALUES(?,?,?,?,?,?,?,?,?,?,?,'CAD',?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'unverified',?,?)`);55  let inserted = 0, skipped = 0;56  const b = (v: boolean | null | undefined) => (v == null ? null : v ? 1 : 0);57  const tx = d.transaction(() => {58    for (const o of obs) {59      const r = ins.run(randomUUID(), sourceKey, documentId, evidenceClass, o.externalId, o.resourceCode ?? null, o.description, o.originalUnit, o.originalAmount, o.normalizedUnit ?? o.originalUnit, o.normalizedAmount ?? o.originalAmount, b(o.includesMaterial), b(o.includesLabour), b(o.includesEquipment), o.taxBasis ?? "unknown", o.geography ?? null, o.regionCode ?? null, o.buyer ?? null, o.classificationScheme ?? null, o.classificationCode ?? null, o.classificationLabel ?? null, o.dateBasis ?? null, o.effectiveFrom ?? null, o.effectiveTo ?? null, license, o.extractionMethod, JSON.stringify(o.lineage).slice(0, 2000), o.uncertaintyNote ?? null, o.sourceUrl ?? null);60      if (r.changes) inserted++; else skipped++;61    }62  });63  tx();64  return { inserted, skipped };65}6667function flag(d: Database.Database, entityType: string, entityId: string, severity: "info" | "warning" | "error", code: string, message: string): void {68  d.prepare("INSERT INTO metre_quality_flags(entity_type,entity_id,severity,code,message) VALUES(?,?,?,?,?)").run(entityType, entityId, severity, code, message.slice(0, 500));69}7071function startRun(d: Database.Database, sourceKey: string, by: string): number {72  return Number(d.prepare("INSERT INTO metre_ingest_runs(source_key,started_at,triggered_by) VALUES(?,?,?)").run(sourceKey, nowIso(), by).lastInsertRowid);73}74function endRun(d: Database.Database, id: number, status: string, stats: { documents: number; records: number; skipped: number; errors: number }, log: unknown): void {75  d.prepare("UPDATE metre_ingest_runs SET finished_at=?, status=?, documents=?, records=?, skipped=?, errors=?, log_json=? WHERE id=?").run(nowIso(), status, stats.documents, stats.records, stats.skipped, stats.errors, JSON.stringify(log).slice(0, 20000), id);76  const src = (d.prepare("SELECT source_key FROM metre_ingest_runs WHERE id=?").get(id) as { source_key: string }).source_key;77  d.prepare("UPDATE metre_sources SET last_run_at=?, last_status=?, last_error=? WHERE key=?").run(nowIso(), status, status === "error" ? JSON.stringify(log).slice(0, 500) : null, src);78}7980/* ------------------------------------------------------------------ SEAO */8182export async function ingestSeao(ctx: Ctx, opts: { weeks?: number } = {}): Promise<{ runId: number; documents: number; records: number; skipped: number; log: unknown[] }> {83  const d = ctx.db;84  const runId = startRun(d, "seao", ctx.user.id);85  const log: unknown[] = [];86  const stats = { documents: 0, records: 0, skipped: 0, errors: 0 };87  try {88    const pk = await download(SEAO_DATASET_URL);89    if (pk.status !== 200) throw new Error(`CKAN package_show HTTP ${pk.status}`);90    const resources = listWeeklyResources(JSON.parse(pk.buf.toString("utf8"))).slice(0, Math.min(12, Math.max(1, opts.weeks ?? 2)));91    for (const res of resources) {92      const known = d.prepare("SELECT id FROM metre_source_documents WHERE source_key='seao' AND url=?").get(res.url) as { id: string } | undefined;93      if (known) { stats.skipped++; log.push({ resource: res.name, status: "déjà ingéré (URL)" }); continue; }94      const dl = await download(res.url);95      if (dl.status !== 200) { stats.errors++; log.push({ resource: res.name, status: `HTTP ${dl.status}` }); continue; }96      let json: unknown;97      try { json = JSON.parse(dl.buf.toString("utf8")); } catch { stats.errors++; log.push({ resource: res.name, status: "JSON invalide" }); continue; }98      const parsed = parseSeaoPackage(json);99      const doc = storeDocument(d, "seao", dl.buf, { url: res.url, documentId: res.name, title: `SEAO hebdo ${res.from} → ${res.to}`, publisher: "Secrétariat du Conseil du trésor", publicationDate: parsed.publishedDate?.slice(0, 10) ?? null, effectiveDate: res.to, license: "CC-BY 4.0", parserVersion: SEAO_PARSER_VERSION, ext: "json" });100      if (!doc) { stats.skipped++; log.push({ resource: res.name, status: "déjà ingéré (hash)" }); continue; }101      stats.documents++;102      const r = insertObservations(d, "seao", doc.id, "awarded_contract_total", "CC-BY 4.0", parsed.observations.map((o) => ({103        externalId: o.externalId, description: `${o.title}${o.itemDescription ? ` — ${o.itemDescription}` : ""}`.slice(0, 400), originalUnit: "contrat", originalAmount: o.amount, normalizedUnit: "contrat", normalizedAmount: o.amount,104        includesMaterial: null, includesLabour: null, includesEquipment: null, taxBasis: "unknown", geography: o.supplierLocality ? `${o.supplierLocality}, ${o.supplierRegion ?? ""}` : o.supplierRegion, regionCode: null, buyer: o.buyer,105        classificationScheme: o.unspscCode ? "UNSPSC" : null, classificationCode: o.unspscCode, classificationLabel: o.unspscLabel, dateBasis: o.awardDate, effectiveFrom: o.awardDate, extractionMethod: "ocds-json",106        lineage: { ocid: o.ocid, method: o.procurementMethod, tenderers: o.tenderers, municipal: o.buyerMunicipal, supplier: o.supplier }, uncertaintyNote: "Montant total d'un contrat octroyé : NE PAS interpréter comme un prix unitaire ni comme un coût résidentiel installé.", sourceUrl: o.noticeUrl,107      })));108      d.prepare("UPDATE metre_source_documents SET qa_status='published', records=?, qa_notes=? WHERE id=?").run(r.inserted, JSON.stringify({ releases: parsed.releases, works: parsed.observations.length, skipped: parsed.skipped }), doc.id);109      stats.records += r.inserted; stats.skipped += r.skipped;110      if (parsed.observations.length === 0) flag(d, "source_document", doc.id, "warning", "no_records", `Aucune release « works » avec octroi dans ${res.name}`);111      log.push({ resource: res.name, releases: parsed.releases, inserted: r.inserted, duplicates: r.skipped, skipped: parsed.skipped });112    }113    endRun(d, runId, stats.errors && !stats.documents ? "error" : stats.errors ? "partial" : "ok", stats, log);114  } catch (e) {115    stats.errors++; log.push({ error: (e as Error).message });116    endRun(d, runId, "error", stats, log);117  }118  audit(d, { userId: ctx.user.id, role: ctx.user.role, action: "ingest.seao", details: stats, ip: ctx.ip });119  return { runId, ...stats, log };120}121122/* --------------------------------------------------------- permis Montréal */123124export async function ingestPermits(ctx: Ctx, opts: { url?: string; detail?: boolean } = {}): Promise<{ runId: number; documents: number; records: number; skipped: number; log: unknown[] }> {125  const d = ctx.db;126  const runId = startRun(d, "mtl_permits", ctx.user.id);127  const log: unknown[] = [];128  const stats = { documents: 0, records: 0, skipped: 0, errors: 0 };129  try {130    let statsUrl = opts.url ?? null, detailUrl: string | null = null;131    if (!statsUrl) {132      const pk = await download(PERMITS_DATASET_URL);133      if (pk.status !== 200) throw new Error(`CKAN package_show HTTP ${pk.status}`);134      const res = ((JSON.parse(pk.buf.toString("utf8")) as { result?: { resources?: { url?: string; format?: string }[] } }).result?.resources ?? []).filter((r) => (r.format ?? "").toUpperCase() === "CSV" && r.url);135      statsUrl = res.find((r) => /statistiques/i.test(r.url!))?.url ?? null;136      detailUrl = res.find((r) => /permis-construction\.csv$/i.test(r.url!))?.url ?? null;137      if (!statsUrl) throw new Error("ressource « statistiques-permis-de-construction.csv » introuvable dans le jeu de données");138    }139    // 1. agrégats avec valeur déclarée des travaux140    const dl = await download(statsUrl);141    if (dl.status !== 200) throw new Error(`téléchargement HTTP ${dl.status} (${statsUrl}) — importer le CSV manuellement`);142    const doc = storeDocument(d, "mtl_permits", dl.buf, { url: statsUrl, documentId: path.basename(statsUrl), title: "Statistiques des permis de construction (valeur déclarée des travaux par année, arrondissement et type) — Ville de Montréal", publisher: "Ville de Montréal", publicationDate: null, effectiveDate: null, license: "CC-BY 4.0", parserVersion: PERMITS_PARSER_VERSION, ext: "csv" });143    if (!doc) { stats.skipped++; log.push({ resource: "statistiques", status: "déjà ingéré (hash)" }); }144    else {145      stats.documents++;146      const parsed = parsePermitStatsCsv(dl.buf.toString("utf8"));147      const r = insertObservations(d, "mtl_permits", doc.id, "permit_declared_value", "CC-BY 4.0", parsed.observations.map((o) => ({148        externalId: o.externalId, description: `${o.typeLabel} — ${o.borough} — ${o.year} : ${o.permits} permis, valeur déclarée totale des travaux`.slice(0, 400), originalUnit: "total annuel", originalAmount: o.declaredWorksValue, normalizedUnit: "permis (moyenne)", normalizedAmount: o.avgPerPermit, taxBasis: "unknown",149        geography: `Montréal — ${o.borough}`, regionCode: "QC-MTL", classificationScheme: "mtl_permit_type", classificationCode: o.typeCode, classificationLabel: o.typeLabel, dateBasis: `${o.year}-12-31`, effectiveFrom: `${o.year}-01-01`, effectiveTo: `${o.year}-12-31`,150        extractionMethod: "csv", lineage: { permits: o.permits, permitFees: o.permitFees, avgPerPermit: o.avgPerPermit }, uncertaintyNote: "Valeur des travaux DÉCLARÉE par les requérants, agrégée par année/arrondissement/type (sans valeur légale selon la Ville) — ni coût réel ni prix unitaire.", sourceUrl: statsUrl,151      })));152      d.prepare("UPDATE metre_source_documents SET qa_status='published', records=?, qa_notes=? WHERE id=?").run(r.inserted, JSON.stringify({ rows: parsed.rows, skipped: parsed.skipped, columns: parsed.columns }), doc.id);153      stats.records += r.inserted; stats.skipped += r.skipped;154      log.push({ resource: "statistiques", rows: parsed.rows, inserted: r.inserted, duplicates: r.skipped });155    }156    // 2. détail (sans valeur) : résumé statistique seulement, document conservé157    if (detailUrl && opts.detail !== false) {158      const dd = await download(detailUrl);159      if (dd.status !== 200) { stats.errors++; log.push({ resource: "detail", status: `HTTP ${dd.status}` }); }160      else {161        const doc2 = storeDocument(d, "mtl_permits", dd.buf, { url: detailUrl, documentId: path.basename(detailUrl), title: "Permis de construction, transformation et démolition — détail (sans valeur des travaux)", publisher: "Ville de Montréal", publicationDate: null, effectiveDate: null, license: "CC-BY 4.0", parserVersion: PERMITS_PARSER_VERSION, ext: "csv" });162        if (!doc2) { stats.skipped++; log.push({ resource: "detail", status: "déjà ingéré (hash)" }); }163        else {164          stats.documents++;165          const sum = summarizePermitDetailCsv(dd.buf.toString("utf8"), 2023);166          d.prepare("UPDATE metre_source_documents SET qa_status='published', records=0, qa_notes=? WHERE id=?").run(JSON.stringify(sum), doc2.id);167          if (!sum.hasValueColumn) flag(d, "source_document", doc2.id, "info", "no_value_column", "Le détail des permis ne publie aucune valeur de travaux : résumé statistique seulement (aucune observation).");168          log.push({ resource: "detail", rows_since_2023: sum.rows, years: sum.years, hasValueColumn: sum.hasValueColumn });169        }170      }171    }172    endRun(d, runId, stats.errors ? "partial" : "ok", stats, log);173  } catch (e) {174    stats.errors++; log.push({ error: (e as Error).message });175    endRun(d, runId, "error", stats, log);176  }177  audit(d, { userId: ctx.user.id, role: ctx.user.role, action: "ingest.mtl_permits", details: stats, ip: ctx.ip });178  return { runId, ...stats, log };179}180181/* ------------------------------------------------------- import manuel CSV */182183export function importScheduleCsv(ctx: Ctx, p: { sourceKey: string; csv: string; title: string; url: string | null; publicationDate: string | null; effectiveDate: string | null; license: string; evidenceClass: "published_schedule" | "awarded_unit_bid" | "supplier_quote" | "contractor_quote"; geography: string | null; regionCode: string | null }): { documentId: string | null; inserted: number; skipped: number; parseSkipped: { line: number; reason: string }[] } {184  const d = ctx.db;185  const src = d.prepare("SELECT key, publisher FROM metre_sources WHERE key=?").get(p.sourceKey) as { key: string; publisher: string } | undefined;186  if (!src) throw new HttpError(400, "Source inconnue.");187  if (!p.license.trim()) throw new HttpError(400, "Licence / droits de réutilisation requis pour tout import.");188  if (p.csv.length > 20 * 1024 * 1024) throw new HttpError(413, "CSV trop volumineux.");189  let parsed;190  try { parsed = parseScheduleCsv(p.csv); } catch (e) { throw new HttpError(400, (e as Error).message); }191  const runId = startRun(d, p.sourceKey, ctx.user.id);192  const doc = storeDocument(d, p.sourceKey, Buffer.from(p.csv, "utf8"), { url: p.url, documentId: null, title: p.title.slice(0, 200), publisher: src.publisher, publicationDate: p.publicationDate, effectiveDate: p.effectiveDate, license: p.license.slice(0, 200), parserVersion: SCHEDULE_PARSER_VERSION, ext: "csv" });193  if (!doc) { endRun(d, runId, "ok", { documents: 0, records: 0, skipped: 1, errors: 0 }, [{ status: "déjà importé (hash)" }]); return { documentId: null, inserted: 0, skipped: 1, parseSkipped: parsed.skipped }; }194  const r = insertObservations(d, p.sourceKey, doc.id, p.evidenceClass, p.license, parsed.rows.map((o) => ({195    externalId: `${doc.hash.slice(0, 12)}:${o.externalId}`, resourceCode: o.code, description: o.description, originalUnit: o.unit, originalAmount: o.amount, includesMaterial: o.includesMaterial, includesLabour: o.includesLabour, includesEquipment: o.includesEquipment, taxBasis: o.taxBasis,196    geography: o.region ?? p.geography, regionCode: p.regionCode, classificationLabel: o.category, dateBasis: o.effectiveDate ?? p.effectiveDate, effectiveFrom: o.effectiveDate ?? p.effectiveDate, extractionMethod: "manual-csv", lineage: { line: o.externalId, note: o.note, importedBy: ctx.user.id },197    uncertaintyNote: p.evidenceClass === "published_schedule" ? "Barème publié (infrastructure/municipal) — ne pas assimiler à un prix résidentiel installé sans vérification." : null, sourceUrl: p.url,198  })));199  d.prepare("UPDATE metre_source_documents SET qa_status='parsed', records=?, qa_notes=? WHERE id=?").run(r.inserted, JSON.stringify({ header: parsed.header, skipped: parsed.skipped.slice(0, 50) }), doc.id);200  for (const s of parsed.skipped.slice(0, 20)) flag(d, "source_document", doc.id, "info", "row_skipped", `ligne ${s.line} : ${s.reason}`);201  endRun(d, runId, "ok", { documents: 1, records: r.inserted, skipped: r.skipped + parsed.skipped.length, errors: 0 }, [{ inserted: r.inserted, duplicates: r.skipped, parseSkipped: parsed.skipped.length }]);202  audit(d, { userId: ctx.user.id, role: ctx.user.role, action: "ingest.manual", entityType: "source_document", entityId: doc.id, details: { source: p.sourceKey, inserted: r.inserted }, ip: ctx.ip });203  return { documentId: doc.id, inserted: r.inserted, skipped: r.skipped, parseSkipped: parsed.skipped };204}205206/* ------------------------------------------------------------------ QA */207208export function setDocumentQa(ctx: Ctx, documentId: string, status: "published" | "rejected", notes: string | null): void {209  ctx.db.prepare("UPDATE metre_source_documents SET qa_status=?, qa_notes=COALESCE(?, qa_notes) WHERE id=?").run(status, notes, documentId);210  ctx.db.prepare("UPDATE metre_price_observations SET verification_status=? WHERE document_id=?").run(status === "published" ? "verified" : "rejected", documentId);211  audit(ctx.db, { userId: ctx.user.id, role: ctx.user.role, action: `source.qa.${status}`, entityType: "source_document", entityId: documentId, ip: ctx.ip });212}213214export function sourcesOverview(d: Database.Database) {215  const sources = d.prepare(`SELECT s.*, (SELECT COUNT(*) FROM metre_source_documents x WHERE x.source_key=s.key) documents, (SELECT COUNT(*) FROM metre_price_observations o WHERE o.source_key=s.key) observations, (SELECT MAX(retrieved_at) FROM metre_source_documents x WHERE x.source_key=s.key) lastRetrieved FROM metre_sources s ORDER BY s.key`).all();216  const documents = d.prepare("SELECT id, source_key sourceKey, title, url, publication_date publicationDate, effective_date effectiveDate, retrieved_at retrievedAt, license, content_hash hash, bytes, parser_version parserVersion, qa_status qaStatus, records FROM metre_source_documents ORDER BY retrieved_at DESC LIMIT 100").all();217  const runs = d.prepare("SELECT id, source_key sourceKey, started_at startedAt, finished_at finishedAt, status, documents, records, skipped, errors FROM metre_ingest_runs ORDER BY started_at DESC LIMIT 40").all();218  const flags = d.prepare("SELECT id, entity_type entityType, entity_id entityId, severity, code, message, status, created_at createdAt FROM metre_quality_flags WHERE status='open' ORDER BY created_at DESC LIMIT 100").all();219  const byClass = d.prepare("SELECT evidence_class evidenceClass, COUNT(*) n, MIN(date_basis) first, MAX(date_basis) last FROM metre_price_observations GROUP BY evidence_class").all();220  return { sources, documents, runs, flags, byClass };221}222223export function searchObservations(d: Database.Database, p: { q?: string; evidenceClass?: string; sourceKey?: string; limit?: number }) {224  const where: string[] = []; const args: unknown[] = [];225  if (p.q) { where.push("(original_description LIKE ? OR classification_label LIKE ? OR buyer LIKE ?)"); const like = `%${p.q.slice(0, 80)}%`; args.push(like, like, like); }226  if (p.evidenceClass) { where.push("evidence_class=?"); args.push(p.evidenceClass); }227  if (p.sourceKey) { where.push("source_key=?"); args.push(p.sourceKey); }228  const sql = `SELECT id, source_key sourceKey, evidence_class evidenceClass, external_id externalId, original_description description, original_unit unit, original_amount amount, normalized_unit normalizedUnit, normalized_amount normalizedAmount, geography, buyer, classification_scheme scheme, classification_code code, classification_label label, date_basis dateBasis, license, extraction_method extractionMethod, verification_status verificationStatus, uncertainty_note uncertaintyNote, source_url sourceUrl FROM metre_price_observations ${where.length ? "WHERE " + where.join(" AND ") : ""} ORDER BY date_basis DESC LIMIT ?`;229  return d.prepare(sql).all(...args, Math.min(500, Math.max(1, p.limit ?? 50)));230}231
Files main
Menu

Theme