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)
+ # Первый приоритет:
+# 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