Files
frieze/update-prices.py
T

466 lines
18 KiB
Python

# -*- 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()