diff --git a/scrapers/PornoLab/pornolab.py b/scrapers/PornoLab/pornolab.py index d2a6a3dc4..fc48639db 100644 --- a/scrapers/PornoLab/pornolab.py +++ b/scrapers/PornoLab/pornolab.py @@ -1,5 +1,6 @@ import json import re +import random import sys from py_common import log from py_common.deps import ensure_requirements @@ -8,7 +9,7 @@ ensure_requirements("bs4:beautifulsoup4", "requests") -from bs4 import BeautifulSoup, Tag +from bs4 import BeautifulSoup, Tag, NavigableString import requests @@ -38,13 +39,19 @@ def get_field_text(self, tag, titles): for title in titles: if tag.text == title or tag.text == f"{title}:": value_text = "" - if tag.nextSibling: - value_text = tag.nextSibling.text + # Начально захватываем текст сразу после тега + if tag.next_sibling: + node = tag.next_sibling + value_text = node.get_text() if isinstance(node, Tag) else str(node) + else: + value_text = "" + # Собираем текст от следующих siblings, пока не встретим маркер нового поля for sibling in tag.find_next_siblings(): - if sibling.name == "span" and 'class' in sibling.attrs and "post-b" in sibling['class']: - break - if sibling.name == "span" and 'class' in sibling.attrs and "post-br" in sibling['class']: - break + if sibling.name == "span" and 'class' in sibling.attrs: + classes = sibling['class'] + # останавливаемся на следующем поле (post-b), на переносе (post-br) или на других разделительных блоках (post-u) + if any(c in classes for c in ("post-b", "post-br", "post-u")): + break value_text += sibling.text value_text = value_text.strip() if len(value_text) > 1 and value_text[0] == ':': @@ -65,103 +72,191 @@ def get_field_text(self, tag, titles): return "" def get_title(self): - post_bs = self.data.find_all('span', class_='post-b') - for post_b in post_bs: - title = self.get_field_text(post_b, ["Название ролика"]) - title = re.sub(r"\[.*?\]", "", title) - title = re.sub(r"\(.*?\)", "", title) - title = re.sub(r"/.*", "", title) - title = title.strip() - if title: - return title - - post_aligns = self.data.find_all('div', class_='post-align') - if post_aligns: - span = post_aligns[0].find('span') - if span: - title = span.text.split('\n')[0] - title = re.sub(r"\[.*?\]", "", title) - title = re.sub(r"\(.*?\)", "", title) - title = re.sub(r"/.*", "", title) - title = title.strip() - if title: - return title - else: - span = self.data.find('span') - title = span.text.strip() - title = re.sub(r"\[.*?\]", "", title) - title = re.sub(r"\(.*?\)", "", title) - #title = re.sub(r"/.*", "", title) - title = title.strip() - if title: - return title - - header_tag = self.soup.find('h1', class_='maintitle') - if header_tag: - title = header_tag.text - title = re.sub(r"\[.*?\]", "", title) + # Первый приоритет: + if self.soup.title and self.soup.title.string: + raw = self.soup.title.string + # отрезаем суффикс сайта + raw = re.sub(r"::.*$", "", raw) + # чистим от [..], (..) и всего после '/' или '|' + title = re.sub(r"\[.*?\]", "", raw) title = re.sub(r"\(.*?\)", "", title) - title = re.sub(r"/.*", "", title) + title = re.sub(r"[/|].*", "", title) title = title.strip() if title: return title +# # Второй приоритет: +# for post_b in self.data.find_all('span', class_='post-b'): +# title = self.get_field_text(post_b, ["Название ролика"]) +# # Очищаем от [..], (..), и всего после / +# title = re.sub(r"\[.*?\]", "", title) +# title = re.sub(r"\(.*?\)", "", title) +# title = re.sub(r"[/|].*", "", title) +# title = title.strip() +# if title: +# return title +# +# # Второй приоритет:
+# post_aligns = self.data.find_all('div', class_='post-align') +# if post_aligns: +# span = post_aligns[0].find('span') +# if span: +# title = span.text.split('\n')[0] +# title = re.sub(r"\[.*?\]", "", title) +# title = re.sub(r"\(.*?\)", "", title) +# title = re.sub(r"[/|].*", "", title) +# title = title.strip() +# if title: +# return title +# else: +# # Фолбэк: первый попавшийся +# span = self.data.find('span') +# if span: +# title = span.get_text(strip=True) +# title = re.sub(r"\[.*?\]", "", title) +# title = re.sub(r"\(.*?\)", "", title) +# title = re.sub(r"[/|].*", "", title) +# title = title.strip() +# if title: +# return title +# +# # Третий приоритет:

+# header_tag = self.soup.find('h1', class_='maintitle') +# if header_tag: +# title = header_tag.text +# title = re.sub(r"\[.*?\]", "", title) +# title = re.sub(r"\(.*?\)", "", title) +# title = re.sub(r"[/|].*", "", title) +# title = title.strip() +# if title: +# return title +# +# # Четвёртый фолбэк: Assent Of A Woman / … :: PornoLab.Net +# if self.soup.title and self.soup.title.string: +# title = self.soup.title.string +# # убираем суффикс сайта +# title = re.sub(r"::.*$", "", title) +# # чистим от [..], (..) и всего после '/' или '|' +# title = re.sub(r"\[.*?\]", "", title) +# title = re.sub(r"\(.*?\)", "", title) +# title = re.sub(r"[/|].*", "", title) +# title = title.strip() +# if title: +# return title +# return "" def get_details(self): - started = False - details = "" - for element in self.data.descendants: - if isinstance(element, Tag): - tag = element - if tag.name == "span" and 'class' in tag.attrs and "post-b" in tag['class']: - if tag.text.startswith("Описание"): - started = True - elif started: - break - if tag.name == "span" and 'class' in tag.attrs and "post-br" in tag['class']: - if started: - break - else: - if started: - if element.startswith("Описание"): - continue - details += element - - details = details.strip() - if len(details) > 0 and details[0] == ':': - details = details[1:].strip() - if details: - return details - - return "" + started = False + details = "" + for element in self.data.descendants: + if isinstance(element, Tag): + tag = element + # нашли маркер начала описания + if tag.name == "span" and 'class' in tag.attrs and "post-b" in tag['class']: + if tag.text.startswith("Описание"): + started = True + continue + # при встрече любого другого post-b после старта — выходим + elif started: + break + # игнорируем post-br (можно вставлять "\n" вместо них) + if tag.name == "span" and 'class' in tag.attrs and "post-br" in tag['class']: + if started: + details += "\n" + continue + else: + if started: + # пропускаем сам лейбл «Описание» + if element.startswith("Описание"): + continue + details += element + + details = details.strip() + if details.startswith(':'): + details = details[1:].strip() + return details if details else "" def get_date(self): - post_bs = self.data.find_all('span', class_='post-b') - for post_b in post_bs: - date = self.get_field_text(post_b, ("Год производства", "Год выпуска", "Охваченный временной промежуток")) - if date: - date = date.replace('г.', '').replace('г', '').replace('|', '').strip() - return date - + # 1) Основной способ: через get_field_text по span.post-b + titles = ("Год производства", "Год выпуска", "Охваченный временной промежуток") + for post_b in self.data.find_all('span', class_='post-b'): + raw = self.get_field_text(post_b, titles) + if raw: + # убираем «г.», «г» и «|» + clean = raw.replace('г.', '').replace('г', '').replace('|', '').strip() + m = re.search(r"(\d{4})", clean) + if m: + return f"{m.group(1)}-01-01" + + # 2) Фолбэк для структуры: + # Год выпуска: + # … + # 2012 + for wrapper in self.data.find_all('span', class_='post-color-text'): + nested = wrapper.find('span', class_='post-b') + if nested and nested.text in titles: + # ищем следующий span.post-color-text с цифрами + for sib in wrapper.find_next_siblings(): + if isinstance(sib, Tag) and 'post-color-text' in sib.get('class', []): + raw2 = sib.get_text(strip=True) + clean2 = raw2.replace('г.', '').replace('г', '').replace('|', '').strip() + m2 = re.search(r"(\d{4})", clean2) + if m2: + return f"{m2.group(1)}-01-01" + break + + # Если год так и не найден — возвращаем пустую строку return "" def get_duration(self): + # 1) Стандартный способ: через get_field_text post_bs = self.data.find_all('span', class_='post-b') for post_b in post_bs: duration = self.get_field_text(post_b, ["Продолжительность"]) if duration: - return duration - + return duration.strip().rstrip('.') + + # 2) Фолбэк: структура вида + # Продолжительность: + # 02:31:16. + for wrapper in self.data.find_all('span', class_='post-color-text'): + nested = wrapper.find('span', class_='post-b') + if nested and nested.text.strip() == "Продолжительность": + for sib in wrapper.find_next_siblings(): + if isinstance(sib, Tag) and 'post-color-text' in sib.get('class', []): + dur = sib.get_text(strip=True) + # убираем финальную точку, если есть + return dur.rstrip('.') + break + + # если не нашли ни одним способом — возвращаем пустую строку return "" def get_studio(self): + # 1) Стандартный способ: через get_field_text по post_bs = self.data.find_all('span', class_='post-b') for post_b in post_bs: - studio = self.get_field_text(post_b, ["Студия"]) + studio = self.get_field_text(post_b, ["Студия"]).lstrip(':').strip() if studio: - return ScrapedStudio(name=studio) - + return ScrapedStudio(name=studio.strip().rstrip('.')) + + # 2) Фолбэк: структура вида + # Студия: + # Sweet Sinner. + for wrapper in self.data.find_all('span', class_='post-color-text'): + nested = wrapper.find('span', class_='post-b') + if nested and nested.text.strip().startswith("Студия"): + # ищем следующий с именем студии + for sib in wrapper.find_next_siblings(): + if isinstance(sib, Tag) and 'post-color-text' in sib.get('class', []): + raw = sib.get_text().lstrip(':').strip() + name = raw.rstrip('.') + if name: + return ScrapedStudio(name=name) + break + + # ничего не найдено return "" def get_code(self): @@ -174,80 +269,229 @@ def get_code(self): return "" def get_director(self): - post_bs = self.data.find_all('span', class_='post-b') - for post_b in post_bs: - director = self.get_field_text(post_b, ["Режиссер"]) + # 1) Стандартный способ: через get_field_text по Режиссер + for post_b in self.data.find_all('span', class_='post-b'): + director = self.get_field_text(post_b, ["Режиссер"]).lstrip(':').strip() if director: - return director - + return director.rstrip('.') + + # 2) Фолбэк: ищем после Режиссер + for wrapper in self.data.find_all('span', class_='post-color-text'): + nested = wrapper.find('span', class_='post-b') + if nested and nested.text.strip() == "Режиссер": + for sib in wrapper.find_next_siblings(): + # пропускаем всё, что не post-color-text + if not (isinstance(sib, Tag) and 'post-color-text' in sib.get('class', [])): + continue + # если это новый лейбл — значит информации нет, выходим + if sib.find('span', class_='post-b'): + return "" + # иначе это значение режиссёра + raw = sib.get_text().lstrip(':').strip() + return raw.rstrip('.') + # после метки не нашлось ни одного значения — сразу пусто + return "" + + # ничего не нашли — возвращаем пустую строку return "" def get_urls(self): post_bs = self.data.find_all('span', class_='post-b') for post_b in post_bs: - url = self.get_field_text(post_b, ["Подсайт и сайт"]) - if url: - if not url.startswith("http"): - url = f"https://{url}" - return [url,] - - return "" + url_raw = self.get_field_text(post_b, ["Подсайт и сайт"]) + if not url_raw: + continue + # разбиваем по слешу (учитываем возможные пробелы вокруг) + parts = re.split(r"\s*/\s*", url_raw) + urls = [] + for part in parts: + u = part.strip() + if not u: + continue + # если нет протокола, добавляем https:// + if not re.match(r"^https?://", u, re.IGNORECASE): + u = f"https://{u}" + urls.append(u) + if urls: + return urls + # если не нашли ни одной — возвращаем пустой список + return [] def get_tags(self): scraped_tags = [] + # 1) Стандартный способ через get_field_text post_bs = self.data.find_all('span', class_='post-b') for post_b in post_bs: tags = self.get_field_text(post_b, ["Жанр"]) if tags: for tag in tags.split(','): - tag = tag.strip() - if tag: - scraped_tags.append(ScrapedTag(name=tag)) + t = tag.strip().rstrip('.') + if t: + scraped_tags.append(ScrapedTag(name=t)) + if scraped_tags: + return scraped_tags + + # 2) Фолбэк: структура вида + # Жанр: + # Feature, All Sex, M.I.L.F.s. + for wrapper in self.data.find_all('span', class_='post-color-text'): + nested = wrapper.find('span', class_='post-b') + if nested and nested.text.strip().startswith("Жанр"): + # следующий span.post-color-text содержит сами жанры + for sib in wrapper.find_next_siblings(): + if isinstance(sib, Tag) and 'post-color-text' in sib.get('class', []): + raw = sib.get_text(strip=True) + for tag in raw.rstrip('.').split(','): + t = tag.strip() + if t: + scraped_tags.append(ScrapedTag(name=t)) + return scraped_tags + break return scraped_tags def get_performers(self): - split_performers_pattern = re.compile(r"[,&](?=[^()]*(?:\(|$))") - post_bs = self.data.find_all('span', class_='post-b') - for post_b in post_bs: - performers = self.get_field_text(post_b, ["В ролях"]) - if performers: - performers = re.split(r"[,&](?=[^()]*(?:\(|$))", performers) - scraped_performers = [] - for performer in performers: - aliases = re.search(r"\((.*?)\)", performer) - performer = re.sub(r"\(.*?\)", "", performer) - if aliases: - aliases = aliases.group(1).split(',') - aliases = [alias.strip() for alias in aliases] - scraped_performers.append(ScrapedPerformer(name=performer.strip(), aliases=aliases)) + split_pattern = re.compile(r"[,&](?=[^()]*(?:\(|$))") + + # 1) Стандартный способ: через get_field_text по «В ролях» и «Имя актрисы» + for post_b in self.data.find_all('span', class_='post-b'): + # ветка «В ролях» + raw = self.get_field_text(post_b, ["В ролях"]) + if raw: + parts = split_pattern.split(raw) + scraped = [] + for part in parts: + m = re.search(r"\((.*?)\)", part) + name = re.sub(r"\(.*?\)", "", part).strip().rstrip('.') + if m: + aliases = [a.strip() for a in m.group(1).split(',')] + scraped.append(ScrapedPerformer(name=name, aliases=", ".join(aliases))) else: - scraped_performers.append(ScrapedPerformer(name=performer.strip())) - - return scraped_performers - - performers = self.get_field_text(post_b, ["Имя актрисы"]) - if performers: - performers = re.split(r"[,&](?=[^()]*(?:\(|$))", performers) - scraped_performers = [] - for performer in performers: - aliases = re.search(r"\((.*?)\)", performer) - performer = re.sub(r"\(.*?\)", "", performer) - if aliases: - aliases = aliases.group(1) - scraped_performers.append(ScrapedPerformer(name=performer.strip(), gender="FEMALE", aliases=aliases)) + scraped.append(ScrapedPerformer(name=name)) + return scraped + + # ветка «Имя актрисы» + raw = self.get_field_text(post_b, ["Имя актрисы"]) + if raw: + parts = split_pattern.split(raw) + scraped = [] + for part in parts: + m = re.search(r"\((.*?)\)", part) + name = re.sub(r"\(.*?\)", "", part).strip().rstrip('.') + if m: + aliases = [a.strip() for a in m.group(1).split(',')] + scraped.append(ScrapedPerformer(name=name, gender="FEMALE", aliases=", ".join(aliases))) else: - scraped_performers.append(ScrapedPerformer(name=performer.strip(), gender="FEMALE")) - - return scraped_performers - - return [] + scraped.append(ScrapedPerformer(name=name, gender="FEMALE")) + return scraped + + # 2) Фолбэк: после В ролях + for wrapper in self.data.find_all('span', class_='post-color-text'): + nested = wrapper.find('span', class_='post-b') + if not (nested and nested.text.strip() == "В ролях"): + continue + # проходим все следующие узлы + for sib in wrapper.next_siblings: + # пропускаем тег
+ if isinstance(sib, Tag) and sib.name == 'br': + continue + # пропускаем одиночный «:» + if isinstance(sib, NavigableString): + text = sib.strip() + if not text or text == ':': + continue + raw = text + # если список в + elif isinstance(sib, Tag) and 'post-color-text' in sib.get('class', []): + raw = sib.get_text(strip=True) + if not raw or raw == ':': + continue + else: + continue + # мы получили строку актёров — разбираем + parts = split_pattern.split(raw.rstrip('.')) + scraped = [] + for part in parts: + m = re.search(r"\((.*?)\)", part) + name = re.sub(r"\(.*?\)", "", part).strip() + if m: + aliases = [a.strip() for a in m.group(1).split(',')] + scraped.append(ScrapedPerformer(name=name, aliases=", ".join(aliases))) + else: + scraped.append(ScrapedPerformer(name=name)) + return scraped + # если после «В ролях» не нашлось ничего кроме «:» и
— возвращаем пустой список + return [] def get_image(self): - postImg = self.data.find('var', class_='postImg') - if postImg: - return postImg['title'] + # Собираем три группы кандидатов + left_imgs = self.data.find_all( + 'var', class_=lambda c: c and 'postImg' in c and 'img-left' in c + ) + right_imgs = [ + v for v in self.data.find_all( + 'var', class_=lambda c: c and 'postImg' in c and 'img-right' in c + ) if v not in left_imgs + ] + generic_imgs = [ + v for v in self.data.find_all( + 'var', class_=lambda c: c and 'postImg' in c + ) if v not in left_imgs + right_imgs + ] + + # Если нет ни img-left, ни img-right — перемешиваем generic и берём их в случайном порядке + if not left_imgs and not right_imgs and generic_imgs: + random.shuffle(generic_imgs) + + candidates = left_imgs + right_imgs + generic_imgs + + # Если на странице есть блок года — фильтруем изображения, оставляя только те, что идут выше него + marker = None + for post_b in self.data.find_all('span', class_='post-b'): + if post_b.text.strip() in ("Год производства", "Год выпуска"): + marker = post_b + break + if marker: + # все , идущие ДО этого маркера + prev_imgs = marker.find_all_previous( + 'var', + class_=lambda c: c and 'postImg' in c + ) + if prev_imgs: + candidates = [v for v in candidates if v in prev_imgs] + + # Перебираем кандидатов и возвращаем первую «живую» картинку + for postImg in candidates: + url = postImg.get('title', '').strip() + + # Пропускаем GIF-файлы + if url.lower().endswith('.gif'): + continue + + if not url: + continue + + try: + resp = requests.head( + url, + headers=scrape_headers, + timeout=3, + allow_redirects=True + ) + except requests.exceptions.Timeout: + # Считаем таймаут не признаком мёртвой ссылки + return url + except requests.RequestException as e: + log.warning(f"Cannot reach image {url}: {e}, skipping to next.") + continue + + if resp.status_code == 200: + return url + else: + log.warning(f"Image URL returned {resp.status_code}, skipping to next.") + continue + # Если ни один кандидат не годится return "" def is_valid(self): @@ -262,8 +506,8 @@ def parse_scene(self) -> ScrapedScene: if details := self.get_details(): scene["details"] = details -# if date := self.get_date(): -# scene["date"] = date + if date := self.get_date(): + scene["date"] = date if studio := self.get_studio(): scene["studio"] = studio @@ -294,11 +538,11 @@ def parse_movie(self) -> ScrapedMovie: if name := self.get_title(): movie["name"] = name -# if date := self.get_date(): -# movie["date"] = date + if date := self.get_date(): + movie["date"] = date -# if duration := self.get_duration(): -# movie["duration"] = duration + if duration := self.get_duration(): + movie["duration"] = duration if synopsis := self.get_details(): movie["synopsis"] = synopsis