ZARAHOME 2.0

This commit is contained in:
va1is 2025-08-15 11:36:08 +03:00
parent c6e684c830
commit a2ea206e32
19 changed files with 690 additions and 214 deletions

3
.gitignore vendored
View File

@ -29,4 +29,5 @@ Temporary Items
__pycache__
/Parsing ZARAHOME/src/records_folder
Ignore_Temp
Ignore_Temp
/Processing/Files-todo

Binary file not shown.

View File

@ -0,0 +1 @@
"glass", "ceramic","stoneware","earthware","borosilicate","knif","kniv","marble","stone","porcelain"

View File

@ -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,

View File

@ -1,3 +1,6 @@
# ZARAHOME 2.0
from categories import get_categories
from xlsx_recorder import Recorder
from requester import get_parser

View 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 Размер пачки ~2050 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.

View File

@ -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)

View File

@ -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)

View File

@ -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()

View 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()

View File

@ -1 +1 @@
https://www.ikea.com/pl/pl/cat/firanki-i-zaslony-10700/
https://www.ikea.com/pl/pl/cat/poduszki-ergonomiczne-46083/

File diff suppressed because one or more lines are too long

View File

@ -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"

View File

@ -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.

Binary file not shown.