ZARAHOME 2.0
This commit is contained in:
parent
c6e684c830
commit
a2ea206e32
3
.gitignore
vendored
3
.gitignore
vendored
@ -29,4 +29,5 @@ Temporary Items
|
||||
|
||||
__pycache__
|
||||
/Parsing ZARAHOME/src/records_folder
|
||||
Ignore_Temp
|
||||
Ignore_Temp
|
||||
/Processing/Files-todo
|
||||
Binary file not shown.
1
Parsing ZARAHOME/src/exclusion_materials.txt
Normal file
1
Parsing ZARAHOME/src/exclusion_materials.txt
Normal file
@ -0,0 +1 @@
|
||||
"glass", "ceramic","stoneware","earthware","borosilicate","knif","kniv","marble","stone","porcelain"
|
||||
@ -7,7 +7,14 @@ import logging, os, sys
|
||||
|
||||
# ────────────────────────── конфигурация ───────────────────────────
|
||||
DEL_SAME = "YES" # "YES" → фильтрация, "NO" → без фильтра
|
||||
|
||||
"""| Шаг | Условие | Что делаем | Переход |
|
||||
| ----- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------- |
|
||||
| **1** | Совпали **пять** полей `article, name, size_full, price, clr_name` | Группируем такие строки. | → 2 |
|
||||
| **2** | Сравниваем `partnumber_val` внутри группы | *Если все одинаковые* → оставляем первую, остальные удаляем (полные дубли).<br>*Если разные* → → 3 | |
|
||||
| **3** | Сравниваем `vis` | *Если `vis` одинаковые* → → 4<br>*Если разные* → → 5 | |
|
||||
| **4** | `vis` одинаковы **(любое значение)** | Берём первые 4 символа `article` и символы 2-5 `partnumber_val`.<br>Оставляем **только те строки, где они совпадают**; все остальные удаляем. | Фильтр завершён |
|
||||
| **5** | `vis` разные | Оставляем **только** строку, где `vis == "SHOW"`; другая(ие) удаляются. | Фильтр завершён |
|
||||
"""
|
||||
_log_level = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||
logging.basicConfig(
|
||||
level=_log_level,
|
||||
@ -38,12 +45,12 @@ def extract_components_zarahome(parts):
|
||||
# ───────────────────── фильтр дубликатов on-the-fly ────────────────
|
||||
def push_row_dedup(row, idx, seen, out):
|
||||
"""Добавляет строку в out, соблюдая правила DEL_SAME."""
|
||||
art, name, size, price, clr = (row[idx["Артикул"]],
|
||||
art, name, size, clr = (row[idx["Артикул"]],
|
||||
row[idx["Название товара или услуги"]],
|
||||
row[idx["Свойство: Размер"]],
|
||||
row[idx["Цена закупки"]],
|
||||
#row[idx["Цена закупки"]], #Если нужно еще и при одинаковой цене - то раскоментить
|
||||
row[idx["Свойство: Цвет"]])
|
||||
base = (art, name, size, price, clr)
|
||||
base = (art, name, size, clr)
|
||||
|
||||
if base not in seen:
|
||||
seen[base] = row
|
||||
@ -99,7 +106,7 @@ class Extractor:
|
||||
"Цена закупки",
|
||||
"Свойство: Вес(г)",
|
||||
"Наличие на сайте",
|
||||
"Изображения",
|
||||
#"Изображения",
|
||||
"Изображения варианта",
|
||||
"Параметр: Состав",
|
||||
"Параметр: Уход",
|
||||
@ -253,13 +260,14 @@ class Extractor:
|
||||
default_idx = det.get("xmediaDefaultSet")
|
||||
if isinstance(raw_xmedia, list) and raw_xmedia:
|
||||
media_sets = [raw_xmedia[default_idx]] if isinstance(default_idx, int) else raw_xmedia
|
||||
elif isinstance(raw_xmedia, dict):
|
||||
media_sets = [raw_xmedia]
|
||||
else:
|
||||
media_sets = []
|
||||
all_imgs = [f"https://static.zarahome.net/8/photos4{loc['path']}/{m['idMedia']}2.jpg"
|
||||
for loc in media_sets for m in loc["xmediaItems"][0]["medias"]]
|
||||
all_imgs_s = "\n".join(all_imgs)
|
||||
#elif isinstance(raw_xmedia, dict):
|
||||
# media_sets = [raw_xmedia]
|
||||
#else:
|
||||
# media_sets = []
|
||||
|
||||
#all_imgs = [f"https://static.zarahome.net/8/photos4{loc['path']}/{m['idMedia']}2.jpg"
|
||||
# for loc in media_sets for m in loc["xmediaItems"][0]["medias"]]
|
||||
#all_imgs_s = "\n".join(all_imgs)
|
||||
|
||||
comp_txt = ""
|
||||
if det.get("compositionDetail") and det["compositionDetail"].get("parts"):
|
||||
@ -281,14 +289,48 @@ class Extractor:
|
||||
for clr in colors:
|
||||
clr_code = clr.get("id")
|
||||
clr_name = clr.get("name", "")
|
||||
clr_image = ""
|
||||
if clr.get("image") and clr["image"].get("url"):
|
||||
clr_image = f"https://static.zarahome.net/8/photos4{clr['image']['url']}_3_1_5.jpg"
|
||||
|
||||
clr_sets = [loc for loc in media_sets if loc.get("colorCode") == clr_code] or media_sets
|
||||
clr_imgs = [f"https://static.zarahome.net/8/photos4{loc['path']}/{m['idMedia']}2.jpg"
|
||||
for loc in clr_sets for m in loc["xmediaItems"][0]["medias"]]
|
||||
clr_imgs_s = "\n".join(clr_imgs)
|
||||
# Собираем все медиа текущего цвета с deliveryUrl и clazz
|
||||
clr_sets = [loc for loc in media_sets if str(loc.get("colorCode")) == str(clr_code)] or []
|
||||
media_items = [] # [(deliveryUrl, clazz)]
|
||||
for loc in clr_sets:
|
||||
for item in (loc.get("xmediaItems") or []):
|
||||
for m in (item.get("medias") or []):
|
||||
du = ((m.get("extraInfo") or {}).get("deliveryUrl"))
|
||||
if not du:
|
||||
continue
|
||||
media_items.append((du, m.get("clazz")))
|
||||
|
||||
# Выбор clr_image: среди clazz == 3 (ракурсы R), приоритет '-r1/' → '-r/'
|
||||
def _pick_color_image(cands):
|
||||
# приоритет -r1/
|
||||
for du, _ in cands:
|
||||
if "-r1/" in du:
|
||||
return du
|
||||
# затем -r/
|
||||
for du, _ in cands:
|
||||
if "-r/" in du:
|
||||
return du
|
||||
return ""
|
||||
|
||||
r_cands = [(du, clz) for du, clz in media_items if clz == 3]
|
||||
clr_image = _pick_color_image(r_cands)
|
||||
|
||||
# Фолбэк, если R-кадров нет совсем — используем штатную «сводч»-картинку цвета
|
||||
if not clr_image and clr.get("image") and clr["image"].get("url"):
|
||||
clr_image = f"https://static.zarahome.net/8/photos4{clr['image']['url']}_3_1_0.jpg"
|
||||
|
||||
# Список картинок варианта: все deliveryUrl, КРОМЕ clazz == 3 (все r/r1 исключаем)
|
||||
def _uniq_keep_order(seq):
|
||||
seen, out = set(), []
|
||||
for x in seq:
|
||||
if x and x not in seen:
|
||||
seen.add(x); out.append(x)
|
||||
return out
|
||||
|
||||
clr_urls = [du for du, clz in media_items if clz != 3]
|
||||
clr_imgs_s = "\n".join(_uniq_keep_order(clr_urls))
|
||||
|
||||
|
||||
for size in clr["sizes"]:
|
||||
vis = size.get("visibilityValue", "UNKNOWN")
|
||||
@ -317,7 +359,7 @@ class Extractor:
|
||||
price,
|
||||
weight,
|
||||
vis,
|
||||
all_imgs_s,
|
||||
#all_imgs_s,
|
||||
clr_imgs_s,
|
||||
comp_txt,
|
||||
care,
|
||||
|
||||
@ -1,3 +1,6 @@
|
||||
# ZARAHOME 2.0
|
||||
|
||||
|
||||
from categories import get_categories
|
||||
from xlsx_recorder import Recorder
|
||||
from requester import get_parser
|
||||
|
||||
165
Parsing ZARAHOME/src/readme.md
Normal file
165
Parsing ZARAHOME/src/readme.md
Normal file
@ -0,0 +1,165 @@
|
||||
Общее устройство
|
||||
• main.py
|
||||
o Инициализирует окружение (лог-уровень, режимы отправки JSON и т.п.), подхватывает категории из categories.xlsx (через categories.py), запускает экстрактор и рекордер.
|
||||
o По договорённости, переменные окружения:
|
||||
• LOG_LEVEL — уровень логов (DEBUG|INFO|WARNING|ERROR).
|
||||
• SEND_JSON / SAVE_JSON — 0/1 (или False/True) для отправки POST и/или сохранения локальной копии JSON.
|
||||
o Обрабатывает исключения верхнего уровня (не падаем молча).
|
||||
• categories.py
|
||||
o Читает categories.xlsx, отдаёт список URL категорий (пустые/невалидные строки игнорируются).
|
||||
• requester.py
|
||||
o Унифицированная обёртка вокруг HTTP:
|
||||
• parse(url, return_type=None) — делает GET, ретраи, заголовки, куки; при return_type="json" — json.loads().
|
||||
• Логи HTTP-запросов (в DEBUG видно метод/код/URL).
|
||||
• Контролируемые паузы между повторами при бэд-кодах.
|
||||
o Возвращает None при безнадёжной ошибке (экстрактор учитывает это).
|
||||
• xlsx_recorder.py
|
||||
o Создаёт папку records_folder (если её нет).
|
||||
o Пишет XLSX с «шапкой» колонок и данными.
|
||||
o Имя файла санитизируется (убираются/заменяются проблемные символы типа ? : * " < > | \ /), чтобы windows/mac не ругались.
|
||||
• extractor.py (ядро)
|
||||
o Класс Extractor выбирает метод в зависимости от parse_settings.json["method"].
|
||||
o Для ZaraHome — метод zarahome_extract_method. Остальные магазины пока «заглушены» (сообщают, что отключены), но исходные заготовки/сигнатуры сохранены, чтобы parse_settings не падал.
|
||||
|
||||
Поток данных (Zara Home)
|
||||
1. Старт по категории
|
||||
o Загружаем HTML категории; парсим #serverApp-state (большой JSON от сайта).
|
||||
o Определяем category_id. Ищем продуктовый ключ /category/{id}/product... в state.
|
||||
2. Получение списка товаров в категории
|
||||
o Если в state сразу есть массив products — берём «сводки» оттуда.
|
||||
o Если есть только списки ID (productIds, sortedProductIds...) — тянем товары пачками через API:
|
||||
o https://www.zarahome.com/itxrest/3/catalog/store/85009924/80290000/productsArray?languageId=-1&productIds=ID1,ID2,...&appId=1
|
||||
o Размер пачки ~20–50 ID (выбираем безопасно, чтобы не словить таймауты/блокировки).
|
||||
o Итог: «список сводок» товаров по категории (summaries).
|
||||
3. Проход по товарам и разворачивание вариаций
|
||||
o Для каждой сводки:
|
||||
• Если в ней уже есть detail.colors — это единичный товар, обрабатываем как «vprod».
|
||||
• Если это «bundled» — пробегаем по bundleProductSummaries.
|
||||
• Если вообще нет цветов — считаем «моно-вариант».
|
||||
o На каждом «vprod» берём:
|
||||
• name, detail.displayReference (article), price (в копейках), weight, и т.д.
|
||||
• Состав/уход/происхождение:
|
||||
• compositionDetail.parts → через нашу функцию extract_components_zarahome() собираем читаемую строку.
|
||||
• care[] → список описаний, склеенных переносами.
|
||||
• trace (страна изготовления) — берём из поля country у конкретного size и формируем строку Страна изготовления {COUNTRY} (если есть).
|
||||
• Каталожный путь: из sectionNameEN, familyName, subFamilyName (что пришло в конкретном vprod). Склеивается как Каталог/ZaraHome/<Section>/<Family>/<SubFamily> (пустые звенья выкидываются).
|
||||
4. Цвета и размеры
|
||||
o Проходим detail.colors:
|
||||
• clr_name (название цвета),
|
||||
• sizes[] — по каждому размеру берём:
|
||||
• size.name/size.description → склеенный size_full ("{description} ({name})" или просто name),
|
||||
• visibilityValue → поле «Наличие на сайте» (SHOW → в наличии, всё остальное → не в наличии),
|
||||
• price (переводим из копеек в основную валюту),
|
||||
• weight,
|
||||
• sku и partnumber: берём напрямую из полей размера size["sku"], size["partnumber"].
|
||||
o Картинки цвета (важно, обновлено):
|
||||
• Собираем все deliveryUrl из extraInfo каждого медиа, относящегося к текущему цвету (фильтр по colorCode).
|
||||
• clr_imgs_s = все такие URL (удаляем дубли, порядок сохраняем).
|
||||
• clr_image (превью цвета) — выбираем единственный кадр из тех же медиа по правилу:
|
||||
• смотрим только медиа с clazz == 3 (ракурс R),
|
||||
• среди них приоритет кадрам, у которых deliveryUrl содержит "-r1/", если таких нет — берём первый с "-r/",
|
||||
• если вообще нет clazz == 3 — фолбэк: превью из clr["image"]["url"] + "_3_1_0.jpg".
|
||||
5. Фильтрация дублей (DEL_SAME)
|
||||
o Опциональная логика (включается флагом DEL_SAME = "YES"):
|
||||
• Сначала группируем позиции по ключу: (article, name, clr_name, size_full, price).
|
||||
• Если в группе одна позиция — оставляем.
|
||||
• Если несколько:
|
||||
1. Если все partnumber одинаковые — оставляем любую одну (остальные выкидываем).
|
||||
2. Если partnumber различаются:
|
||||
• Сначала смотрим visibilityValue:
|
||||
• если в группе есть SHOW и нет противоречий по «короткому артикулу» (см. ниже) — оставляем только SHOW.
|
||||
• если vis у всех одинаковый → идём к проверке «короткого артикула».
|
||||
• «Короткий артикул» vs PartNumber: сравниваем article[:4] и partnumber[1:5].
|
||||
• если совпадают — оставляем только те, у кого совпали 4 символа;
|
||||
• если не совпадают — удаляем такой элемент как «лишний вариант».
|
||||
• В итоге из каждой группы остаётся максимум 1 строка.
|
||||
o Вся фильтрация выполняется в памяти, уже после сбора всех строк по категории, до записи в XLSX/JSON. Это резко ускорило работу (запись — один раз в конце).
|
||||
6. Запись в XLSX
|
||||
o Формируем шапку и строки в точной последовательности, как ожидает ваш шаблон.
|
||||
o Имя файла — хвост URL категории, очищенный от запрещённых символов.
|
||||
7. Формирование JSON и отправка POST
|
||||
o Для каждой строки сначала проверяем «ценовой фильтр»:
|
||||
• MIN_PRICE <= cost <= MAX_PRICE (включительно) — только такие попадают в JSON/POST.
|
||||
o Формируем объект:
|
||||
o {
|
||||
o "items": [
|
||||
o {
|
||||
o "category": { "name": "<очищенный путь без 'Каталог/ZaraHome/'>" },
|
||||
o "variant": {
|
||||
o "status_id": 1,
|
||||
o "color": "<ClrName, Первая Заглавная>",
|
||||
o "sku": "<article>-<partnumber>",
|
||||
o "size": "<size_full с <br/>>",
|
||||
o "cost": <price округлён вверх до целого>,
|
||||
o "originalUrl": "<url_full>",
|
||||
o "originalName": "<Name, Первая заглавная, остальное как есть>",
|
||||
o "originalDescription": "<longDescription + care + 'Страна изготовления ...' склеены через <br/>>",
|
||||
o "originalComposition": "<comp_txt с <br/>>",
|
||||
o "images": ["<список clr_imgs_s>"],
|
||||
o "inStock": <true если vis == 'SHOW'>,
|
||||
o "weight": <вес кг, ceil(граммы/1000)>
|
||||
o },
|
||||
o "brand": { "name": "zara-home" }
|
||||
o }
|
||||
o ],
|
||||
o "parserName": "zara-home"
|
||||
o }
|
||||
o Нормализация category.name: из cat_path убираем префикс Каталог/ZaraHome/, затем заменяем все спецсимволы, кроме / и -, на _ (регулярка по сути [^\\w\\/-]+ → _).
|
||||
o Все \n в size_full и comp_txt заменяем на <br/>.
|
||||
o originalDescription — это longDescription, затем care (каждую строку), затем наша строка Страна изготовления ... — склеиваем через <br/>, все \n → <br/>.
|
||||
o Отправка: POST на http://localhost:3005/parser/data с JSON-телом (если SEND_JSON=1).
|
||||
o Сохранение локально: если SAVE_JSON=1, пишем JSON-файл рядом (имя файла включает timestamp и ключ категории; расширение .json).
|
||||
o Если цена вне диапазона — позиция остаётся только в XLSX (в JSON не попадает).
|
||||
8. Исключение по материалам (exclusion_materials.txt)
|
||||
o Путь: в корне проекта (рядом с кодом), имя exclusion_materials.txt.
|
||||
o Формат: строки вида "glass","kniv","knif"," steel ", запятая — разделитель, кавычки обязательны (пробелы внутри кавычек учитываются).
|
||||
o Для каждого товара берём только поле comp_txt и приводим к одному регистру.
|
||||
o Если вхождение любой строки из словаря встречается как подстрока (без требований к границам слова), товар исключается из JSON/POST (из XLSX не исключаем).
|
||||
o Сравнение регистронезависимое.
|
||||
o Если файл отсутствует — считаем список исключений пустым.
|
||||
|
||||
Обработка картинок (детально)
|
||||
• Источник — данные варианта (конкретного colorCode) из detail.xmedia.
|
||||
• Мы больше не конструируем пути из idMedia + "2.jpg" и не используем mediaLocations для ссылок.
|
||||
• Берём «как есть» extraInfo.deliveryUrl (это уже абсолютный CDN-URL), причём:
|
||||
o Для списка Изображения варианта (clr_imgs_s) — все deliveryUrl для текущего цвета, кроме clazz == 3 (ракурсы r/r1 отфильтрованы).
|
||||
o Для clr_image — выбираем один кадр из clazz == 3 по приоритету -r1/ → -r/ (если нет — фолбэк к превью …_3_1_0.jpg из clr['image']['url']).
|
||||
• Дубликаты удаляем, порядок — как в исходных массивах.
|
||||
(Если когда-нибудь понадобится вернуть сортировку по xmediaLocations и/или учитывать только location == 0, это можно включить отдельно: у нас уже были патчи, но на текущей ревизии выбрана стратегия “все deliveryUrl текущего цвета, минус clazz==3 для списка и с отдельным выбором превью”).
|
||||
|
||||
Логирование
|
||||
• Единый логер с форматированием времени/уровня/сообщения.
|
||||
• LOG_LEVEL управляет подробностью.
|
||||
• В DEBUG видно:
|
||||
o Запуски категорий/количество продуктов,
|
||||
o HTTP запросы (метод/код/URL),
|
||||
o Ключевые точки преобразований (при необходимости можно включать точечные log.debug() в «узких местах» — выбор clr_image, выкидывание дублей, отбор по цене и т.п.).
|
||||
|
||||
Конфигурация/переключатели
|
||||
• DEL_SAME = "YES"|"NO" — включение/выключение продвинутой фильтрации дублей (по (article, name, clr_name, size_full, price, partnumber, vis)).
|
||||
• MIN_PRICE, MAX_PRICE — границы цены для попадания в JSON/POST (включительные).
|
||||
• SEND_JSON / SAVE_JSON — 0/1, можно передать через env или (если так реализовано у вас в main.py) спросить у пользователя интерактивно при запуске, когда переменные не заданы.
|
||||
• Путь к exclusion_materials.txt — корень проекта.
|
||||
|
||||
Производительность
|
||||
• Сбор всей категории в память, затем фильтрации (дубли/исключения/ценовой фильтр), затем единичная запись XLSX/JSON → резко быстрее, чем писать строки по одной.
|
||||
• API пачками по productIds (сбалансированный размер пачки).
|
||||
• Минимум повторных запросов на одну карточку.
|
||||
|
||||
Отказоустойчивость/краевые случаи
|
||||
• Если не нашли category_id в serverApp-state — категория пропускается (логируется ошибка).
|
||||
• Если products пуст или productIds пуст — логируем: «no products in this category».
|
||||
• Если по размеру отсутствует sku/partnumber — пишем пустые строки (а sku в JSON формируем аккуратно, чтобы не получить "-").
|
||||
• Если deliveryUrl отсутствует — картинка цвета уходит в фолбэк (см. выше), список картинок может быть пуст.
|
||||
• Если нет ни sectionNameEN/familyName/subFamilyName — cat_path укорачивается до того, что есть (или пусто), а category.name в JSON корректно нормализуется.
|
||||
• Если comp_txt пустой, модуль исключений просто ничего не найдёт.
|
||||
• Если хост localhost:3005 недоступен — логируем ошибку POST; XLSX и локальные JSON (при SAVE_JSON=1) при этом остаются.
|
||||
|
||||
Что «самое важное» мы поменяли в 2.0
|
||||
1. Развели сбор картинок: список картинок варианта (clr_imgs_s) теперь из deliveryUrl, а не из конкатенации idMedia + "2.jpg".
|
||||
2. Выбор превью цвета (clr_image) — строго из clazz == 3 с приоритетом -r1/ → -r/ (вместо «случайных» кропов).
|
||||
3. Фильтрация дублей вынесена до записи (по группам, как описано выше) → существенный выигрыш по скорости и стабильности размера XLSX.
|
||||
4. Ценовой фильтр и исключения материалов влияют только на JSON/POST; XLSX остаётся полной «сырым» логом категории.
|
||||
5. Нормализация category.name для JSON (без «Каталог/ZaraHome/» и без мусорных символов).
|
||||
6. Логи — вместо print, с уровнем подробности через LOG_LEVEL.
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
# xlsx_recorder.py · расширен отправкой JSON
|
||||
from openpyxl import Workbook
|
||||
from os.path import isdir, abspath, join
|
||||
from os import mkdir
|
||||
import re, json, math, logging, requests, os
|
||||
|
||||
log = logging.getLogger("recorder")
|
||||
|
||||
# ── интерактивный выбор режима ──────────────────────────────
|
||||
def ask(flag_name: str, default: str = "1") -> bool:
|
||||
"""Спрашивает 0/1 в консоли; пустой ввод → default."""
|
||||
try:
|
||||
val = input(f"{flag_name} (1 – yes, 0 – no) [{default}]: ").strip() or default
|
||||
except EOFError: # если запустили без tty (cron)
|
||||
val = default
|
||||
return val == "1"
|
||||
|
||||
SEND_JSON = ask("SEND_JSON") # 1 или 0
|
||||
SAVE_JSON = ask("SAVE_JSON") # 1 или 0
|
||||
|
||||
|
||||
POST_URL = "http://localhost:3005/parser/data"
|
||||
|
||||
MIN_PRICE = 40 # включительно
|
||||
MAX_PRICE = 1200 # включительно
|
||||
INVALID_CHARS = r'[<>:"/\\|*?]'
|
||||
|
||||
def sanitize_filename(name: str, repl: str = "_") -> str:
|
||||
"""Удаляет/заменяет символы, запрещённые в именах файлов."""
|
||||
clean = re.sub(INVALID_CHARS, repl, name)
|
||||
return clean.split("?", 1)[0].strip()
|
||||
|
||||
# объект для сохранения спаршенных категорий
|
||||
class Recorder:
|
||||
def __init__(self, records_folder="records_folder"):
|
||||
# создаём папку при необходимости
|
||||
rf_abs = abspath(records_folder)
|
||||
if not isdir(rf_abs):
|
||||
mkdir(rf_abs)
|
||||
self.record_folder = rf_abs
|
||||
|
||||
# запись таблицы + JSON/POST
|
||||
def record(self, csv_name, table):
|
||||
csv_name = sanitize_filename(csv_name)
|
||||
|
||||
# ─── 1) сохраняем XLSX ─────────────────────────────
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
for row in table:
|
||||
ws.append(row)
|
||||
xlsx_path = join(self.record_folder, f"{csv_name}.xlsx")
|
||||
wb.save(xlsx_path)
|
||||
log.info("XLSX saved → %s", xlsx_path)
|
||||
|
||||
# ─── 2) формируем JSON (по утверждённым правилам) ──
|
||||
headers = table[0]
|
||||
idx = {h: i for i, h in enumerate(headers)}
|
||||
|
||||
items = []
|
||||
for row in table[1:]:
|
||||
# базовые поля
|
||||
article = row[idx["Артикул"]]
|
||||
partnumber = row[idx["PartNumber"]]
|
||||
size_full = row[idx["Свойство: Размер"]].replace("\n", "<br/>")
|
||||
price_raw = row[idx["Цена закупки"]]
|
||||
price_int = math.ceil(float(price_raw))
|
||||
clr_name_raw = row[idx["Свойство: Цвет"]]
|
||||
clr_name = clr_name_raw.capitalize()
|
||||
vis = row[idx["Наличие на сайте"]]
|
||||
weight_gram = float(row[idx["Свойство: Вес(г)"]]) if row[idx["Свойство: Вес(г)"]] else 0.0
|
||||
weight_kg = math.ceil(weight_gram / 1000) if weight_gram else 0
|
||||
|
||||
# --- фильтр по цене ---
|
||||
if not (MIN_PRICE <= price_int <= MAX_PRICE):
|
||||
# товар вне диапазона -> отправится только в .xlsx
|
||||
continue
|
||||
|
||||
# составляем объект
|
||||
variant = {
|
||||
"status_id": 1,
|
||||
"color": clr_name,
|
||||
"sku": f"{article}-{partnumber}",
|
||||
"size": size_full,
|
||||
"cost": price_int,
|
||||
"originalUrl": row[idx["Краткое описание"]], # url_full в таблице
|
||||
"originalName": row[idx["Название товара или услуги"]].capitalize(),
|
||||
"originalDescription": (
|
||||
row[idx["Полное описание"]].replace("\n", "<br/>") + "<br/>" +
|
||||
row[idx["Параметр: Уход"]].replace("\n", "<br/>") + "<br/>" +
|
||||
row[idx["Параметр: Происхождение"]].replace("\n", "<br/>")
|
||||
).strip("<br/>"),
|
||||
"originalComposition": row[idx["Параметр: Состав"]].replace("\n", "<br/>"),
|
||||
"images": [img for img in row[idx["Изображения варианта"]].split("\n") if img],
|
||||
"inStock": vis == "SHOW",
|
||||
"weight": weight_kg
|
||||
}
|
||||
|
||||
cat_raw = row[idx["Размещение на сайте"]].replace("Каталог/ZaraHome/WOMEN/", "")
|
||||
category_name = re.sub(r"[^\w/-]+|_+", "_", cat_raw)
|
||||
|
||||
items.append({
|
||||
"category": {"name": category_name},
|
||||
"variant": variant,
|
||||
"brand": {"name": "zara-home"}
|
||||
})
|
||||
|
||||
payload = {"items": items, "parserName": "zara-home"}
|
||||
|
||||
# ─── 3) сохраняем JSON при необходимости ───────────
|
||||
if SAVE_JSON:
|
||||
json_path = join(self.record_folder, f"{csv_name}.json")
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||
log.info("JSON saved → %s", json_path)
|
||||
|
||||
# ─── 4) POST на локальный сервис ───────────────────
|
||||
if SEND_JSON:
|
||||
try:
|
||||
resp = requests.post(POST_URL, json=payload, timeout=20)
|
||||
resp.raise_for_status()
|
||||
log.info("POST %s OK (%s items)", csv_name, len(items))
|
||||
except Exception as err:
|
||||
log.warning("POST %s FAILED: %s", csv_name, err)
|
||||
@ -1,49 +1,63 @@
|
||||
# xlsx_recorder.py · расширен отправкой JSON
|
||||
# xlsx_recorder.py · 2025-07-24
|
||||
from openpyxl import Workbook
|
||||
from os.path import isdir, abspath, join
|
||||
from os import mkdir
|
||||
import re, json, math, logging, requests, os
|
||||
import re, json, math, logging, requests, os, sys, pathlib
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger("recorder")
|
||||
|
||||
# ── интерактивный выбор режима ──────────────────────────────
|
||||
def ask(flag_name: str, default: str = "1") -> bool:
|
||||
"""Спрашивает 0/1 в консоли; пустой ввод → default."""
|
||||
# ─────────────────────── интерактивные флаги ──────────────────────
|
||||
def ask(flag: str, default: str = "1") -> bool:
|
||||
"""Спрашивает 0/1; пустой ввод → default."""
|
||||
try:
|
||||
val = input(f"{flag_name} (1 – yes, 0 – no) [{default}]: ").strip() or default
|
||||
except EOFError: # если запустили без tty (cron)
|
||||
val = input(f"{flag} (1=yes, 0=no) [{default}]: ").strip() or default
|
||||
except EOFError:
|
||||
val = default
|
||||
return val == "1"
|
||||
|
||||
SEND_JSON = ask("SEND_JSON") # 1 или 0
|
||||
SAVE_JSON = ask("SAVE_JSON") # 1 или 0
|
||||
SEND_JSON = ask("SEND_JSON") # отправка POST
|
||||
SAVE_JSON = ask("SAVE_JSON") # сохранять копию JSON
|
||||
|
||||
# ─────────────────────── прочие настройки ─────────────────────────
|
||||
POST_URL = "http://localhost:3005/parser/data"
|
||||
MIN_PRICE = 40
|
||||
MAX_PRICE = 1200
|
||||
EXCLUSION_FILE = Path(__file__).with_name("exclusion_materials.txt") # в том же каталоге
|
||||
|
||||
POST_URL = "http://localhost:3005/parser/data"
|
||||
|
||||
MIN_PRICE = 40 # включительно
|
||||
MAX_PRICE = 1200 # включительно
|
||||
INVALID_CHARS = r'[<>:"/\\|*?]'
|
||||
INVALID_FILE_CHARS = r'[<>:"/\\|*?]' # для имён файлов Windows
|
||||
|
||||
def sanitize_filename(name: str, repl: str = "_") -> str:
|
||||
"""Удаляет/заменяет символы, запрещённые в именах файлов."""
|
||||
clean = re.sub(INVALID_CHARS, repl, name)
|
||||
clean = re.sub(INVALID_FILE_CHARS, repl, name)
|
||||
return clean.split("?", 1)[0].strip()
|
||||
|
||||
# объект для сохранения спаршенных категорий
|
||||
# ────────────────────────── Recorder ──────────────────────────────
|
||||
class Recorder:
|
||||
def __init__(self, records_folder="records_folder"):
|
||||
# создаём папку при необходимости
|
||||
rf_abs = abspath(records_folder)
|
||||
if not isdir(rf_abs):
|
||||
mkdir(rf_abs)
|
||||
self.record_folder = rf_abs
|
||||
self.forbidden = self._load_forbidden()
|
||||
|
||||
# запись таблицы + JSON/POST
|
||||
# ---------- загрузка словаря исключений ----------
|
||||
def _load_forbidden(self):
|
||||
path = pathlib.Path(EXCLUSION_FILE)
|
||||
if not path.is_file():
|
||||
log.warning("Exclusion file not found: %s (filter disabled)", path)
|
||||
return set()
|
||||
txt = path.read_text(encoding="utf-8")
|
||||
# ищем элементы в кавычках или делим по запятой
|
||||
tokens = re.findall(r'"([^"]+)"', txt) or [t.strip() for t in txt.split(",")]
|
||||
cleaned = {t.lower() for t in tokens if t}
|
||||
log.info("Loaded %s exclusion tokens", len(cleaned))
|
||||
return cleaned
|
||||
|
||||
# ---------------- запись таблицы + JSON ----------------
|
||||
def record(self, csv_name, table):
|
||||
csv_name = sanitize_filename(csv_name)
|
||||
|
||||
# ─── 1) сохраняем XLSX ─────────────────────────────
|
||||
# 1) сохраняем XLSX
|
||||
wb = Workbook()
|
||||
ws = wb.active
|
||||
for row in table:
|
||||
@ -52,52 +66,60 @@ class Recorder:
|
||||
wb.save(xlsx_path)
|
||||
log.info("XLSX saved → %s", xlsx_path)
|
||||
|
||||
# ─── 2) формируем JSON (по утверждённым правилам) ──
|
||||
# индексы столбцов
|
||||
headers = table[0]
|
||||
idx = {h: i for i, h in enumerate(headers)}
|
||||
|
||||
items = []
|
||||
for row in table[1:]:
|
||||
# базовые поля
|
||||
article = row[idx["Артикул"]]
|
||||
partnumber = row[idx["PartNumber"]]
|
||||
size_full = row[idx["Свойство: Размер"]].replace("\n", "<br/>")
|
||||
price_raw = row[idx["Цена закупки"]]
|
||||
price_int = math.ceil(float(price_raw))
|
||||
clr_name_raw = row[idx["Свойство: Цвет"]]
|
||||
clr_name = clr_name_raw.capitalize()
|
||||
vis = row[idx["Наличие на сайте"]]
|
||||
weight_gram = float(row[idx["Свойство: Вес(г)"]]) if row[idx["Свойство: Вес(г)"]] else 0.0
|
||||
weight_kg = math.ceil(weight_gram / 1000) if weight_gram else 0
|
||||
|
||||
# --- фильтр по цене ---
|
||||
# ----------- фильтр цены -----------
|
||||
price_raw = row[idx["Цена закупки"]]
|
||||
price_int = math.ceil(float(price_raw))
|
||||
if not (MIN_PRICE <= price_int <= MAX_PRICE):
|
||||
# товар вне диапазона -> отправится только в .xlsx
|
||||
continue
|
||||
|
||||
# составляем объект
|
||||
# ----------- фильтр по составу -------
|
||||
comp_txt = row[idx["Параметр: Состав"]].replace("\n", "<br/>")
|
||||
comp_low = comp_txt.lower()
|
||||
if any(tok in comp_low for tok in self.forbidden):
|
||||
log.debug("Skip by exclusion token: %s", row[idx["Артикул"]])
|
||||
continue
|
||||
|
||||
# ----------- формируем variant -------
|
||||
article = row[idx["Артикул"]]
|
||||
partnumber = row[idx["PartNumber"]]
|
||||
clr_name = row[idx["Свойство: Цвет"]].capitalize()
|
||||
size_full = row[idx["Свойство: Размер"]].replace("\n", "<br/>")
|
||||
vis = row[idx["Наличие на сайте"]]
|
||||
weight_g = float(row[idx["Свойство: Вес(г)"]]) if row[idx["Свойство: Вес(г)"]] else 0.0
|
||||
weight_kg = math.ceil(weight_g / 1000) if weight_g else 0
|
||||
url_full = row[idx["Краткое описание"]]
|
||||
name_orig = row[idx["Название товара или услуги"]].capitalize()
|
||||
desc_orig = (
|
||||
row[idx["Полное описание"]].replace("\n", "<br/>") + "<br/>" +
|
||||
row[idx["Параметр: Уход"]].replace("\n", "<br/>") + "<br/>" +
|
||||
row[idx["Параметр: Происхождение"]].replace("\n", "<br/>")
|
||||
).strip("<br/>")
|
||||
images = [img for img in row[idx["Изображения варианта"]].split("\n") if img]
|
||||
|
||||
cat_raw = row[idx["Размещение на сайте"]].replace("Каталог/ZaraHome/", "")
|
||||
category_name = re.sub(r"[^\w/-]+|_+", "_", cat_raw)
|
||||
|
||||
variant = {
|
||||
"status_id": 1,
|
||||
"color": clr_name,
|
||||
"sku": f"{article}-{partnumber}",
|
||||
"size": size_full,
|
||||
"cost": price_int,
|
||||
"originalUrl": row[idx["Краткое описание"]], # url_full в таблице
|
||||
"originalName": row[idx["Название товара или услуги"]].capitalize(),
|
||||
"originalDescription": (
|
||||
row[idx["Полное описание"]].replace("\n", "<br/>") + "<br/>" +
|
||||
row[idx["Параметр: Уход"]].replace("\n", "<br/>") + "<br/>" +
|
||||
row[idx["Параметр: Происхождение"]].replace("\n", "<br/>")
|
||||
).strip("<br/>"),
|
||||
"originalComposition": row[idx["Параметр: Состав"]].replace("\n", "<br/>"),
|
||||
"images": [img for img in row[idx["Изображения варианта"]].split("\n") if img],
|
||||
"originalUrl": url_full,
|
||||
"originalName": name_orig,
|
||||
"originalDescription": desc_orig,
|
||||
"originalComposition": comp_txt,
|
||||
"images": images,
|
||||
"inStock": vis == "SHOW",
|
||||
"weight": weight_kg
|
||||
}
|
||||
|
||||
cat_raw = row[idx["Размещение на сайте"]].replace("Каталог/ZaraHome/WOMEN/", "")
|
||||
category_name = re.sub(r"[^\w/-]+|_+", "_", cat_raw)
|
||||
|
||||
items.append({
|
||||
"category": {"name": category_name},
|
||||
"variant": variant,
|
||||
@ -106,14 +128,14 @@ class Recorder:
|
||||
|
||||
payload = {"items": items, "parserName": "zara-home"}
|
||||
|
||||
# ─── 3) сохраняем JSON при необходимости ───────────
|
||||
# 3) сохраняем JSON
|
||||
if SAVE_JSON:
|
||||
json_path = join(self.record_folder, f"{csv_name}.json")
|
||||
with open(json_path, "w", encoding="utf-8") as fh:
|
||||
json.dump(payload, fh, ensure_ascii=False, indent=2)
|
||||
log.info("JSON saved → %s", json_path)
|
||||
|
||||
# ─── 4) POST на локальный сервис ───────────────────
|
||||
# 4) отправляем POST
|
||||
if SEND_JSON:
|
||||
try:
|
||||
resp = requests.post(POST_URL, json=payload, timeout=20)
|
||||
|
||||
@ -1,30 +1,126 @@
|
||||
import pandas as pd
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
# Укажите путь к папке, где находятся все файлы Excel
|
||||
folder_path = '/Users/valis/Yandex.Disk.localized/Python3/Parsing ZARAHOME/src_2024-09-05/records_folder'
|
||||
# Укажите путь и имя нового файла Excel, в который будут скопированы все строки
|
||||
output_file = os.path.join(folder_path, 'Allfile.xlsx')
|
||||
# === Вспомогательное ===
|
||||
def get_script_dir() -> str:
|
||||
try:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
except NameError:
|
||||
# на всякий случай для интерактивных сред
|
||||
return os.getcwd()
|
||||
|
||||
# Считываем все имена файлов в папке
|
||||
files = [f for f in os.listdir(folder_path) if f.endswith('.xlsx') or f.endswith('.xls')]
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
line = f'[{ts}] {msg}'
|
||||
print(line)
|
||||
try:
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(line + '\n')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Флаг для определения, является ли текущий файл первым (для записи заголовков)
|
||||
first_file = True
|
||||
def is_temp_or_hidden(name: str) -> bool:
|
||||
# временные Excel-файлы (~$...) и скрытые (.xxx)
|
||||
return name.startswith('~$') or name.startswith('.')
|
||||
|
||||
# Открываем файл Excel для записи
|
||||
with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
|
||||
for file in files:
|
||||
file_path = os.path.join(folder_path, file)
|
||||
# === Пути ===
|
||||
script_dir = get_script_dir()
|
||||
folder_path = os.path.join(script_dir, 'Files-todo')
|
||||
|
||||
# имя результата: All-todo-YYYYMMDD-HHMM.xlsx
|
||||
timestamp = datetime.now().strftime('%Y%m%d-%H%M')
|
||||
output_filename = f'All-todo-{timestamp}.xlsx'
|
||||
output_file = os.path.join(folder_path, output_filename)
|
||||
|
||||
# лог в той же папке
|
||||
log_file = os.path.join(folder_path, 'merge_log.txt')
|
||||
|
||||
def main():
|
||||
log('=== Старт объединения Excel-файлов ===')
|
||||
log(f'Папка: {folder_path}')
|
||||
|
||||
if not os.path.isdir(folder_path):
|
||||
log(f'ОШИБКА: Папка не найдена: {folder_path}')
|
||||
sys.exit(1)
|
||||
|
||||
# собираем только .xlsx, исключая временные/скрытые и любые All-todo-*.xlsx
|
||||
names = sorted(os.listdir(folder_path))
|
||||
files = []
|
||||
for name in names:
|
||||
if is_temp_or_hidden(name):
|
||||
continue
|
||||
if not name.lower().endswith('.xlsx'):
|
||||
continue
|
||||
if name.startswith('All-todo-'):
|
||||
continue
|
||||
full = os.path.join(folder_path, name)
|
||||
if os.path.isfile(full):
|
||||
files.append(full)
|
||||
|
||||
if not files:
|
||||
log('Нет входных .xlsx файлов для слияния. Завершение.')
|
||||
return
|
||||
|
||||
log(f'Найдено файлов: {len(files)}')
|
||||
|
||||
dfs = []
|
||||
all_columns = []
|
||||
seen_cols = set()
|
||||
processed = 0
|
||||
errors = 0
|
||||
|
||||
for i, path in enumerate(files, start=1):
|
||||
fname = os.path.basename(path)
|
||||
try:
|
||||
df = pd.read_excel(file_path, engine='openpyxl')
|
||||
|
||||
# Если это первый файл, записываем с заголовками
|
||||
if first_file:
|
||||
df.to_excel(writer, index=False, sheet_name="Sheet", startrow=0, header=True)
|
||||
first_file = False
|
||||
else:
|
||||
# Иначе записываем без заголовков, продолжая с последней строки
|
||||
df.to_excel(writer, index=False, sheet_name="Sheet", startrow=writer.sheets["Sheet"].max_row, header=False)
|
||||
df = pd.read_excel(path, engine='openpyxl')
|
||||
if df is None or df.empty:
|
||||
log(f'ПРЕДУПРЕЖДЕНИЕ: Пустой файл — пропуск: {fname}')
|
||||
continue
|
||||
|
||||
if not all_columns:
|
||||
for c in df.columns:
|
||||
if c not in seen_cols:
|
||||
all_columns.append(c)
|
||||
seen_cols.add(c)
|
||||
|
||||
df['SourceFile'] = fname
|
||||
dfs.append(df)
|
||||
processed += 1
|
||||
log(f'[{i}/{len(files)}] OK: {fname} — строк: {len(df)}')
|
||||
except Exception as e:
|
||||
print(f"Ошибка при обработке файла {file}: {e}")
|
||||
errors += 1
|
||||
log(f'[{i}/{len(files)}] ОШИБКА: {fname}: {e}')
|
||||
log(traceback.format_exc())
|
||||
|
||||
if not dfs:
|
||||
log('Все файлы пустые или не прочитаны — нечего сохранять.')
|
||||
return
|
||||
|
||||
combined = pd.concat(dfs, ignore_index=True)
|
||||
|
||||
# порядок колонок: сначала из первого удачного файла, потом прочие по алфавиту, затем SourceFile (если нужно)
|
||||
extra_cols = [c for c in combined.columns if c not in all_columns and c != 'SourceFile']
|
||||
extra_cols.sort()
|
||||
final_cols = all_columns + extra_cols
|
||||
if 'SourceFile' not in final_cols:
|
||||
final_cols.append('SourceFile')
|
||||
|
||||
combined = combined.reindex(columns=final_cols)
|
||||
|
||||
try:
|
||||
with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
|
||||
combined.to_excel(writer, index=False, sheet_name='Sheet')
|
||||
log(f'Готово: {output_file}')
|
||||
log(f'Итоговых строк: {len(combined)}')
|
||||
log(f'Успешно обработано файлов: {processed}, ошибок: {errors}')
|
||||
except Exception as e:
|
||||
log(f'ОШИБКА сохранения {output_file}: {e}')
|
||||
log(traceback.format_exc())
|
||||
|
||||
log('=== Готово ===')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
130
Processing/0_02_слияние_всех эксель файлов ZARAHOME.py
Normal file
130
Processing/0_02_слияние_всех эксель файлов ZARAHOME.py
Normal file
@ -0,0 +1,130 @@
|
||||
import os
|
||||
import sys
|
||||
import traceback
|
||||
import pandas as pd
|
||||
from datetime import datetime
|
||||
|
||||
# === Вспомогательное ===
|
||||
def get_script_dir() -> str:
|
||||
try:
|
||||
return os.path.dirname(os.path.abspath(__file__))
|
||||
except NameError:
|
||||
return os.getcwd()
|
||||
|
||||
def log(msg: str):
|
||||
ts = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
|
||||
line = f'[{ts}] {msg}'
|
||||
print(line)
|
||||
try:
|
||||
with open(log_file, 'a', encoding='utf-8') as f:
|
||||
f.write(line + '\n')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def is_temp_or_hidden(name: str) -> bool:
|
||||
return name.startswith('~$') or name.startswith('.')
|
||||
|
||||
# === Пути ===
|
||||
script_dir = get_script_dir()
|
||||
folder_path = os.path.join(script_dir, 'Files-todo')
|
||||
|
||||
timestamp = datetime.now().strftime('%Y%m%d-%H%M')
|
||||
output_filename = f'All-todo-{timestamp}.xlsx'
|
||||
output_file = os.path.join(folder_path, output_filename)
|
||||
|
||||
log_file = os.path.join(folder_path, 'merge_log.txt')
|
||||
|
||||
BASE_URL = "https://www.zarahome.com/pl/en/"
|
||||
|
||||
def main():
|
||||
log('=== Старт объединения Excel-файлов ===')
|
||||
log(f'Папка: {folder_path}')
|
||||
|
||||
if not os.path.isdir(folder_path):
|
||||
log(f'ОШИБКА: Папка не найдена: {folder_path}')
|
||||
sys.exit(1)
|
||||
|
||||
names = sorted(os.listdir(folder_path))
|
||||
files = []
|
||||
for name in names:
|
||||
if is_temp_or_hidden(name):
|
||||
continue
|
||||
if not name.lower().endswith('.xlsx'):
|
||||
continue
|
||||
if name.startswith('All-todo-'):
|
||||
continue
|
||||
full = os.path.join(folder_path, name)
|
||||
if os.path.isfile(full):
|
||||
files.append(full)
|
||||
|
||||
if not files:
|
||||
log('Нет входных .xlsx файлов для слияния. Завершение.')
|
||||
return
|
||||
|
||||
log(f'Найдено файлов: {len(files)}')
|
||||
|
||||
dfs = []
|
||||
all_columns = []
|
||||
seen_cols = set()
|
||||
processed = 0
|
||||
errors = 0
|
||||
|
||||
for i, path in enumerate(files, start=1):
|
||||
fname = os.path.basename(path)
|
||||
try:
|
||||
df = pd.read_excel(path, engine='openpyxl')
|
||||
if df is None or df.empty:
|
||||
log(f'ПРЕДУПРЕЖДЕНИЕ: Пустой файл — пропуск: {fname}')
|
||||
continue
|
||||
|
||||
if not all_columns:
|
||||
for c in df.columns:
|
||||
if c not in seen_cols:
|
||||
all_columns.append(c)
|
||||
seen_cols.add(c)
|
||||
|
||||
# Добавляем источники
|
||||
stem, _ = os.path.splitext(fname)
|
||||
df['SourceFile'] = fname
|
||||
df['SourceFileUrl'] = BASE_URL + stem
|
||||
|
||||
dfs.append(df)
|
||||
processed += 1
|
||||
log(f'[{i}/{len(files)}] OK: {fname} — строк: {len(df)}')
|
||||
except Exception as e:
|
||||
errors += 1
|
||||
log(f'[{i}/{len(files)}] ОШИБКА: {fname}: {e}')
|
||||
log(traceback.format_exc())
|
||||
|
||||
if not dfs:
|
||||
log('Все файлы пустые или не прочитаны — нечего сохранять.')
|
||||
return
|
||||
|
||||
combined = pd.concat(dfs, ignore_index=True)
|
||||
|
||||
# порядок колонок: сначала из первого удачного файла, потом прочие (кроме служебных) по алфавиту,
|
||||
# затем служебные SourceFile и SourceFileUrl в конце
|
||||
extra_cols = [c for c in combined.columns if c not in all_columns and c not in ('SourceFile', 'SourceFileUrl')]
|
||||
extra_cols.sort()
|
||||
final_cols = all_columns + extra_cols
|
||||
if 'SourceFile' not in final_cols:
|
||||
final_cols.append('SourceFile')
|
||||
if 'SourceFileUrl' not in final_cols:
|
||||
final_cols.append('SourceFileUrl')
|
||||
|
||||
combined = combined.reindex(columns=final_cols)
|
||||
|
||||
try:
|
||||
with pd.ExcelWriter(output_file, engine='openpyxl') as writer:
|
||||
combined.to_excel(writer, index=False, sheet_name='Sheet')
|
||||
log(f'Готово: {output_file}')
|
||||
log(f'Итоговых строк: {len(combined)}')
|
||||
log(f'Успешно обработано файлов: {processed}, ошибок: {errors}')
|
||||
except Exception as e:
|
||||
log(f'ОШИБКА сохранения {output_file}: {e}')
|
||||
log(traceback.format_exc())
|
||||
|
||||
log('=== Готово ===')
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
BIN
Парсер_IKEA/ALL_ikea_products_ХЗ для чего просто так.xlsx
Normal file
BIN
Парсер_IKEA/ALL_ikea_products_ХЗ для чего просто так.xlsx
Normal file
Binary file not shown.
Binary file not shown.
@ -1 +1 @@
|
||||
https://www.ikea.com/pl/pl/cat/firanki-i-zaslony-10700/
|
||||
https://www.ikea.com/pl/pl/cat/poduszki-ergonomiczne-46083/
|
||||
Binary file not shown.
File diff suppressed because one or more lines are too long
@ -6,7 +6,7 @@ from bs4 import BeautifulSoup
|
||||
from openpyxl import Workbook
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
INPUT_FILE = os.path.join(BASE_DIR, "links.txt")
|
||||
INPUT_FILE = os.path.join(BASE_DIR, "product_links.txt")
|
||||
OUTPUT_FILE = os.path.join(BASE_DIR, "result.xlsx")
|
||||
|
||||
CSS_SELECTOR = ".pip-product__subgrid.product-pip.js-product-pip"
|
||||
|
||||
@ -1,115 +1,8 @@
|
||||
https://www.ikea.com/pl/pl/p/teresia-firanki-2-szt-bialy-na-tunelu-50232333/
|
||||
https://www.ikea.com/pl/pl/p/matilda-firanki-2-szt-bialy-na-szelkach-50046048/
|
||||
https://www.ikea.com/pl/pl/p/rosenrobinia-firanki-2-szt-bezowy-na-tasmie-30564856/
|
||||
https://www.ikea.com/pl/pl/p/maesterrot-zaslona-2-szt-bialy-bialy-kratka-na-tasmie-20602496/
|
||||
https://www.ikea.com/pl/pl/p/tibast-zaslony-zacieniajace-1-para-ciemnozielony-na-tasmie-70504592/
|
||||
https://www.ikea.com/pl/pl/p/haeggveckmal-zaslony-zacieniajace-1-para-ciemnoszary-na-tasmie-00562123/
|
||||
https://www.ikea.com/pl/pl/p/stockholm-2025-firanka-1-szt-kremowy-na-tasmie-90601002/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-bialy-na-tasmie-60597167/
|
||||
https://www.ikea.com/pl/pl/p/korgmott-zaslony-zaciemniajace-para-ciemnoszary-na-tasmie-30597159/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-ciemnoszary-na-tasmie-00591413/
|
||||
https://www.ikea.com/pl/pl/p/bruksvara-zaslona-zacieniajaca-1-szt-szary-na-tasmie-10574248/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zacieniajace-1-para-ciemny-szaro-turkusowy-na-tasmie-60586022/
|
||||
https://www.ikea.com/pl/pl/p/matilda-firanka-1-szt-bialy-na-szelkach-30570027/
|
||||
https://www.ikea.com/pl/pl/p/hilja-zaslona-2-szt-bialy-na-tasmie-50430818/
|
||||
https://www.ikea.com/pl/pl/p/tempelfly-zaslona-2-szt-bialy-czarny-na-tasmie-90607867/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanka-1-szt-bezowy-na-tasmie-70597713/
|
||||
https://www.ikea.com/pl/pl/p/sanddadra-firanki-2-szt-na-tunelu-bialy-90548972/
|
||||
https://www.ikea.com/pl/pl/p/bengta-zaslona-zaciemniajaca-1-szt-bezowy-na-tasmie-30602090/
|
||||
https://www.ikea.com/pl/pl/p/dytag-zaslona-2-szt-granatowy-na-tasmie-90552493/
|
||||
https://www.ikea.com/pl/pl/p/hildrun-firanki-2-szt-bialy-w-kropki-na-tunelu-00386549/
|
||||
https://www.ikea.com/pl/pl/p/marielena-zaslona-bialy-na-tunelu-00417428/
|
||||
https://www.ikea.com/pl/pl/p/annakajsa-zaslony-zacieniajace-1-para-jasnoszary-na-tasmie-60583429/
|
||||
https://www.ikea.com/pl/pl/p/rosenmandel-zaslony-zaciemniajace-para-szary-na-tasmie-00536247/
|
||||
https://www.ikea.com/pl/pl/p/korgmott-zaslony-zaciemniajace-para-bezowy-na-tasmie-50597158/
|
||||
https://www.ikea.com/pl/pl/p/bymott-zaslona-2-szt-bialy-jasnoszary-w-paski-na-tasmie-30466686/
|
||||
https://www.ikea.com/pl/pl/p/bengta-zaslona-zaciemniajaca-1-szt-zielony-na-tasmie-10602166/
|
||||
https://www.ikea.com/pl/pl/p/stockholm-2025-zaslona-2-szt-czarny-wielobarwny-na-tasmie-80601088/
|
||||
https://www.ikea.com/pl/pl/p/lenda-zaslona-z-wiazaniem-2-szt-ciemnoszary-na-tasmie-60552871/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-jasnobezowy-na-tasmie-60589879/
|
||||
https://www.ikea.com/pl/pl/p/palpljungmal-firanki-2-szt-bialy-bialy-kratka-na-tasmie-50607845/
|
||||
https://www.ikea.com/pl/pl/p/sotstaevmal-firanki-2-szt-bialy-na-tasmie-10548990/
|
||||
https://www.ikea.com/pl/pl/p/haellebraecka-firanki-2-szt-bialy-na-tasmie-70559674/
|
||||
https://www.ikea.com/pl/pl/p/annakajsa-zaslony-zacieniajace-1-para-bezowy-na-tasmie-30462792/
|
||||
https://www.ikea.com/pl/pl/p/dytag-zaslona-2-szt-bialy-na-tasmie-20466719/
|
||||
https://www.ikea.com/pl/pl/p/moalisa-zaslona-2-szt-bladorozowy-rozowy-na-tasmie-20499507/
|
||||
https://www.ikea.com/pl/pl/p/haellebraecka-firanki-2-szt-jasnobezowy-na-tasmie-00556848/
|
||||
https://www.ikea.com/pl/pl/p/korgmott-zaslony-zaciemniajace-para-bialy-na-tasmie-40597149/
|
||||
https://www.ikea.com/pl/pl/p/rosenmandel-zaslony-zaciemniajace-para-ciemnozielony-na-tasmie-70539068/
|
||||
https://www.ikea.com/pl/pl/p/silverloenn-firanki-2-szt-jasnoszary-na-tasmie-60583392/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanka-1-szt-bialy-na-tasmie-10597706/
|
||||
https://www.ikea.com/pl/pl/p/annakajsa-zaslony-zacieniajace-1-para-antracyt-na-tasmie-40583430/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zaciemniajace-para-szary-na-tasmie-80417815/
|
||||
https://www.ikea.com/pl/pl/p/gjertrud-firanki-2-szt-bialy-na-tasmie-30386538/
|
||||
https://www.ikea.com/pl/pl/p/alvine-spets-firanki-1-para-kremowy-na-tunelu-80070763/
|
||||
https://www.ikea.com/pl/pl/p/hilja-zaslona-2-szt-bialy-na-tasmie-40430814/
|
||||
https://www.ikea.com/pl/pl/p/tibast-zaslona-2-szt-niebieski-na-tasmie-50427420/
|
||||
https://www.ikea.com/pl/pl/p/slanhoestmal-zaslona-2-szt-bialy-czarny-paski-z-oczkami-20607842/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-zolty-na-tasmie-60597186/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-ciemny-szaro-turkusowy-na-tasmie-90589887/
|
||||
https://www.ikea.com/pl/pl/p/vilborg-zaslony-zacieniajace-1-para-rozowy-na-tasmie-50586013/
|
||||
https://www.ikea.com/pl/pl/p/dytag-zaslona-2-szt-len-na-tasmie-80607820/
|
||||
https://www.ikea.com/pl/pl/p/trystaevmal-zaslona-2-szt-bezowy-bialy-wzor-kwiatowy-na-tasmie-00559700/
|
||||
https://www.ikea.com/pl/pl/p/alvine-spets-firanka-1-szt-bialy-na-tunelu-50559811/
|
||||
https://www.ikea.com/pl/pl/p/silverloenn-firanki-2-szt-bialy-na-tasmie-20491040/
|
||||
https://www.ikea.com/pl/pl/p/foensterbomal-magnetyczna-opaska-do-zaslon-bezowy-70498553/
|
||||
https://www.ikea.com/pl/pl/p/haeggveckmal-zaslony-zacieniajace-1-para-ciemnozielony-na-tasmie-00569110/
|
||||
https://www.ikea.com/pl/pl/p/lenda-zaslona-z-wiazaniem-2-szt-jasny-szarozielony-na-tasmie-90559197/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanki-2-szt-zielony-na-tasmie-50594904/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zacieniajace-1-para-jasnoszary-na-tasmie-30346750/
|
||||
https://www.ikea.com/pl/pl/p/trixig-39-elementowy-zestaw-do-szycia-30578250/
|
||||
https://www.ikea.com/pl/pl/p/maesterrot-zaslona-2-szt-bezowy-bialy-wzor-w-kropki-na-tasmie-00602567/
|
||||
https://www.ikea.com/pl/pl/p/annakajsa-zaslony-zacieniajace-1-para-ciemnozielony-na-tasmie-70586074/
|
||||
https://www.ikea.com/pl/pl/p/gjertrud-firanka-1-szt-bialy-na-tasmie-60558948/
|
||||
https://www.ikea.com/pl/pl/p/tibast-zaslona-2-szt-bialy-na-tasmie-10396760/
|
||||
https://www.ikea.com/pl/pl/p/haeggveckmal-zaslony-zacieniajace-1-para-bezowy-na-tasmie-20569029/
|
||||
https://www.ikea.com/pl/pl/p/loennstaevmal-zaslony-zaciemniajace-para-bezowy-na-tasmie-70556374/
|
||||
https://www.ikea.com/pl/pl/p/loennstaevmal-zaslony-zaciemniajace-para-jasnooliwkowy-na-tasmie-80556335/
|
||||
https://www.ikea.com/pl/pl/p/bergklematis-magnetyczna-opaska-do-zaslon-szary-90498552/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zaciemniajace-para-ciemnozielony-na-tasmie-30586033/
|
||||
https://www.ikea.com/pl/pl/p/trystaevmal-zaslona-2-szt-zielony-bialy-wzor-kwiatowy-na-tasmie-20559704/
|
||||
https://www.ikea.com/pl/pl/p/bymott-zaslona-2-szt-bialy-bezowy-w-paski-na-tasmie-80509971/
|
||||
https://www.ikea.com/pl/pl/p/silverloenn-firanki-2-szt-rozowy-na-tasmie-70583396/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanki-2-szt-rozowy-na-tasmie-00594893/
|
||||
https://www.ikea.com/pl/pl/p/glesgroee-firanki-2-szt-szary-na-tasmie-30548989/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-bezowy-z-oczkami-70605416/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zacieniajace-1-para-jasnoszary-na-tasmie-90346752/
|
||||
https://www.ikea.com/pl/pl/p/vilborg-zaslony-zacieniajace-1-para-bezowy-na-tasmie-50297555/
|
||||
https://www.ikea.com/pl/pl/p/lenda-zaslona-z-wiazaniem-2-szt-kremowy-na-tasmie-50552881/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanka-1-szt-rozowy-na-tasmie-30597729/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-bialy-z-oczkami-30605418/
|
||||
https://www.ikea.com/pl/pl/p/aengsfryle-firanka-1-szt-bialy-na-tasmie-70569220/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-bezowy-na-tasmie-40597173/
|
||||
https://www.ikea.com/pl/pl/p/hilja-zaslona-2-szt-szary-na-tasmie-90390735/
|
||||
https://www.ikea.com/pl/pl/p/trystaevmal-zaslona-2-szt-granatowy-bialy-wzor-kwiatowy-na-tasmie-60556505/
|
||||
https://www.ikea.com/pl/pl/p/rosenmandel-zaslony-zacieniajace-1-para-granatowy-na-tasmie-60539064/
|
||||
https://www.ikea.com/pl/pl/p/teresia-firanki-2-szt-bialy-na-tunelu-90232331/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-granatowy-na-tasmie-80597190/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanki-2-szt-turkusowy-na-tasmie-70594899/
|
||||
https://www.ikea.com/pl/pl/p/loennstaevmal-zaslony-zaciemniajace-para-jasny-czerwono-brazowy-na-tasmie-50556370/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zaciemniajace-para-szary-na-tasmie-50417812/
|
||||
https://www.ikea.com/pl/pl/p/merete-zaslony-zacieniajace-1-para-bialy-z-oczkami-90046843/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-kremowy-na-tasmie-20589876/
|
||||
https://www.ikea.com/pl/pl/p/silverloenn-firanki-2-szt-bezowy-na-tasmie-10493978/
|
||||
https://www.ikea.com/pl/pl/p/stockholm-2025-zaslona-2-szt-bialy-czarny-na-tasmie-10601096/
|
||||
https://www.ikea.com/pl/pl/p/bruksvara-zaslona-zacieniajaca-1-szt-jasnoniebieski-na-tasmie-50574171/
|
||||
https://www.ikea.com/pl/pl/p/vildpersilja-zaslona-2-szt-bialy-wielobarwny-wzor-kwiatowy-na-tasmie-60573345/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-bladorozowy-na-tasmie-90589892/
|
||||
https://www.ikea.com/pl/pl/p/sy-tasma-do-podklejania-firanek-50078746/
|
||||
https://www.ikea.com/pl/pl/p/lenda-zaslona-z-wiazaniem-2-szt-brazowoczerwony-na-tasmie-30559195/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-bezowozolty-na-tasmie-60589884/
|
||||
https://www.ikea.com/pl/pl/p/rosenrobinia-firanki-2-szt-bialy-na-tasmie-20556324/
|
||||
https://www.ikea.com/pl/pl/p/tibast-zaslona-2-szt-bezowy-na-tasmie-20396745/
|
||||
https://www.ikea.com/pl/pl/p/tibast-zaslony-zacieniajace-1-para-ciemnoczerwony-na-tasmie-50466666/
|
||||
https://www.ikea.com/pl/pl/p/lillyana-firanki-2-szt-bialy-kwiat-na-tunelu-30386524/
|
||||
https://www.ikea.com/pl/pl/p/lill-firanki-1-para-bialy-na-tunelu-10070262/
|
||||
https://www.ikea.com/pl/pl/p/majgull-zaslony-zacieniajace-1-para-bezowozolty-na-tasmie-70586026/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanka-1-szt-turkusowy-na-tasmie-90597726/
|
||||
https://www.ikea.com/pl/pl/p/sanela-zaslona-2-szt-granatowy-na-tasmie-00591408/
|
||||
https://www.ikea.com/pl/pl/p/sandsvingel-zaslona-2-szt-szary-na-tasmie-50532261/
|
||||
https://www.ikea.com/pl/pl/p/ginstmott-zaslona-2-szt-rozowy-na-tasmie-50597182/
|
||||
https://www.ikea.com/pl/pl/p/dytag-zaslona-2-szt-szarozielony-na-tasmie-80552889/
|
||||
https://www.ikea.com/pl/pl/p/fjaedermott-zaslona-2-szt-bialy-szary-na-tasmie-70504587/
|
||||
https://www.ikea.com/pl/pl/p/stenfroe-firanki-2-szt-granatowy-na-tasmie-20594910/
|
||||
https://www.ikea.com/pl/pl/p/rosenmandel-zaslony-zacieniajace-1-para-zoltobezowy-na-tasmie-80539077/
|
||||
https://www.ikea.com/pl/pl/p/vilborg-zaslony-zacieniajace-1-para-szary-na-tasmie-90423415/
|
||||
https://www.ikea.com/pl/pl/p/klubbsporre-poduszka-ergonomiczna-sen-bok-plecy-00446096/
|
||||
https://www.ikea.com/pl/pl/p/rosenskaerm-poduszka-ergonomiczna-sen-bok-plecy-90444366/
|
||||
https://www.ikea.com/pl/pl/p/styltmal-poduszka-ergonomiczna-multi-bialy-90518084/
|
||||
https://www.ikea.com/pl/pl/p/isranunkel-poduszka-ergonomiczna-multi-00576733/
|
||||
https://www.ikea.com/pl/pl/p/kvarnven-poduszka-ergonomiczna-sen-bok-plecy-70507350/
|
||||
https://www.ikea.com/pl/pl/p/loekstamfly-poduszka-ergonomiczna-sen-bok-plecy-50596192/
|
||||
https://www.ikea.com/pl/pl/p/papegojbuske-poduszka-ergonomiczna-sen-bok-plecy-00552845/
|
||||
https://www.ikea.com/pl/pl/p/nordstaloert-poduszka-ergonomiczna-sen-bok-plecy-20596240/
|
||||
Binary file not shown.
BIN
Парсер_IKEA/~$result.xlsx
Normal file
BIN
Парсер_IKEA/~$result.xlsx
Normal file
Binary file not shown.
Loading…
Reference in New Issue
Block a user