Загрузил проект на гит сурс
This commit is contained in:
@@ -0,0 +1,465 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user