# -*- coding: utf-8 -*- """ Синхронизация прайса сайта с Excel. Как пользоваться: 1. Откройте prices.xlsx и правьте таблицу на листе "Цены" 2. Запустите файл "обновить-цены.bat" (двойной клик) 3. Залейте на хостинг обновлённый prices-data.js Колонки Excel: A Раздел | B Подкатегория | C Название | D Цена (число) | E Ед.изм | F Кол-во | G Показывать (Да/Нет) """ from __future__ import annotations import json import re import sys from pathlib import Path try: import openpyxl from openpyxl.styles import Alignment, Font, PatternFill, Border, Side from openpyxl.utils import get_column_letter from openpyxl.worksheet.datavalidation import DataValidation except ImportError: print("Нужна библиотека openpyxl. Установите: pip install openpyxl") sys.exit(1) ROOT = Path(__file__).resolve().parent XLSX_PATH = ROOT / "prices.xlsx" JS_PATH = ROOT / "prices-data.js" YES_VALUES = {"да", "yes", "y", "1", "true", "+", "д"} def js_str(value: str) -> str: return json.dumps(value, ensure_ascii=False) def parse_prices_js(text: str) -> list[dict]: """Parse controlled prices-data.js format into nested Python structure.""" cats: list[dict] = [] # Top-level categories: either with children or with items # Split by top-level objects roughly via brace matching after window assignment body = re.sub(r"^[\s\S]*?window\.PRICES_DATA\s*=\s*", "", text.strip()) body = re.sub(r";\s*$", "", body).strip() if not (body.startswith("[") and body.endswith("]")): raise ValueError("Некорректный формат prices-data.js") # Tokenize top-level category objects by scanning braces inner = body[1:-1] objects: list[str] = [] depth = 0 start = None for i, ch in enumerate(inner): if ch == "{": if depth == 0: start = i depth += 1 elif ch == "}": depth -= 1 if depth == 0 and start is not None: objects.append(inner[start : i + 1]) start = None def parse_items(blob: str) -> list[dict]: items = [] for item in re.finditer( r'\{\s*name:\s*("(?:\\.|[^"\\])*")\s*,\s*price:\s*("(?:\\.|[^"\\])*")\s*,\s*amount:\s*("(?:\\.|[^"\\])*")\s*,\s*unit:\s*("(?:\\.|[^"\\])*")\s*\}', blob, ): items.append( { "name": json.loads(item.group(1)), "price": json.loads(item.group(2)), "amount": json.loads(item.group(3)), "unit": json.loads(item.group(4)), } ) return items for obj in objects: title_m = re.search(r'title:\s*("(?:\\.|[^"\\])*")', obj) if not title_m: continue title = json.loads(title_m.group(1)) children_m = re.search(r"children:\s*\[([\s\S]*)\]\s*$", obj.strip().rstrip("}")) # Better: find children array bounds children_idx = obj.find("children:") if children_idx != -1: ch_start = obj.find("[", children_idx) # find matching closing bracket for children array depth = 0 ch_end = None for i in range(ch_start, len(obj)): if obj[i] == "[": depth += 1 elif obj[i] == "]": depth -= 1 if depth == 0: ch_end = i break children_blob = obj[ch_start + 1 : ch_end] if ch_end else "" children = [] # child objects depth = 0 start = None for i, ch in enumerate(children_blob): if ch == "{": if depth == 0: start = i depth += 1 elif ch == "}": depth -= 1 if depth == 0 and start is not None: child_obj = children_blob[start : i + 1] ctitle_m = re.search(r'title:\s*("(?:\\.|[^"\\])*")', child_obj) if not ctitle_m: continue children.append( { "title": json.loads(ctitle_m.group(1)), "items": parse_items(child_obj), } ) start = None cats.append({"title": title, "children": children}) else: cats.append({"title": title, "items": parse_items(obj)}) return cats def fmt_price_number(value) -> str: try: num = float(value) if num.is_integer(): num = int(num) return f"{num:,}".replace(",", " ") except Exception: return str(value).strip() def extract_number_from_price(price: str): """'от 1 200 ₽/м²' -> 1200""" if price is None: return "" if isinstance(price, (int, float)): return price s = str(price) s = re.sub(r"^от\s+", "", s, flags=re.I) s = re.sub(r"\s*₽.*$", "", s) s = s.replace(" ", "").replace("\u00a0", "") s = s.replace(",", ".") try: num = float(s) return int(num) if num.is_integer() else num except Exception: return s def build_price_string(number, unit: str) -> str: unit = (unit or "").strip() num = fmt_price_number(number) if unit and unit != "—": return f"от {num} ₽/{unit}" return f"от {num} ₽" def flatten_for_excel(data: list[dict]) -> list[dict]: rows = [] for cat in data: section = cat.get("title", "") children = cat.get("children") or [] if children: for child in children: sub = child.get("title", "") for item in child.get("items") or []: rows.append( { "section": section, "subcategory": sub, "name": item.get("name", ""), "price": extract_number_from_price(item.get("price", "")), "unit": item.get("unit", ""), "amount": item.get("amount", "1"), "show": "Да", } ) else: for item in cat.get("items") or []: rows.append( { "section": section, "subcategory": "", "name": item.get("name", ""), "price": extract_number_from_price(item.get("price", "")), "unit": item.get("unit", ""), "amount": item.get("amount", "1"), "show": "Да", } ) return rows def export_xlsx_from_js() -> None: if not JS_PATH.exists(): print(f"Не найден {JS_PATH.name}") sys.exit(1) data = parse_prices_js(JS_PATH.read_text(encoding="utf-8")) rows = flatten_for_excel(data) wb = openpyxl.Workbook() ws = wb.active ws.title = "Цены" headers = ["Раздел", "Подкатегория", "Название работы", "Цена", "Ед.изм", "Кол-во", "Показывать"] header_fill = PatternFill("solid", fgColor="1F4E79") header_font = Font(color="FFFFFF", bold=True) thin = Border( left=Side(style="thin", color="BDD7EE"), right=Side(style="thin", color="BDD7EE"), top=Side(style="thin", color="BDD7EE"), bottom=Side(style="thin", color="BDD7EE"), ) for col, title in enumerate(headers, start=1): cell = ws.cell(1, col, title) cell.fill = header_fill cell.font = header_font cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True) for i, row in enumerate(rows, start=2): values = [ row["section"], row["subcategory"], row["name"], row["price"], row["unit"], row["amount"], row["show"], ] for col, value in enumerate(values, start=1): cell = ws.cell(i, col, value) cell.border = thin cell.alignment = Alignment(vertical="center", wrap_text=True) if col == 4: cell.alignment = Alignment(horizontal="right", vertical="center") widths = [34, 18, 70, 12, 10, 10, 12] for i, width in enumerate(widths, start=1): ws.column_dimensions[get_column_letter(i)].width = width ws.freeze_panes = "A2" ws.auto_filter.ref = f"A1:G{len(rows) + 1}" dv = DataValidation(type="list", formula1='"Да,Нет"', allow_blank=False) dv.error = "Выберите Да или Нет" dv.errorTitle = "Показывать" ws.add_data_validation(dv) dv.add(f"G2:G{max(2, len(rows) + 200)}") # Instruction sheet info = wb.create_sheet("Инструкция", 0) info["A1"] = "Как обновлять цены на сайте" info["A1"].font = Font(bold=True, size=14) lines = [ "", "1) Редактируйте только лист «Цены».", "2) Можно менять названия, цены, единицы, количество.", "3) Чтобы временно скрыть позицию с сайта — поставьте в колонке «Показывать» значение «Нет».", "4) Чтобы удалить позицию навсегда — удалите строку.", "5) Чтобы добавить новую позицию — добавьте новую строку в конец таблицы.", "6) Для разделов с подкатегориями заполняйте и «Раздел», и «Подкатегория»", " (например: Раздел = Демонтажные работы, Подкатегория = Полы).", "7) Для разделов без подкатегорий оставьте «Подкатегория» пустой", " (например: Электромонтажные работы).", "8) В колонке «Цена» пишите только число, например: 650", " На сайте автоматически станет: от 650 ₽/шт (если ед.изм = шт).", "9) После правок закройте Excel и запустите файл «обновить-цены.bat».", "10) На хостинг REG.RU залейте обновлённый файл prices-data.js", " (и при желании сам prices.xlsx — для вашего удобного хранения).", "", "Важно: сайт сам Excel не читает. Сначала bat обновляет prices-data.js, потом этот файл идёт на сайт.", ] for idx, line in enumerate(lines, start=2): info[f"A{idx}"] = line info.column_dimensions["A"].width = 110 wb.save(XLSX_PATH) print(f"Создан/обновлён Excel: {XLSX_PATH.name} ({len(rows)} строк)") def nest_from_rows(rows: list[dict]) -> list[dict]: """Build nested PRICES_DATA from flat excel rows, preserving order.""" result: list[dict] = [] section_map: dict[str, dict] = {} child_map: dict[tuple[str, str], dict] = {} for row in rows: section = row["section"] sub = row["subcategory"] item = { "name": row["name"], "price": build_price_string(row["price"], row["unit"]), "amount": str(row["amount"] or "1"), "unit": row["unit"] or "—", } if section not in section_map: section_obj = {"title": section} section_map[section] = section_obj result.append(section_obj) section_obj = section_map[section] if sub: key = (section, sub) if key not in child_map: if "children" not in section_obj: section_obj["children"] = [] child = {"title": sub, "items": []} child_map[key] = child section_obj["children"].append(child) child_map[key]["items"].append(item) else: if "items" not in section_obj: section_obj["items"] = [] section_obj["items"].append(item) # Cleanup: if a section has children, drop empty items key for section_obj in result: if section_obj.get("children"): section_obj.pop("items", None) elif "items" not in section_obj: section_obj["items"] = [] return result def write_prices_js(data: list[dict]) -> None: parts = ["window.PRICES_DATA = ["] for ci, cat in enumerate(data): parts.append(" {") parts.append(f" title: {js_str(cat['title'])},") if cat.get("children"): parts.append(" children: [") for si, sub in enumerate(cat["children"]): parts.append(" {") parts.append(f" title: {js_str(sub['title'])},") parts.append(" items: [") for ii, item in enumerate(sub["items"]): comma = "," if ii < len(sub["items"]) - 1 else "" parts.append( " { " f"name: {js_str(item['name'])}, " f"price: {js_str(item['price'])}, " f"amount: {js_str(item['amount'])}, " f"unit: {js_str(item['unit'])} " f"}}{comma}" ) parts.append(" ]") parts.append(" }" + ("," if si < len(cat["children"]) - 1 else "")) parts.append(" ]") else: parts.append(" items: [") for ii, item in enumerate(cat.get("items") or []): comma = "," if ii < len(cat["items"]) - 1 else "" parts.append( " { " f"name: {js_str(item['name'])}, " f"price: {js_str(item['price'])}, " f"amount: {js_str(item['amount'])}, " f"unit: {js_str(item['unit'])} " f"}}{comma}" ) parts.append(" ]") parts.append(" }" + ("," if ci < len(data) - 1 else "")) parts.append("];") parts.append("") JS_PATH.write_text("\n".join(parts), encoding="utf-8") def import_xlsx_to_js() -> None: if not XLSX_PATH.exists(): print(f"Не найден {XLSX_PATH.name}. Сначала создайте его командой: python update-prices.py --init") sys.exit(1) wb = openpyxl.load_workbook(XLSX_PATH, data_only=True) if "Цены" not in wb.sheetnames: print("В Excel нет листа «Цены»") sys.exit(1) ws = wb["Цены"] rows = [] skipped = 0 for r in range(2, ws.max_row + 1): section = ws.cell(r, 1).value sub = ws.cell(r, 2).value name = ws.cell(r, 3).value price = ws.cell(r, 4).value unit = ws.cell(r, 5).value amount = ws.cell(r, 6).value show = ws.cell(r, 7).value section = str(section).strip() if section is not None else "" sub = str(sub).strip() if sub is not None else "" name = str(name).strip() if name is not None else "" unit = str(unit).strip() if unit is not None else "" amount = str(amount).strip() if amount is not None else "1" show_s = str(show).strip().lower() if show is not None else "да" if not section and not name: continue if not name: skipped += 1 continue if show_s not in YES_VALUES: skipped += 1 continue if price in (None, ""): skipped += 1 continue rows.append( { "section": section or "Прочее", "subcategory": sub, "name": name, "price": price, "unit": unit, "amount": amount or "1", } ) data = nest_from_rows(rows) write_prices_js(data) total_items = sum( len(c.get("items") or []) + sum(len(ch.get("items") or []) for ch in (c.get("children") or [])) for c in data ) print(f"Готово: {JS_PATH.name}") print(f"Разделов: {len(data)}") print(f"Позиций на сайте: {total_items}") if skipped: print(f"Пропущено строк (скрытые/пустые): {skipped}") print("Теперь залейте prices-data.js на хостинг REG.RU") def main() -> None: args = sys.argv[1:] if "--init" in args or "--export" in args or not XLSX_PATH.exists(): export_xlsx_from_js() if "--init" in args or "--export" in args: return import_xlsx_to_js() if __name__ == "__main__": main()