Перейти к публикации
  • Сейчас на странице   Всего пользователей: 6   (0 пользователей, 6 гостей)

Rooster

Программирование[11]

var  

320 пользователей проголосовало

У вас нет прав на голосование в этом опросе, или на просмотр результатов опроса. Пожалуйста, войдите или зарегистрируйтесь для голосования в опросе.

Рекомендованные сообщения

sB.Raven написал 19 часов назад:

один день из жизни вротдалиендера.
рынок сдох, помянем кто строил карьеру вокруг Js.
поэтому давайте  кто какими ии-агентами пользуется?

image.png.ba78435d3b355d71d4e44015f06f6064.pngimage.png.86f56be76cc32d1120da36d3aba4abab.pngimage.png.e45e2b77023582e6fba12b863032e895.pngimage.png.e05455e3dbe9f258fb2b3a0cc4d25a61.pngimage.png.c78928d2ee9116ed74468e53e622cad3.pngimage.png.90f671de3d32e72f96f4106095378783.png

 

че эти ...эндеры не могут собраться и свой стартап организовать? У нас вон емптич спокойно своё приложение залил в апсторе


image.png.dedda6c5d04a07ad35710ee55d78775d.png 

Поделиться сообщением


Ссылка на сообщение
Baza_KAiF написал 1 минуту назад:

че эти ...эндеры не могут собраться и свой стартап организовать?

Может у них денег нет на курсор.

 

Я работал с одним челом у которого финансовая подушка была отрицательная. Он получал зарплату, почти в ноль гасил с неё месячный грейс овердрафт, а потом шел и в овердрафт ел устрицы с вином со своей тяночкой.

 

Для стартапа нужен какой-то капитал. 

Поделиться сообщением


Ссылка на сообщение
Index написал 1 минуту назад:
Baza_KAiF написал 5 минут назад:

че эти ...эндеры не могут собраться и свой стартап организовать?

Может у них денег нет на курсор.

 

Я работал с одним челом у которого финансовая подушка была отрицательная. Он получал зарплату, почти в ноль гасил с неё месячный грейс овердрафт, а потом шел и в овердрафт ел устрицы с вином со своей тяночкой.

 

Для стартапа нужен какой-то капитал. 

нет денег можно с дипсика влетать

 

мне он недавно показал что можно в указатель вектора сообщения скастовать указатель структры заголовка, и потом лехко заполнить часть вектора как структуру. оказывается программирование это так легко если знать как какать

мне кажется что можно хоть гигачатом прогать если ты реально разработчик и видишь где иишка пытается сделать хуйню

пора уже всем стартапы открывать


image.png.dedda6c5d04a07ad35710ee55d78775d.png 

Поделиться сообщением


Ссылка на сообщение
Index написал 20 минут назад:

не досписал

Я вот себя пиздец конечно тупым не считаю, но у меня напрочь отсутствует скилл визуализации в голове. 

Я проблемы или тех-риски решений которые обсуждают на созвонах начинаю замечать только тогда, когда берусь за клавиатуру. Я могу придумать охуенное решение только тогда, когда возьмусь за клавиатуру а без доски чисто фейс-ту-фейс с меня брейнштормер как со свиньи в лужу хрюкающей.

 

Если обсуждается домен в который я не коммитал я не могу на словах ничего дирижировать.

И очень часто я просто киваю головой и соглашаюсь с какой-то мыслью а потом сев за клаву и раскурив её бегу подсвечивать в чате техриски или лучшие решения.

 

К чему я это веду. Обычно я раскрываюсь через какое-то время на работе, от средне-нормиса до крутого специалиста который подмечает тонкие моменты, нужно там пару месяцев хуе мое.

Но эту характеристику очень тяжело как-то продать на ебучих собесах. На собесах я себя пиздец тупым ощущаю и в условиях текущего рынка даже как-то стремно оказаться в нем. :corona:

 

ну пиши код в голове по верхам, хз, может поможет

 

 

есть люди, которые не могут думать невербально, даже название какое-то было специальное, мб ты к ним относишься


Торжество разума в том, чтобы уживаться с теми, у кого этого разума нет. Вольтер.
Чтобы хорошо высыпаться, нужно спать 8 часов в день. И еще столько же ночью.

Поделиться сообщением


Ссылка на сообщение

Тоже недавно удивился, узнав о распространённости афантазии. Каждый сотый, а то и пятидесятый?! Как такие люди живут без картинок в голове?

Поделиться сообщением


Ссылка на сообщение
(изменено)
Index написал 3 часа назад:
Baza_KAiF написал 3 часа назад:

че эти ...эндеры не могут собраться и свой стартап организовать?

Может у них денег нет на курсор.

 

Я работал с одним челом у которого финансовая подушка была отрицательная. Он получал зарплату, почти в ноль гасил с неё месячный грейс овердрафт, а потом шел и в овердрафт ел устрицы с вином со своей тяночкой.

 

Для стартапа нужен какой-то капитал. 

шо-то жестка... даже для вротспустилиендера

Index написал 4 часа назад:

не досписал

Я вот себя пиздец конечно тупым не считаю, но у меня напрочь отсутствует скилл визуализации в голове. 

Я проблемы или тех-риски решений которые обсуждают на созвонах начинаю замечать только тогда, когда берусь за клавиатуру. Я могу придумать охуенное решение только тогда, когда возьмусь за клавиатуру а без доски чисто фейс-ту-фейс с меня брейнштормер как со свиньи в лужу хрюкающей.

 

Если обсуждается домен в который я не коммитал я не могу на словах ничего дирижировать.

И очень часто я просто киваю головой и соглашаюсь с какой-то мыслью а потом сев за клаву и раскурив её бегу подсвечивать в чате техриски или лучшие решения.

 

К чему я это веду. Обычно я раскрываюсь через какое-то время на работе, от средне-нормиса до крутого специалиста который подмечает тонкие моменты, нужно там пару месяцев хуе мое.

Но эту характеристику очень тяжело как-то продать на ебучих собесах. На собесах я себя пиздец тупым ощущаю и в условиях текущего рынка даже как-то стремно оказаться в нем. :corona:

 

алгосы порешай, систем дизайн там поделай.
у меня тоже есть проблемс с визуализацией, ну как будто после дефолтных тем и прорешивания - стало лучше... или я сам себе придумал хуй знает...
просто нахуй видешь похожие закономерности и в разных интерпретациях, но ведь это энивей одно


Изменено пользователем sB.Raven

Поделиться сообщением


Ссылка на сообщение
Index написал 4 часа назад:

Я вот себя пиздец конечно тупым не считаю, но у меня напрочь отсутствует скилл визуализации в голове. 

Я проблемы или тех-риски решений которые обсуждают на созвонах начинаю замечать только тогда, когда берусь за клавиатуру. Я могу придумать охуенное решение только тогда, когда возьмусь за клавиатуру а без доски чисто фейс-ту-фейс с меня брейнштормер как со свиньи в лужу хрюкающей.

 

Если обсуждается домен в который я не коммитал я не могу на словах ничего дирижировать.

И очень часто я просто киваю головой и соглашаюсь с какой-то мыслью а потом сев за клаву и раскурив её бегу подсвечивать в чате техриски или лучшие решения.

мне кажется так у большинства

общение фейс ту фейс сбивает концентрацию и фокус

Index написал 4 часа назад:

К чему я это веду. Обычно я раскрываюсь через какое-то время на работе, от средне-нормиса до крутого специалиста который подмечает тонкие моменты, нужно там пару месяцев хуе мое.

Но эту характеристику очень тяжело как-то продать на ебучих собесах. На собесах я себя пиздец тупым ощущаю и в условиях текущего рынка даже как-то стремно оказаться в нем

сейм щит

обычно это долно выливаться в твои достижения и карьерные хайлайты

но если в процессе собесов это не проверяют - то и не поможет никак. но некоторые компании (по ощущениям процентов 30-40) что-то подобное так или иначе проверяют

но. с другой стороны это помогает расти внутри компании. так что может быть хорошим скиллом чтобы полдучить промоушен внутри, поработать год-два, а потом уходить в другую компанию на грейд выше. либо искать компании где можно хорошо расти внутри

antirat написал 2 часа назад:

Тоже недавно удивился, узнав о распространённости афантазии. Каждый сотый, а то и пятидесятый?! Как такие люди живут без картинок в голове?

мне кажется вы не поняли про что индекс рассказывает

у него трабл в созвонах фейс ту фейс.

я в такой хуйне не могу думать вообще, могу только заранее заготовленные опции обсудить либо понять что нихуя не подходит и надо расходиться и новое что-то придумывать. решения принятые в таких созвонах полная хуйня. каждый раз какая-то хуйня вылетает тк никто не погрузился на 100% в проблему и не покрутил в голове вдумчиво все корнер кейсы. и потом половина принятых решений выкидывается блять

 

sB.Raven написал 27 минут назад:

алгосы порешай, систем дизайн там поделай.

плюсую

недавно проходил собесы в забугорный экс яндекс

3 кодинга, 1 сисдиз

готовился - в итоге прошел.

начинал с того что нихуя не мог нормально решить подобное в другие компании (вообще не готовился)

так что подготовка тащит

Index и sB.Raven понравилось это

 

очень крутые котейки

RqvSzvr.png


Кому-то пизды дал - нужно сделать скрин обязательно. (с) Solo

Поделиться сообщением


Ссылка на сообщение

Написал парсер постов для продолжения с помощью нейронки. Лучше всего показала себя deepseek

 Qwen 3.6 plus новая лагает и ошибки в синтаксисе с табуляциями. Glm5.1 на втором месте после дипсика.

Что делает прога: вы вводите ссылку на первую страницу выбранной темы,за тем путь сохранения на компе или смартфоне, имя папки сохранения и нажимаете парсинг. В выходном файле получаете список всех постов темы.

 

Парсер для продоты:

Скрытый текст

# -*- coding: utf-8 -*-
"""
================================================================================
ПАРСЕР ФОРУМА PRODOTA.RU (версия 4.3 — исправленная очистка)
================================================================================

📋 ОПИСАНИЕ:
Программа скачивает сообщения с указанной темы форума prodota.ru и сохраняет
их в удобном формате. Поддерживает два режима работы.

🔹 РЕЖИМ 1 — Только текст (рекомендуется):
   • Сохраняет: автор, номер поста, время, текст сообщения
   • Формат: JSON или CSV (на выбор)
   • Опция: разбивка на файлы заданного размера (в МБ)
   • Очистка: удаляет подписи, лайки, кнопки интерфейса, метаданные
   • Цитаты: сохраняет структуру "цитата + ответ автора"

🔹 РЕЖИМ 2 — Полный архив:
   • Всё из режима 1 + скачивание вложений (картинки, видео, документы)
   • Создание локальной HTML-страницы для офлайн-просмотра
   • Вложения сохраняются в подпапки: attachments/images, videos, docs

📁 ВЫХОДНЫЕ ДАННЫЕ (режим 1):
Каждая запись содержит ТОЛЬКО 4 поля:
{
    "author": "никнейм_автора",
    "post_number": "ID_поста",
    "post_time": "время_публикации",
    "post_text": "очищенный_текст_сообщения"
}

================================================================================
🔧 НАСТРОЙКА ПРОГРАММЫ (редактируйте этот раздел перед запуском)
================================================================================
"""

# ========================== ИМПОРТЫ БИБЛИОТЕК ==========================
import os
import re
import sys
import json
import csv
import time
import logging
from pathlib import Path
from urllib.parse import urljoin
from typing import Optional, List, Dict, Any, Generator

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from bs4 import BeautifulSoup, Tag

# Цветной вывод в консоль (кросс-платформенно)
try:
    from colorama import init, Fore, Back, Style
    init(autoreset=True)
except ImportError:
    # Если colorama не установлена, создаём заглушки
    class Fore:
        RED = GREEN = YELLOW = CYAN = MAGENTA = WHITE = RESET = ''
    class Style:
        BRIGHT = RESET_ALL = ''
    class Back:
        RESET = ''


# ========================== НАСТРОЙКИ ПОЛЬЗОВАТЕЛЯ ==========================
# ▼▼▼ ИЗМЕНЯЙТЕ ЗНАЧЕНИЯ НИЖЕ ПОД СВОИ ЗАДАЧИ ▼▼▼

class Config:
    """
    🔹 ВСЕ НАСТРОЙКИ ПРОГРАММЫ НАХОДЯТСЯ ЗДЕСЬ 🔹
    
    Как менять:
    1. Найдите нужную строку ниже
    2. Замените значение в кавычках или число на своё
    3. Сохраните файл и запустите парсер
    
    Пример:
        START_URL: str = 'https://prodota.ru/forum/topic/123456/'
                             ↑↑↑ вставьте сюда ссылку на вашу тему
    """
    
    # ─────────────────────────────────────────────────────────────
    # 🌐 ОБЯЗАТЕЛЬНЫЕ НАСТРОЙКИ
    # ─────────────────────────────────────────────────────────────
    
    # Режим работы по умолчанию:
    #   1 = только текст (быстро, мало места)
    #   2 = полный архив с вложениями (медленнее, нужно место)
    #   None = программа спросит режим при запуске
    DEFAULT_MODE: Optional[int] = None
    
    # 🔗 ССЫЛКА НА ПЕРВУЮ СТРАНИЦУ ТЕМЫ (ОБЯЗАТЕЛЬНО!)
    # Вставьте полную ссылку на тему, которую нужно скачать:
    START_URL: str = 'https://prodota.ru/forum/topic/224233/'
    
    # 📁 ИМЯ ПАПКИ ДЛЯ СОХРАНЕНИЯ РЕЗУЛЬТАТА
    # Используйте латиницу, цифры, подчёркивания (без пробелов и кириллицы):
    FOLDER_NAME: str = 'prodota_archive'
    
    # 📂 БАЗОВАЯ ПАПКА ДЛЯ СОХРАНЕНИЯ
    # На Android автоматически определяется папка Download.
    # На других ОС — папка "Загрузки" пользователя.
    @staticmethod
    def _default_output_dir() -> str:
        # Проверяем, запущено ли на Android (по наличию характерной папки или переменной)
        if os.path.exists('/storage/emulated/0/Download'):
            return '/storage/emulated/0/Download'
        return os.path.join(os.path.expanduser('~'), 'Downloads')
    
    DEFAULT_OUTPUT_DIR: str = os.getenv('PARSER_OUTPUT_DIR', _default_output_dir())
    
    # ─────────────────────────────────────────────────────────────
    # ⚙️ ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (можно не менять)
    # ─────────────────────────────────────────────────────────────
    
    # Задержка между запросами к сайту (в секундах)
    REQUEST_DELAY: float = 1.0
    
    # Таймаут ожидания ответа от сайта (в секундах)
    REQUEST_TIMEOUT: int = 20
    
    # Количество повторных попыток при ошибке соединения
    MAX_RETRIES: int = 3
    
    # Множитель экспоненциальной задержки между повторными попытками
    BACKOFF_FACTOR: float = 1.0
    
    # Формат сохранения для режима 1: 'json' или 'csv'
    TEXT_FORMAT: str = 'json'
    
    # Максимальный размер одного файла при разбивке (в МБ), 0 = не разбивать
    SPLIT_SIZE_MB: float = 0
    
    # CSS-селекторы для поиска элементов на странице
    SELECTORS = {
        'post_blocks': ['li.ipsComment', 'div.cPost', 'article.ipsComment', 'div.post'],
        'author': ['.cAuthorPane_author', '.ipsComment_author', '.author', 'a[data-username]'],
        'content': ['.cPost_contentWrap', '.ipsComment_content', '.post-content', '[data-role="commentContent"]'],
        'next_page': ['a[rel="next"]', '.ipsPagination_next a', '.next a'],
        'last_page': ['a.ipsPagination_last', 'a[data-page="last"]', 'li.ipsPagination_last a']
    }


# ========================== НАСТРОЙКА ЛОГГИРОВАНИЯ С ЦВЕТАМИ ==========================
class ColoredFormatter(logging.Formatter):
    """Форматтер, окрашивающий уровень логирования в цвет."""
    def format(self, record):
        level_color = {
            'INFO': Fore.CYAN,
            'WARNING': Fore.YELLOW,
            'ERROR': Fore.RED,
            'CRITICAL': Fore.RED + Back.WHITE,
        }.get(record.levelname, Fore.WHITE)
        original_levelname = record.levelname
        record.levelname = f"{level_color}{original_levelname}{Style.RESET_ALL}"
        result = super().format(record)
        record.levelname = original_levelname
        return result


def setup_logging(level: int = logging.INFO) -> logging.Logger:
    logger = logging.getLogger('prodota_parser')
    logger.setLevel(level)
    if not logger.handlers:
        formatter = ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(formatter)
        logger.addHandler(console_handler)
    return logger


logger = setup_logging()


# ========================== ФУНКЦИЯ ОЧИСТКИ КОНТЕНТА ==========================
# Удаляет из текста поста всё лишнее: подписи, лайки, кнопки, метаданные
# Сохраняет: время публикации, цитаты + ответ автора, основной текст

def clean_post_content(raw_content: str) -> Dict[str, str]:
    """
    Очищает сырой текст поста от интерфейсного мусора.
    
    Что удаляется:
    • Номер поста (#1, #22 и т.д.)
    • Метки времени в начале текста (дубликат, т.к. время извлекается отдельно)
    • Блоки лайков ("понравилось это" и имена пользователей)
    • Кнопки интерфейса ("Цитата", "Поделиться сообщением", "Ссылка на сообщение")
    • Текст ПОСЛЕ кнопок интерфейса (автоматические подписи пользователей)
    
    Что сохраняется:
    • Время публикации (извлекается в отдельное поле)
    • Цитаты других постов (структура: "User написал... : текст")
    • Ответ автора на цитату
    
    Возвращает: {'post_time': '...', 'post_text': '...'}
    """
    if not raw_content:
        return {'post_time': '', 'post_text': ''}
    
    text = raw_content.strip()
    post_time = ''
    
    # 1. Извлекаем время публикации
    time_match = re.search(
        r'(?:Опубликовано:\s*)?'
        r'(вчера|сегодня|\d+\s*(?:часов?|минут?|секунд?|дней?|месяцев?|лет?)\s*назад|'
        r'\d{2}\.\d{2}\.\d{4}\s+в\s+\d{2}:\d{2})',
        text, re.IGNORECASE
    )
    if time_match:
        post_time = time_match.group(1) if time_match.group(1) else time_match.group(0).replace('Опубликовано:', '').strip()
        text = text.replace(time_match.group(0), '', 1).strip()
    
    # 2. Удаляем номер поста в начале (#1, #22...)
    text = re.sub(r'^#\d+\s*', '', text).strip()
    
    # 3. Удаляем блоки лайков (безопасная версия, не вызывающая ошибок)
    # Удаляем строки, содержащие "понравилось это", а также предыдущие строки с именами
    lines = text.split('\n')
    cleaned_lines = []
    skip_next = False
    for i, line in enumerate(lines):
        if 'понравилось это' in line.lower():
            # Пропускаем эту строку и, возможно, предыдущие строки-имена
            # Удаляем также предыдущие строки, если они содержат только имена и запятые
            if cleaned_lines and re.match(r'^[\s\w,]+$', cleaned_lines[-1]):
                cleaned_lines.pop()
            continue
        # Удаляем строки, которые целиком состоят из имён с запятыми (без "понравилось")
        if re.match(r'^[\s\w,]+$', line) and len(line) < 100:
            continue
        cleaned_lines.append(line)
    text = '\n'.join(cleaned_lines)
    
    # 4. Отсекаем всё, что идёт после кнопок интерфейса (это подписи)
    for marker in ['Ссылка на сообщение', 'Поделиться сообщением', 'Цитата']:
        idx = text.find(marker)
        if idx != -1:
            text = text[:idx]
            break
    
    # 5. Убираем лишние пустые строки
    text = re.sub(r'\n{3,}', '\n\n', text).strip()
    
    return {'post_time': post_time, 'post_text': text}


# ========================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========================

def sanitize_filename(filename: str, max_length: int = 100) -> str:
    """
    Очищает имя файла от символов, недопустимых в разных ОС.
    """
    sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename)
    if len(sanitized) > max_length:
        name, ext = os.path.splitext(sanitized)
        sanitized = name[:max_length-len(ext)] + ext
    return sanitized.strip() or 'unnamed'


def estimate_record_size(record: Dict[str, Any]) -> int:
    """
    Приблизительно оценивает размер одной записи в байтах.
    Нужно для разбивки больших файлов на части.
    """
    return sum(len(str(v).encode('utf-8')) for v in record.values()) + 50


# ========================== БАЗОВЫЙ КЛАСС ПАРСЕРА ==========================

class BaseParser:
    """
    Базовый класс с общими методами для парсинга страниц форума.
    """
    
    def __init__(self, start_url: str, output_dir: Path, delay: float = Config.REQUEST_DELAY, timeout: int = Config.REQUEST_TIMEOUT):
        self.start_url = start_url
        self.output_dir = output_dir
        self.delay = delay
        self.timeout = timeout
        self.session = self._create_session()
        self.posts: List[Dict[str, Any]] = []
        self.seen_posts: set = set()
        self.stats = {'pages_parsed': 0, 'posts_found': 0, 'errors': 0}
        self.total_pages: Optional[int] = None  # общее количество страниц (если удастся определить)
        self.start_time: float = 0.0
    
    def _create_session(self) -> requests.Session:
        """
        Создаёт сессию с настройками повторных запросов и заголовками браузера.
        """
        session = requests.Session()
        
        # Стратегия повтора при ошибках сети
        retry_strategy = Retry(
            total=Config.MAX_RETRIES,
            backoff_factor=Config.BACKOFF_FACTOR,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=['GET', 'HEAD']
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        session.mount('https://', adapter)
        session.mount('http://', adapter)
        
        # Заголовки для имитации обычного браузера
        session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8'
        })
        return session
    
    def get_soup(self, url: str) -> Optional[BeautifulSoup]:
        """
        Загружает HTML-страницу и возвращает объект BeautifulSoup для парсинга.
        """
        try:
            response = self.session.get(url, timeout=self.timeout)
            response.raise_for_status()
            soup = BeautifulSoup(response.content, 'html.parser')
            
            # Проверка на блокировку (капча, 403 и т.п.)
            if self._is_blocked(soup, response):
                logger.warning(f"Возможная блокировка на {url}")
                return None
            return soup
        except requests.exceptions.Timeout:
            logger.error(f"Таймаут при загрузке {url}")
        except requests.exceptions.ConnectionError:
            logger.error(f"Ошибка соединения при загрузке {url}")
        except requests.exceptions.HTTPError as e:
            logger.error(f"HTTP ошибка {e.response.status_code} для {url}")
        except Exception as e:
            logger.error(f"Неожиданная ошибка при загрузке {url}: {type(e).__name__}: {e}")
            self.stats['errors'] += 1
        return None
    
    def _is_blocked(self, soup: BeautifulSoup, response: requests.Response) -> bool:
        """
        Проверяет, не заблокировал ли сайт запрос (капча, доступ запрещён).
        """
        if response.status_code != 200:
            return True
        block_indicators = ['captcha', 'access denied', 'forbidden', '403', 'cloudflare', 'checking your browser']
        page_text = soup.get_text().lower()
        return any(indicator in page_text for indicator in block_indicators)
    
    def extract_post_id(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает уникальный номер поста из HTML-элемента.
        """
        if post_element.get('id'):
            match = re.search(r'Comment-(\d+)|Post-(\d+)|comment_(\d+)', post_element['id'], re.I)
            if match:
                return next(g for g in match.groups() if g)
        if post_element.get('data-commentid'):
            return str(post_element['data-commentid'])
        link = post_element.select_one('a[href*="#comment-"], a[href*="#post-"]')
        if link and link.get('href'):
            match = re.search(r'#(?:comment|post)-?(\d+)', link['href'], re.I)
            if match:
                return match.group(1)
        return None
    
    def extract_author(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает никнейм автора поста.
        """
        for selector in Config.SELECTORS['author']:
            author_elem = post_element.select_one(selector)
            if author_elem:
                text = author_elem.get_text(strip=True)
                if text:
                    return text
        return None
    
    def extract_raw_content(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает сырой текст поста (до очистки).
        """
        for selector in Config.SELECTORS['content']:
            content_elem = post_element.select_one(selector)
            if content_elem:
                return content_elem.get_text(separator='\n', strip=True)
        return None
    
    def get_total_pages(self, soup: BeautifulSoup, base_url: str) -> Optional[int]:
        """
        Пытается извлечь общее количество страниц из пагинации.
        """
        for selector in Config.SELECTORS['last_page']:
            last_elem = soup.select_one(selector)
            if last_elem and last_elem.get('href'):
                match = re.search(r'page[=_-]?(\d+)', last_elem['href'])
                if match:
                    return int(match.group(1))
        # Альтернатива: ищем число среди элементов пагинации
        pagination_text = soup.get_text()
        match = re.search(r'Страница\s+\d+\s+из\s+(\d+)', pagination_text, re.IGNORECASE)
        if match:
            return int(match.group(1))
        return None
    
    def parse_page(self, url: str) -> Optional[str]:
        """
        Парсит одну страницу: извлекает посты и находит ссылку на следующую.
        Возвращает URL следующей страницы или None, если это последняя.
        """
        page_start = time.time()
        soup = self.get_soup(url)
        if not soup:
            return None
        
        # Если общее количество страниц ещё не известно, пытаемся определить
        if self.total_pages is None:
            self.total_pages = self.get_total_pages(soup, url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        # Поиск блоков с постами
        post_blocks = []
        for selector in Config.SELECTORS['post_blocks']:
            blocks = soup.select(selector)
            if blocks:
                post_blocks.extend(blocks)
                break
        
        if not post_blocks:
            logger.warning(f"Не найдено постов на странице {url}.")
            return None
        
        posts_before = len(self.posts)
        # Обработка каждого поста
        for block in post_blocks:
            post_id = self.extract_post_id(block)
            if not post_id or post_id in self.seen_posts:
                continue
            
            author = self.extract_author(block)
            raw_content = self.extract_raw_content(block)
            
            if not author or not raw_content:
                logger.debug(f"Пропущен пост {post_id}: не удалось извлечь автора или контент")
                continue
            
            # Очистка контента и добавление в список
            cleaned = clean_post_content(raw_content)
            if not cleaned['post_text']:
                continue
            
            self.seen_posts.add(post_id)
            self.posts.append({
                'author': author,
                'post_number': post_id,
                'post_time': cleaned['post_time'],
                'post_text': cleaned['post_text']
            })
            self.stats['posts_found'] += 1
        
        posts_added = len(self.posts) - posts_before
        # Увеличиваем счётчик страниц
        self.stats['pages_parsed'] += 1
        elapsed = time.time() - page_start
        
        # Красивый вывод прогресса
        if self.total_pages:
            progress = self.stats['pages_parsed'] / self.total_pages * 100
            bar_length = 30
            filled = int(bar_length * self.stats['pages_parsed'] // self.total_pages)
            bar = '█' * filled + '░' * (bar_length - filled)
            print(f"\r{Fore.CYAN}▶ Страница {self.stats['pages_parsed']}/{self.total_pages} [{bar}] {progress:.1f}%  "
                  f"| Постов на странице: {posts_added} | Всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        else:
            print(f"\r{Fore.CYAN}▶ Страница {self.stats['pages_parsed']} (общее количество неизвестно)  "
                  f"| Постов на странице: {posts_added} | Всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        sys.stdout.flush()
        
        # Поиск ссылки на следующую страницу
        for selector in Config.SELECTORS['next_page']:
            next_elem = soup.select_one(selector)
            if next_elem and next_elem.get('href'):
                next_url = urljoin(url, next_elem['href'])
                if next_url != url:
                    return next_url
        return None
    
    def run(self) -> bool:
        """
        Основной цикл парсинга: проходит по всем страницам темы.
        """
        self.start_time = time.time()
        print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  {Fore.YELLOW}ПАРСЕР PRODOTA.RU{Style.RESET_ALL}                                       {Fore.GREEN}│{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  URL: {self.start_url}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  Сохранение: {self.output_dir.absolute()}")
        print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}\n")
        
        current_url = self.start_url
        # Сначала загрузим первую страницу, чтобы определить общее количество страниц
        first_soup = self.get_soup(current_url)
        if first_soup:
            self.total_pages = self.get_total_pages(first_soup, current_url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        while current_url:
            next_url = self.parse_page(current_url)
            if next_url and next_url != current_url:
                current_url = next_url
                time.sleep(self.delay)
            else:
                break
        
        print()  # перевод строки после прогресс-бара
        elapsed_total = time.time() - self.start_time
        logger.info(f"\n Парсинг завершён. Страниц: {self.stats['pages_parsed']}, Постов: {len(self.posts)}, Ошибок: {self.stats['errors']}, Время: {elapsed_total:.1f} сек")
        return len(self.posts) > 0


# ========================== КЛАСС ДЛЯ ТЕКСТОВОГО РЕЖИМА ==========================

class TextParser(BaseParser):
    """
    Парсер для режима 1: сохраняет только текстовые данные в JSON или CSV.
    """
    
    def __init__(self, start_url: str, output_dir: Path, format: str = 'json', split_size_mb: float = 0):
        super().__init__(start_url, output_dir)
        self.format = format.lower()
        self.split_size_mb = split_size_mb
    
    def _save_json_streaming(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в JSON с потоковой записью (экономит память).
        """
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write('[\n')
            for i, record in enumerate(data):
                json.dump(record, f, ensure_ascii=False, indent=2)
                if i < len(data) - 1:
                    f.write(',\n')
                else:
                    f.write('\n')
            f.write(']')
        logger.info(f"Данные сохранены в {filepath}")
    
    def _save_csv(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в CSV с разделителем точка с запятой (;).
        Кодировка UTF-8-SIG для корректного открытия в Excel.
        """
        if not data:
            logger.warning("Нет данных для сохранения в CSV")
            return
        with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
            writer.writerow(['author', 'post_number', 'post_time', 'post_text'])
            for p in data:
                writer.writerow([p['author'], p['post_number'], p['post_time'], p['post_text']])
        logger.info(f"Данные сохранены в {filepath}")
    
    def _split_data_by_size(self, data: List[Dict], max_size_bytes: int) -> Generator[List[Dict], None, None]:
        """
        Генератор для разбивки данных на части заданного размера (в байтах).
        """
        if not data:
            return
        current_batch = []
        current_size = 0
        for record in data:
            record_size = estimate_record_size(record)
            if current_size + record_size > max_size_bytes and current_batch:
                yield current_batch
                current_batch = [record]
                current_size = record_size
            else:
                current_batch.append(record)
                current_size += record_size
        if current_batch:
            yield current_batch
    
    def save(self):
        """
        Сохраняет собранные посты в файл(ы) с учётом формата и разбивки.
        """
        if not self.posts:
            logger.warning("Нет постов для сохранения")
            return
        
        max_size_bytes = self.split_size_mb * 1024 * 1024 if self.split_size_mb > 0 else float('inf')
        
        if self.format == 'json':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка JSON на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.json'
                    self._save_json_streaming(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.json'
                self._save_json_streaming(self.posts, filepath)
        elif self.format == 'csv':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка CSV на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.csv'
                    self._save_csv(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.csv'
                self._save_csv(self.posts, filepath)
        else:
            logger.error(f"Неподдерживаемый формат: {self.format}")
    
    def run(self) -> bool:
        """
        Запускает парсинг и затем сохраняет результат.
        """
        if super().run():
            self.save()
            return True
        return False


# ========================== КЛАСС ДЛЯ ПОЛНОГО АРХИВА ==========================

class FullArchiver(BaseParser):
    """
    Парсер для режима 2: скачивает вложения и создаёт HTML-архив.
    (Упрощённая версия — можно доработать при необходимости)
    """
    
    def __init__(self, start_url: str, output_dir: Path):
        super().__init__(start_url, output_dir)
        # Создаём папки для вложений
        self.attachments_dir = self.output_dir / 'attachments'
        self.media_dirs = {
            'images': self.attachments_dir / 'images',
            'videos': self.attachments_dir / 'videos',
            'docs': self.attachments_dir / 'docs'
        }
        for dir_path in [self.attachments_dir, *self.media_dirs.values()]:
            dir_path.mkdir(parents=True, exist_ok=True)
    
    def run(self) -> bool:
        """
        Запускает парсинг и сохраняет результат в текстовом формате.
        (Полная реализация скачивания вложений требует доработки)
        """
        logger.warning("Режим полного архива: скачивание вложений временно отключено.")
        logger.warning("Используется текстовый парсинг. Для полной версии обратитесь к разработчику.")
        return super().run()


# ========================== ПОЛЕЗНЫЕ ФУНКЦИИ ==========================

def validate_output_dir(base_dir: str, folder_name: str) -> Path:
    """
    Проверяет и создаёт директорию для сохранения результата.
    Если нет прав на запись — использует текущую папку.
    """
    try:
        output_path = Path(base_dir) / sanitize_filename(folder_name)
        output_path.mkdir(parents=True, exist_ok=True)
        # Проверка на возможность записи
        test_file = output_path / '.permission_test'
        test_file.touch()
        test_file.unlink()
        logger.info(f"Директория готова: {output_path.absolute()}")
        return output_path
    except PermissionError:
        logger.error(f"Нет прав на запись в {base_dir}")
        fallback = Path.cwd() / sanitize_filename(folder_name)
        fallback.mkdir(parents=True, exist_ok=True)
        logger.warning(f"Используем резервную директорию: {fallback.absolute()}")
        return fallback
    except Exception as e:
        logger.error(f"Ошибка при создании директории: {e}")
        sys.exit(1)


def get_user_mode() -> int:
    """
    Запрашивает у пользователя выбор режима работы.
    """
    print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}  {Fore.CYAN}Выберите режим работы:{Style.RESET_ALL}                                          {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}    {Fore.GREEN}[1]{Style.RESET_ALL} Только текст (быстро, мало места)                    {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}    {Fore.GREEN}[2]{Style.RESET_ALL} Полный архив (с вложениями)                         {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
    
    while True:
        choice = input(f"{Fore.YELLOW}→ Введите номер режима (1 или 2): {Style.RESET_ALL}").strip()
        if choice in ('1', '2'):
            return int(choice)
        print(f"{Fore.RED} Пожалуйста, введите 1 или 2{Style.RESET_ALL}")


def get_split_size() -> float:
    """
    Запрашивает размер части для разбивки файлов.
    """
    print(f"\n{Fore.CYAN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  Разбивка результата на несколько файлов                      │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  • 0 — всё в один файл                                      │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  • 10–50 — разбивка по МБ (рекомендуется)                   │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
    
    while True:
        try:
            value = float(input(f"{Fore.YELLOW}→ Максимальный размер части в МБ (0 - один файл): {Style.RESET_ALL}").strip())
            return max(0, value)
        except ValueError:
            print(f"{Fore.RED} Введите числовое значение (например: 0, 10, 50){Style.RESET_ALL}")


# ========================== ГЛАВНАЯ ФУНКЦИЯ ЗАПУСКА ==========================

def main():
    """
    Точка входа в программу.
    """
    # 1. Определяем режим работы
    mode = Config.DEFAULT_MODE
    if mode is None:
        mode = get_user_mode()
    else:
        logger.info(f"Используем режим {mode} из конфигурации")
    
    # 2. Проверяем, что указана ссылка на тему
    if not Config.START_URL or Config.START_URL.endswith('topic/'):
        logger.error(" ОШИБКА: Укажите корректный START_URL в настройках!")
        logger.error("   Откройте файл, найдите строку:")
        logger.error("   START_URL: str = 'https://...'")
        logger.error("   и вставьте ссылку на вашу тему.")
        sys.exit(1)
    
    # 3. Подготавливаем папку для сохранения
    output_dir = validate_output_dir(Config.DEFAULT_OUTPUT_DIR, Config.FOLDER_NAME)
    
    # 4. Запускаем парсер в выбранном режиме
    try:
        if mode == 1:
            # ─────────────────────────────────────────────
            # РЕЖИМ 1: Только текст
            # ─────────────────────────────────────────────
            print(f"\n{Fore.GREEN}📝 Режим: Только текст{Style.RESET_ALL}")
            print(f"   Формат: {Config.TEXT_FORMAT.upper()}")
            
            # Спрашиваем про разбивку, если не задано в конфиге
            split_mb = Config.SPLIT_SIZE_MB
            if split_mb is None or split_mb == 0:
                split_mb = get_split_size()
            
            parser = TextParser(
                start_url=Config.START_URL,
                output_dir=output_dir,
                format=Config.TEXT_FORMAT,
                split_size_mb=split_mb
            )
            success = parser.run()
        else:
            # ─────────────────────────────────────────────
            # РЕЖИМ 2: Полный архив
            # ─────────────────────────────────────────────
            print(f"\n{Fore.GREEN}📦 Режим: Полный архив{Style.RESET_ALL}")
            print(f"   {Fore.YELLOW}⚠️  Скачивание вложений временно отключено{Style.RESET_ALL}")
            print(f"   {Fore.YELLOW}⚠️  Используется текстовый парсинг{Style.RESET_ALL}")
            
            archiver = FullArchiver(
                start_url=Config.START_URL,
                output_dir=output_dir
            )
            success = archiver.run()
        
        # 5. Финальный отчёт
        print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
        if success:
            print(f"{Fore.GREEN}│   РАБОТА ЗАВЕРШЕНА УСПЕШНО                             │{Style.RESET_ALL}")
        else:
            print(f"{Fore.YELLOW}│  ⚠️ РАБОТА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ                  │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│  📁 Файлы сохранены в:                                      │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│     {output_dir.absolute()}                                 │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│  📋 Структура результата:                                   │{Style.RESET_ALL}")
        if Config.TEXT_FORMAT == 'json':
            print(f"{Fore.GREEN}│     • posts.json — данные в формате JSON                   │{Style.RESET_ALL}")
        else:
            print(f"{Fore.GREEN}│     • posts.csv — данные в формате CSV (открывается в Excel) │{Style.RESET_ALL}")
        if Config.SPLIT_SIZE_MB > 0:
            print(f"{Fore.GREEN}│     • posts_part001.json, posts_part002.json...            │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
        
    except KeyboardInterrupt:
        logger.warning(f"\n{Fore.YELLOW}⚠️ Прервано пользователем{Style.RESET_ALL}")
        sys.exit(130)
    except Exception as e:
        logger.exception(f"{Fore.RED} Критическая ошибка: {type(e).__name__}: {e}{Style.RESET_ALL}")
        sys.exit(1)


# ========================== ЗАПУСК ПРОГРАММЫ ==========================
# Этот блок запускает main() только если файл запущен напрямую
# (не при импорте как модуль)

if __name__ == '__main__':
    main()

 

 


moonfangtopich написал 29.08.2019 в 14:57:
У вас недостаточно широкий кругозор, пацаны

Я странствия этого еблана видел в покерных топанах, а потом в таверне - это один из самых безумных людей на форуме. Я искренне надеялся, что его зов о помощи останется незамеченным, но нет, нашелся доброволец и вот уже три страницы мы пожинаем плоды

Поделиться сообщением


Ссылка на сообщение
yellyex написал 5 минут назад:

Написал парсер постов для продолжения с помощью нейронки. Лучше всего показала себя deepseek

 Qwen 3.6 plus новая лагает и ошибки в синтаксисе с табуляциями. Glm5.1 на втором месте после дипсика.

Что делает прога: вы вводите ссылку на первую страницу выбранной темы,за тем путь сохранения на компе или смартфоне, имя папки сохранения и нажимаете парсинг. В выходном файле получаете список всех постов темы.

 

Парсер для продоты:

  Показать содержимое


# -*- coding: utf-8 -*-
"""
================================================================================
ПАРСЕР ФОРУМА PRODOTA.RU (версия 4.3 — исправленная очистка)
================================================================================

📋 ОПИСАНИЕ:
Программа скачивает сообщения с указанной темы форума prodota.ru и сохраняет
их в удобном формате. Поддерживает два режима работы.

🔹 РЕЖИМ 1 — Только текст (рекомендуется):
   • Сохраняет: автор, номер поста, время, текст сообщения
   • Формат: JSON или CSV (на выбор)
   • Опция: разбивка на файлы заданного размера (в МБ)
   • Очистка: удаляет подписи, лайки, кнопки интерфейса, метаданные
   • Цитаты: сохраняет структуру "цитата + ответ автора"

🔹 РЕЖИМ 2 — Полный архив:
   • Всё из режима 1 + скачивание вложений (картинки, видео, документы)
   • Создание локальной HTML-страницы для офлайн-просмотра
   • Вложения сохраняются в подпапки: attachments/images, videos, docs

📁 ВЫХОДНЫЕ ДАННЫЕ (режим 1):
Каждая запись содержит ТОЛЬКО 4 поля:
{
    "author": "никнейм_автора",
    "post_number": "ID_поста",
    "post_time": "время_публикации",
    "post_text": "очищенный_текст_сообщения"
}

================================================================================
🔧 НАСТРОЙКА ПРОГРАММЫ (редактируйте этот раздел перед запуском)
================================================================================
"""

# ========================== ИМПОРТЫ БИБЛИОТЕК ==========================
import os
import re
import sys
import json
import csv
import time
import logging
from pathlib import Path
from urllib.parse import urljoin
from typing import Optional, List, Dict, Any, Generator

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from bs4 import BeautifulSoup, Tag

# Цветной вывод в консоль (кросс-платформенно)
try:
    from colorama import init, Fore, Back, Style
    init(autoreset=True)
except ImportError:
    # Если colorama не установлена, создаём заглушки
    class Fore:
        RED = GREEN = YELLOW = CYAN = MAGENTA = WHITE = RESET = ''
    class Style:
        BRIGHT = RESET_ALL = ''
    class Back:
        RESET = ''


# ========================== НАСТРОЙКИ ПОЛЬЗОВАТЕЛЯ ==========================
# ▼▼▼ ИЗМЕНЯЙТЕ ЗНАЧЕНИЯ НИЖЕ ПОД СВОИ ЗАДАЧИ ▼▼▼

class Config:
    """
    🔹 ВСЕ НАСТРОЙКИ ПРОГРАММЫ НАХОДЯТСЯ ЗДЕСЬ 🔹
    
    Как менять:
    1. Найдите нужную строку ниже
    2. Замените значение в кавычках или число на своё
    3. Сохраните файл и запустите парсер
    
    Пример:
        START_URL: str = 'https://prodota.ru/forum/topic/123456/'
                             ↑↑↑ вставьте сюда ссылку на вашу тему
    """
    
    # ─────────────────────────────────────────────────────────────
    # 🌐 ОБЯЗАТЕЛЬНЫЕ НАСТРОЙКИ
    # ─────────────────────────────────────────────────────────────
    
    # Режим работы по умолчанию:
    #   1 = только текст (быстро, мало места)
    #   2 = полный архив с вложениями (медленнее, нужно место)
    #   None = программа спросит режим при запуске
    DEFAULT_MODE: Optional[int] = None
    
    # 🔗 ССЫЛКА НА ПЕРВУЮ СТРАНИЦУ ТЕМЫ (ОБЯЗАТЕЛЬНО!)
    # Вставьте полную ссылку на тему, которую нужно скачать:
    START_URL: str = 'https://prodota.ru/forum/topic/224233/'
    
    # 📁 ИМЯ ПАПКИ ДЛЯ СОХРАНЕНИЯ РЕЗУЛЬТАТА
    # Используйте латиницу, цифры, подчёркивания (без пробелов и кириллицы):
    FOLDER_NAME: str = 'prodota_archive'
    
    # 📂 БАЗОВАЯ ПАПКА ДЛЯ СОХРАНЕНИЯ
    # На Android автоматически определяется папка Download.
    # На других ОС — папка "Загрузки" пользователя.
    @staticmethod
    def _default_output_dir() -> str:
        # Проверяем, запущено ли на Android (по наличию характерной папки или переменной)
        if os.path.exists('/storage/emulated/0/Download'):
            return '/storage/emulated/0/Download'
        return os.path.join(os.path.expanduser('~'), 'Downloads')
    
    DEFAULT_OUTPUT_DIR: str = os.getenv('PARSER_OUTPUT_DIR', _default_output_dir())
    
    # ─────────────────────────────────────────────────────────────
    # ⚙️ ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (можно не менять)
    # ─────────────────────────────────────────────────────────────
    
    # Задержка между запросами к сайту (в секундах)
    REQUEST_DELAY: float = 1.0
    
    # Таймаут ожидания ответа от сайта (в секундах)
    REQUEST_TIMEOUT: int = 20
    
    # Количество повторных попыток при ошибке соединения
    MAX_RETRIES: int = 3
    
    # Множитель экспоненциальной задержки между повторными попытками
    BACKOFF_FACTOR: float = 1.0
    
    # Формат сохранения для режима 1: 'json' или 'csv'
    TEXT_FORMAT: str = 'json'
    
    # Максимальный размер одного файла при разбивке (в МБ), 0 = не разбивать
    SPLIT_SIZE_MB: float = 0
    
    # CSS-селекторы для поиска элементов на странице
    SELECTORS = {
        'post_blocks': ['li.ipsComment', 'div.cPost', 'article.ipsComment', 'div.post'],
        'author': ['.cAuthorPane_author', '.ipsComment_author', '.author', 'a[data-username]'],
        'content': ['.cPost_contentWrap', '.ipsComment_content', '.post-content', '[data-role="commentContent"]'],
        'next_page': ['a[rel="next"]', '.ipsPagination_next a', '.next a'],
        'last_page': ['a.ipsPagination_last', 'a[data-page="last"]', 'li.ipsPagination_last a']
    }


# ========================== НАСТРОЙКА ЛОГГИРОВАНИЯ С ЦВЕТАМИ ==========================
class ColoredFormatter(logging.Formatter):
    """Форматтер, окрашивающий уровень логирования в цвет."""
    def format(self, record):
        level_color = {
            'INFO': Fore.CYAN,
            'WARNING': Fore.YELLOW,
            'ERROR': Fore.RED,
            'CRITICAL': Fore.RED + Back.WHITE,
        }.get(record.levelname, Fore.WHITE)
        original_levelname = record.levelname
        record.levelname = f"{level_color}{original_levelname}{Style.RESET_ALL}"
        result = super().format(record)
        record.levelname = original_levelname
        return result


def setup_logging(level: int = logging.INFO) -> logging.Logger:
    logger = logging.getLogger('prodota_parser')
    logger.setLevel(level)
    if not logger.handlers:
        formatter = ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(formatter)
        logger.addHandler(console_handler)
    return logger


logger = setup_logging()


# ========================== ФУНКЦИЯ ОЧИСТКИ КОНТЕНТА ==========================
# Удаляет из текста поста всё лишнее: подписи, лайки, кнопки, метаданные
# Сохраняет: время публикации, цитаты + ответ автора, основной текст

def clean_post_content(raw_content: str) -> Dict[str, str]:
    """
    Очищает сырой текст поста от интерфейсного мусора.
    
    Что удаляется:
    • Номер поста (#1, #22 и т.д.)
    • Метки времени в начале текста (дубликат, т.к. время извлекается отдельно)
    • Блоки лайков ("понравилось это" и имена пользователей)
    • Кнопки интерфейса ("Цитата", "Поделиться сообщением", "Ссылка на сообщение")
    • Текст ПОСЛЕ кнопок интерфейса (автоматические подписи пользователей)
    
    Что сохраняется:
    • Время публикации (извлекается в отдельное поле)
    • Цитаты других постов (структура: "User написал... : текст")
    • Ответ автора на цитату
    
    Возвращает: {'post_time': '...', 'post_text': '...'}
    """
    if not raw_content:
        return {'post_time': '', 'post_text': ''}
    
    text = raw_content.strip()
    post_time = ''
    
    # 1. Извлекаем время публикации
    time_match = re.search(
        r'(?:Опубликовано:\s*)?'
        r'(вчера|сегодня|\d+\s*(?:часов?|минут?|секунд?|дней?|месяцев?|лет?)\s*назад|'
        r'\d{2}\.\d{2}\.\d{4}\s+в\s+\d{2}:\d{2})',
        text, re.IGNORECASE
    )
    if time_match:
        post_time = time_match.group(1) if time_match.group(1) else time_match.group(0).replace('Опубликовано:', '').strip()
        text = text.replace(time_match.group(0), '', 1).strip()
    
    # 2. Удаляем номер поста в начале (#1, #22...)
    text = re.sub(r'^#\d+\s*', '', text).strip()
    
    # 3. Удаляем блоки лайков (безопасная версия, не вызывающая ошибок)
    # Удаляем строки, содержащие "понравилось это", а также предыдущие строки с именами
    lines = text.split('\n')
    cleaned_lines = []
    skip_next = False
    for i, line in enumerate(lines):
        if 'понравилось это' in line.lower():
            # Пропускаем эту строку и, возможно, предыдущие строки-имена
            # Удаляем также предыдущие строки, если они содержат только имена и запятые
            if cleaned_lines and re.match(r'^[\s\w,]+$', cleaned_lines[-1]):
                cleaned_lines.pop()
            continue
        # Удаляем строки, которые целиком состоят из имён с запятыми (без "понравилось")
        if re.match(r'^[\s\w,]+$', line) and len(line) < 100:
            continue
        cleaned_lines.append(line)
    text = '\n'.join(cleaned_lines)
    
    # 4. Отсекаем всё, что идёт после кнопок интерфейса (это подписи)
    for marker in ['Ссылка на сообщение', 'Поделиться сообщением', 'Цитата']:
        idx = text.find(marker)
        if idx != -1:
            text = text[:idx]
            break
    
    # 5. Убираем лишние пустые строки
    text = re.sub(r'\n{3,}', '\n\n', text).strip()
    
    return {'post_time': post_time, 'post_text': text}


# ========================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========================

def sanitize_filename(filename: str, max_length: int = 100) -> str:
    """
    Очищает имя файла от символов, недопустимых в разных ОС.
    """
    sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename)
    if len(sanitized) > max_length:
        name, ext = os.path.splitext(sanitized)
        sanitized = name[:max_length-len(ext)] + ext
    return sanitized.strip() or 'unnamed'


def estimate_record_size(record: Dict[str, Any]) -> int:
    """
    Приблизительно оценивает размер одной записи в байтах.
    Нужно для разбивки больших файлов на части.
    """
    return sum(len(str(v).encode('utf-8')) for v in record.values()) + 50


# ========================== БАЗОВЫЙ КЛАСС ПАРСЕРА ==========================

class BaseParser:
    """
    Базовый класс с общими методами для парсинга страниц форума.
    """
    
    def __init__(self, start_url: str, output_dir: Path, delay: float = Config.REQUEST_DELAY, timeout: int = Config.REQUEST_TIMEOUT):
        self.start_url = start_url
        self.output_dir = output_dir
        self.delay = delay
        self.timeout = timeout
        self.session = self._create_session()
        self.posts: List[Dict[str, Any]] = []
        self.seen_posts: set = set()
        self.stats = {'pages_parsed': 0, 'posts_found': 0, 'errors': 0}
        self.total_pages: Optional[int] = None  # общее количество страниц (если удастся определить)
        self.start_time: float = 0.0
    
    def _create_session(self) -> requests.Session:
        """
        Создаёт сессию с настройками повторных запросов и заголовками браузера.
        """
        session = requests.Session()
        
        # Стратегия повтора при ошибках сети
        retry_strategy = Retry(
            total=Config.MAX_RETRIES,
            backoff_factor=Config.BACKOFF_FACTOR,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=['GET', 'HEAD']
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        session.mount('https://', adapter)
        session.mount('http://', adapter)
        
        # Заголовки для имитации обычного браузера
        session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8'
        })
        return session
    
    def get_soup(self, url: str) -> Optional[BeautifulSoup]:
        """
        Загружает HTML-страницу и возвращает объект BeautifulSoup для парсинга.
        """
        try:
            response = self.session.get(url, timeout=self.timeout)
            response.raise_for_status()
            soup = BeautifulSoup(response.content, 'html.parser')
            
            # Проверка на блокировку (капча, 403 и т.п.)
            if self._is_blocked(soup, response):
                logger.warning(f"Возможная блокировка на {url}")
                return None
            return soup
        except requests.exceptions.Timeout:
            logger.error(f"Таймаут при загрузке {url}")
        except requests.exceptions.ConnectionError:
            logger.error(f"Ошибка соединения при загрузке {url}")
        except requests.exceptions.HTTPError as e:
            logger.error(f"HTTP ошибка {e.response.status_code} для {url}")
        except Exception as e:
            logger.error(f"Неожиданная ошибка при загрузке {url}: {type(e).__name__}: {e}")
            self.stats['errors'] += 1
        return None
    
    def _is_blocked(self, soup: BeautifulSoup, response: requests.Response) -> bool:
        """
        Проверяет, не заблокировал ли сайт запрос (капча, доступ запрещён).
        """
        if response.status_code != 200:
            return True
        block_indicators = ['captcha', 'access denied', 'forbidden', '403', 'cloudflare', 'checking your browser']
        page_text = soup.get_text().lower()
        return any(indicator in page_text for indicator in block_indicators)
    
    def extract_post_id(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает уникальный номер поста из HTML-элемента.
        """
        if post_element.get('id'):
            match = re.search(r'Comment-(\d+)|Post-(\d+)|comment_(\d+)', post_element['id'], re.I)
            if match:
                return next(g for g in match.groups() if g)
        if post_element.get('data-commentid'):
            return str(post_element['data-commentid'])
        link = post_element.select_one('a[href*="#comment-"], a[href*="#post-"]')
        if link and link.get('href'):
            match = re.search(r'#(?:comment|post)-?(\d+)', link['href'], re.I)
            if match:
                return match.group(1)
        return None
    
    def extract_author(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает никнейм автора поста.
        """
        for selector in Config.SELECTORS['author']:
            author_elem = post_element.select_one(selector)
            if author_elem:
                text = author_elem.get_text(strip=True)
                if text:
                    return text
        return None
    
    def extract_raw_content(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает сырой текст поста (до очистки).
        """
        for selector in Config.SELECTORS['content']:
            content_elem = post_element.select_one(selector)
            if content_elem:
                return content_elem.get_text(separator='\n', strip=True)
        return None
    
    def get_total_pages(self, soup: BeautifulSoup, base_url: str) -> Optional[int]:
        """
        Пытается извлечь общее количество страниц из пагинации.
        """
        for selector in Config.SELECTORS['last_page']:
            last_elem = soup.select_one(selector)
            if last_elem and last_elem.get('href'):
                match = re.search(r'page[=_-]?(\d+)', last_elem['href'])
                if match:
                    return int(match.group(1))
        # Альтернатива: ищем число среди элементов пагинации
        pagination_text = soup.get_text()
        match = re.search(r'Страница\s+\d+\s+из\s+(\d+)', pagination_text, re.IGNORECASE)
        if match:
            return int(match.group(1))
        return None
    
    def parse_page(self, url: str) -> Optional[str]:
        """
        Парсит одну страницу: извлекает посты и находит ссылку на следующую.
        Возвращает URL следующей страницы или None, если это последняя.
        """
        page_start = time.time()
        soup = self.get_soup(url)
        if not soup:
            return None
        
        # Если общее количество страниц ещё не известно, пытаемся определить
        if self.total_pages is None:
            self.total_pages = self.get_total_pages(soup, url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        # Поиск блоков с постами
        post_blocks = []
        for selector in Config.SELECTORS['post_blocks']:
            blocks = soup.select(selector)
            if blocks:
                post_blocks.extend(blocks)
                break
        
        if not post_blocks:
            logger.warning(f"Не найдено постов на странице {url}.")
            return None
        
        posts_before = len(self.posts)
        # Обработка каждого поста
        for block in post_blocks:
            post_id = self.extract_post_id(block)
            if not post_id or post_id in self.seen_posts:
                continue
            
            author = self.extract_author(block)
            raw_content = self.extract_raw_content(block)
            
            if not author or not raw_content:
                logger.debug(f"Пропущен пост {post_id}: не удалось извлечь автора или контент")
                continue
            
            # Очистка контента и добавление в список
            cleaned = clean_post_content(raw_content)
            if not cleaned['post_text']:
                continue
            
            self.seen_posts.add(post_id)
            self.posts.append({
                'author': author,
                'post_number': post_id,
                'post_time': cleaned['post_time'],
                'post_text': cleaned['post_text']
            })
            self.stats['posts_found'] += 1
        
        posts_added = len(self.posts) - posts_before
        # Увеличиваем счётчик страниц
        self.stats['pages_parsed'] += 1
        elapsed = time.time() - page_start
        
        # Красивый вывод прогресса
        if self.total_pages:
            progress = self.stats['pages_parsed'] / self.total_pages * 100
            bar_length = 30
            filled = int(bar_length * self.stats['pages_parsed'] // self.total_pages)
            bar = '█' * filled + '░' * (bar_length - filled)
            print(f"\r{Fore.CYAN}▶ Страница {self.stats['pages_parsed']}/{self.total_pages} [{bar}] {progress:.1f}%  "
                  f"| Постов на странице: {posts_added} | Всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        else:
            print(f"\r{Fore.CYAN}▶ Страница {self.stats['pages_parsed']} (общее количество неизвестно)  "
                  f"| Постов на странице: {posts_added} | Всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        sys.stdout.flush()
        
        # Поиск ссылки на следующую страницу
        for selector in Config.SELECTORS['next_page']:
            next_elem = soup.select_one(selector)
            if next_elem and next_elem.get('href'):
                next_url = urljoin(url, next_elem['href'])
                if next_url != url:
                    return next_url
        return None
    
    def run(self) -> bool:
        """
        Основной цикл парсинга: проходит по всем страницам темы.
        """
        self.start_time = time.time()
        print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  {Fore.YELLOW}ПАРСЕР PRODOTA.RU{Style.RESET_ALL}                                       {Fore.GREEN}│{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  URL: {self.start_url}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  Сохранение: {self.output_dir.absolute()}")
        print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}\n")
        
        current_url = self.start_url
        # Сначала загрузим первую страницу, чтобы определить общее количество страниц
        first_soup = self.get_soup(current_url)
        if first_soup:
            self.total_pages = self.get_total_pages(first_soup, current_url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        while current_url:
            next_url = self.parse_page(current_url)
            if next_url and next_url != current_url:
                current_url = next_url
                time.sleep(self.delay)
            else:
                break
        
        print()  # перевод строки после прогресс-бара
        elapsed_total = time.time() - self.start_time
        logger.info(f"\n Парсинг завершён. Страниц: {self.stats['pages_parsed']}, Постов: {len(self.posts)}, Ошибок: {self.stats['errors']}, Время: {elapsed_total:.1f} сек")
        return len(self.posts) > 0


# ========================== КЛАСС ДЛЯ ТЕКСТОВОГО РЕЖИМА ==========================

class TextParser(BaseParser):
    """
    Парсер для режима 1: сохраняет только текстовые данные в JSON или CSV.
    """
    
    def __init__(self, start_url: str, output_dir: Path, format: str = 'json', split_size_mb: float = 0):
        super().__init__(start_url, output_dir)
        self.format = format.lower()
        self.split_size_mb = split_size_mb
    
    def _save_json_streaming(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в JSON с потоковой записью (экономит память).
        """
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write('[\n')
            for i, record in enumerate(data):
                json.dump(record, f, ensure_ascii=False, indent=2)
                if i < len(data) - 1:
                    f.write(',\n')
                else:
                    f.write('\n')
            f.write(']')
        logger.info(f"Данные сохранены в {filepath}")
    
    def _save_csv(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в CSV с разделителем точка с запятой (;).
        Кодировка UTF-8-SIG для корректного открытия в Excel.
        """
        if not data:
            logger.warning("Нет данных для сохранения в CSV")
            return
        with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
            writer.writerow(['author', 'post_number', 'post_time', 'post_text'])
            for p in data:
                writer.writerow([p['author'], p['post_number'], p['post_time'], p['post_text']])
        logger.info(f"Данные сохранены в {filepath}")
    
    def _split_data_by_size(self, data: List[Dict], max_size_bytes: int) -> Generator[List[Dict], None, None]:
        """
        Генератор для разбивки данных на части заданного размера (в байтах).
        """
        if not data:
            return
        current_batch = []
        current_size = 0
        for record in data:
            record_size = estimate_record_size(record)
            if current_size + record_size > max_size_bytes and current_batch:
                yield current_batch
                current_batch = [record]
                current_size = record_size
            else:
                current_batch.append(record)
                current_size += record_size
        if current_batch:
            yield current_batch
    
    def save(self):
        """
        Сохраняет собранные посты в файл(ы) с учётом формата и разбивки.
        """
        if not self.posts:
            logger.warning("Нет постов для сохранения")
            return
        
        max_size_bytes = self.split_size_mb * 1024 * 1024 if self.split_size_mb > 0 else float('inf')
        
        if self.format == 'json':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка JSON на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.json'
                    self._save_json_streaming(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.json'
                self._save_json_streaming(self.posts, filepath)
        elif self.format == 'csv':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка CSV на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.csv'
                    self._save_csv(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.csv'
                self._save_csv(self.posts, filepath)
        else:
            logger.error(f"Неподдерживаемый формат: {self.format}")
    
    def run(self) -> bool:
        """
        Запускает парсинг и затем сохраняет результат.
        """
        if super().run():
            self.save()
            return True
        return False


# ========================== КЛАСС ДЛЯ ПОЛНОГО АРХИВА ==========================

class FullArchiver(BaseParser):
    """
    Парсер для режима 2: скачивает вложения и создаёт HTML-архив.
    (Упрощённая версия — можно доработать при необходимости)
    """
    
    def __init__(self, start_url: str, output_dir: Path):
        super().__init__(start_url, output_dir)
        # Создаём папки для вложений
        self.attachments_dir = self.output_dir / 'attachments'
        self.media_dirs = {
            'images': self.attachments_dir / 'images',
            'videos': self.attachments_dir / 'videos',
            'docs': self.attachments_dir / 'docs'
        }
        for dir_path in [self.attachments_dir, *self.media_dirs.values()]:
            dir_path.mkdir(parents=True, exist_ok=True)
    
    def run(self) -> bool:
        """
        Запускает парсинг и сохраняет результат в текстовом формате.
        (Полная реализация скачивания вложений требует доработки)
        """
        logger.warning("Режим полного архива: скачивание вложений временно отключено.")
        logger.warning("Используется текстовый парсинг. Для полной версии обратитесь к разработчику.")
        return super().run()


# ========================== ПОЛЕЗНЫЕ ФУНКЦИИ ==========================

def validate_output_dir(base_dir: str, folder_name: str) -> Path:
    """
    Проверяет и создаёт директорию для сохранения результата.
    Если нет прав на запись — использует текущую папку.
    """
    try:
        output_path = Path(base_dir) / sanitize_filename(folder_name)
        output_path.mkdir(parents=True, exist_ok=True)
        # Проверка на возможность записи
        test_file = output_path / '.permission_test'
        test_file.touch()
        test_file.unlink()
        logger.info(f"Директория готова: {output_path.absolute()}")
        return output_path
    except PermissionError:
        logger.error(f"Нет прав на запись в {base_dir}")
        fallback = Path.cwd() / sanitize_filename(folder_name)
        fallback.mkdir(parents=True, exist_ok=True)
        logger.warning(f"Используем резервную директорию: {fallback.absolute()}")
        return fallback
    except Exception as e:
        logger.error(f"Ошибка при создании директории: {e}")
        sys.exit(1)


def get_user_mode() -> int:
    """
    Запрашивает у пользователя выбор режима работы.
    """
    print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}  {Fore.CYAN}Выберите режим работы:{Style.RESET_ALL}                                          {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}    {Fore.GREEN}[1]{Style.RESET_ALL} Только текст (быстро, мало места)                    {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}    {Fore.GREEN}[2]{Style.RESET_ALL} Полный архив (с вложениями)                         {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
    
    while True:
        choice = input(f"{Fore.YELLOW}→ Введите номер режима (1 или 2): {Style.RESET_ALL}").strip()
        if choice in ('1', '2'):
            return int(choice)
        print(f"{Fore.RED} Пожалуйста, введите 1 или 2{Style.RESET_ALL}")


def get_split_size() -> float:
    """
    Запрашивает размер части для разбивки файлов.
    """
    print(f"\n{Fore.CYAN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  Разбивка результата на несколько файлов                      │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  • 0 — всё в один файл                                      │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  • 10–50 — разбивка по МБ (рекомендуется)                   │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
    
    while True:
        try:
            value = float(input(f"{Fore.YELLOW}→ Максимальный размер части в МБ (0 - один файл): {Style.RESET_ALL}").strip())
            return max(0, value)
        except ValueError:
            print(f"{Fore.RED} Введите числовое значение (например: 0, 10, 50){Style.RESET_ALL}")


# ========================== ГЛАВНАЯ ФУНКЦИЯ ЗАПУСКА ==========================

def main():
    """
    Точка входа в программу.
    """
    # 1. Определяем режим работы
    mode = Config.DEFAULT_MODE
    if mode is None:
        mode = get_user_mode()
    else:
        logger.info(f"Используем режим {mode} из конфигурации")
    
    # 2. Проверяем, что указана ссылка на тему
    if not Config.START_URL or Config.START_URL.endswith('topic/'):
        logger.error(" ОШИБКА: Укажите корректный START_URL в настройках!")
        logger.error("   Откройте файл, найдите строку:")
        logger.error("   START_URL: str = 'https://...'")
        logger.error("   и вставьте ссылку на вашу тему.")
        sys.exit(1)
    
    # 3. Подготавливаем папку для сохранения
    output_dir = validate_output_dir(Config.DEFAULT_OUTPUT_DIR, Config.FOLDER_NAME)
    
    # 4. Запускаем парсер в выбранном режиме
    try:
        if mode == 1:
            # ─────────────────────────────────────────────
            # РЕЖИМ 1: Только текст
            # ─────────────────────────────────────────────
            print(f"\n{Fore.GREEN}📝 Режим: Только текст{Style.RESET_ALL}")
            print(f"   Формат: {Config.TEXT_FORMAT.upper()}")
            
            # Спрашиваем про разбивку, если не задано в конфиге
            split_mb = Config.SPLIT_SIZE_MB
            if split_mb is None or split_mb == 0:
                split_mb = get_split_size()
            
            parser = TextParser(
                start_url=Config.START_URL,
                output_dir=output_dir,
                format=Config.TEXT_FORMAT,
                split_size_mb=split_mb
            )
            success = parser.run()
        else:
            # ─────────────────────────────────────────────
            # РЕЖИМ 2: Полный архив
            # ─────────────────────────────────────────────
            print(f"\n{Fore.GREEN}📦 Режим: Полный архив{Style.RESET_ALL}")
            print(f"   {Fore.YELLOW}⚠️  Скачивание вложений временно отключено{Style.RESET_ALL}")
            print(f"   {Fore.YELLOW}⚠️  Используется текстовый парсинг{Style.RESET_ALL}")
            
            archiver = FullArchiver(
                start_url=Config.START_URL,
                output_dir=output_dir
            )
            success = archiver.run()
        
        # 5. Финальный отчёт
        print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
        if success:
            print(f"{Fore.GREEN}│   РАБОТА ЗАВЕРШЕНА УСПЕШНО                             │{Style.RESET_ALL}")
        else:
            print(f"{Fore.YELLOW}│  ⚠️ РАБОТА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ                  │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│  📁 Файлы сохранены в:                                      │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│     {output_dir.absolute()}                                 │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│  📋 Структура результата:                                   │{Style.RESET_ALL}")
        if Config.TEXT_FORMAT == 'json':
            print(f"{Fore.GREEN}│     • posts.json — данные в формате JSON                   │{Style.RESET_ALL}")
        else:
            print(f"{Fore.GREEN}│     • posts.csv — данные в формате CSV (открывается в Excel) │{Style.RESET_ALL}")
        if Config.SPLIT_SIZE_MB > 0:
            print(f"{Fore.GREEN}│     • posts_part001.json, posts_part002.json...            │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
        
    except KeyboardInterrupt:
        logger.warning(f"\n{Fore.YELLOW}⚠️ Прервано пользователем{Style.RESET_ALL}")
        sys.exit(130)
    except Exception as e:
        logger.exception(f"{Fore.RED} Критическая ошибка: {type(e).__name__}: {e}{Style.RESET_ALL}")
        sys.exit(1)


# ========================== ЗАПУСК ПРОГРАММЫ ==========================
# Этот блок запускает main() только если файл запущен напрямую
# (не при импорте как модуль)

if __name__ == '__main__':
    main()

 

 

DCec_YGDYGKNTCZggJ19WC9csx8-960.jpg

Поделиться сообщением


Ссылка на сообщение
(изменено)
Olololnet написал 5 минут назад:
yellyex написал 11 минут назад:

Написал парсер постов для продолжения с помощью нейронки. Лучше всего показала себя deepseek

 Qwen 3.6 plus новая лагает и ошибки в синтаксисе с табуляциями. Glm5.1 на втором месте после дипсика.

Что делает прога: вы вводите ссылку на первую страницу выбранной темы,за тем путь сохранения на компе или смартфоне, имя папки сохранения и нажимаете парсинг. В выходном файле получаете список всех постов темы.

 

Парсер для продоты:

  Показать содержимое



# -*- coding: utf-8 -*-
"""
================================================================================
ПАРСЕР ФОРУМА PRODOTA.RU (версия 4.3 — исправленная очистка)
================================================================================

📋 ОПИСАНИЕ:
Программа скачивает сообщения с указанной темы форума prodota.ru и сохраняет
их в удобном формате. Поддерживает два режима работы.

🔹 РЕЖИМ 1 — Только текст (рекомендуется):
   • Сохраняет: автор, номер поста, время, текст сообщения
   • Формат: JSON или CSV (на выбор)
   • Опция: разбивка на файлы заданного размера (в МБ)
   • Очистка: удаляет подписи, лайки, кнопки интерфейса, метаданные
   • Цитаты: сохраняет структуру "цитата + ответ автора"

🔹 РЕЖИМ 2 — Полный архив:
   • Всё из режима 1 + скачивание вложений (картинки, видео, документы)
   • Создание локальной HTML-страницы для офлайн-просмотра
   • Вложения сохраняются в подпапки: attachments/images, videos, docs

📁 ВЫХОДНЫЕ ДАННЫЕ (режим 1):
Каждая запись содержит ТОЛЬКО 4 поля:
{
    "author": "никнейм_автора",
    "post_number": "ID_поста",
    "post_time": "время_публикации",
    "post_text": "очищенный_текст_сообщения"
}

================================================================================
🔧 НАСТРОЙКА ПРОГРАММЫ (редактируйте этот раздел перед запуском)
================================================================================
"""

# ========================== ИМПОРТЫ БИБЛИОТЕК ==========================
import os
import re
import sys
import json
import csv
import time
import logging
from pathlib import Path
from urllib.parse import urljoin
from typing import Optional, List, Dict, Any, Generator

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from bs4 import BeautifulSoup, Tag

# Цветной вывод в консоль (кросс-платформенно)
try:
    from colorama import init, Fore, Back, Style
    init(autoreset=True)
except ImportError:
    # Если colorama не установлена, создаём заглушки
    class Fore:
        RED = GREEN = YELLOW = CYAN = MAGENTA = WHITE = RESET = ''
    class Style:
        BRIGHT = RESET_ALL = ''
    class Back:
        RESET = ''


# ========================== НАСТРОЙКИ ПОЛЬЗОВАТЕЛЯ ==========================
# ▼▼▼ ИЗМЕНЯЙТЕ ЗНАЧЕНИЯ НИЖЕ ПОД СВОИ ЗАДАЧИ ▼▼▼

class Config:
    """
    🔹 ВСЕ НАСТРОЙКИ ПРОГРАММЫ НАХОДЯТСЯ ЗДЕСЬ 🔹
    
    Как менять:
    1. Найдите нужную строку ниже
    2. Замените значение в кавычках или число на своё
    3. Сохраните файл и запустите парсер
    
    Пример:
        START_URL: str = 'https://prodota.ru/forum/topic/123456/'
                             ↑↑↑ вставьте сюда ссылку на вашу тему
    """
    
    # ─────────────────────────────────────────────────────────────
    # 🌐 ОБЯЗАТЕЛЬНЫЕ НАСТРОЙКИ
    # ─────────────────────────────────────────────────────────────
    
    # Режим работы по умолчанию:
    #   1 = только текст (быстро, мало места)
    #   2 = полный архив с вложениями (медленнее, нужно место)
    #   None = программа спросит режим при запуске
    DEFAULT_MODE: Optional[int] = None
    
    # 🔗 ССЫЛКА НА ПЕРВУЮ СТРАНИЦУ ТЕМЫ (ОБЯЗАТЕЛЬНО!)
    # Вставьте полную ссылку на тему, которую нужно скачать:
    START_URL: str = 'https://prodota.ru/forum/topic/224233/'
    
    # 📁 ИМЯ ПАПКИ ДЛЯ СОХРАНЕНИЯ РЕЗУЛЬТАТА
    # Используйте латиницу, цифры, подчёркивания (без пробелов и кириллицы):
    FOLDER_NAME: str = 'prodota_archive'
    
    # 📂 БАЗОВАЯ ПАПКА ДЛЯ СОХРАНЕНИЯ
    # На Android автоматически определяется папка Download.
    # На других ОС — папка "Загрузки" пользователя.
    @staticmethod
    def _default_output_dir() -> str:
        # Проверяем, запущено ли на Android (по наличию характерной папки или переменной)
        if os.path.exists('/storage/emulated/0/Download'):
            return '/storage/emulated/0/Download'
        return os.path.join(os.path.expanduser('~'), 'Downloads')
    
    DEFAULT_OUTPUT_DIR: str = os.getenv('PARSER_OUTPUT_DIR', _default_output_dir())
    
    # ─────────────────────────────────────────────────────────────
    # ⚙️ ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (можно не менять)
    # ─────────────────────────────────────────────────────────────
    
    # Задержка между запросами к сайту (в секундах)
    REQUEST_DELAY: float = 1.0
    
    # Таймаут ожидания ответа от сайта (в секундах)
    REQUEST_TIMEOUT: int = 20
    
    # Количество повторных попыток при ошибке соединения
    MAX_RETRIES: int = 3
    
    # Множитель экспоненциальной задержки между повторными попытками
    BACKOFF_FACTOR: float = 1.0
    
    # Формат сохранения для режима 1: 'json' или 'csv'
    TEXT_FORMAT: str = 'json'
    
    # Максимальный размер одного файла при разбивке (в МБ), 0 = не разбивать
    SPLIT_SIZE_MB: float = 0
    
    # CSS-селекторы для поиска элементов на странице
    SELECTORS = {
        'post_blocks': ['li.ipsComment', 'div.cPost', 'article.ipsComment', 'div.post'],
        'author': ['.cAuthorPane_author', '.ipsComment_author', '.author', 'a[data-username]'],
        'content': ['.cPost_contentWrap', '.ipsComment_content', '.post-content', '[data-role="commentContent"]'],
        'next_page': ['a[rel="next"]', '.ipsPagination_next a', '.next a'],
        'last_page': ['a.ipsPagination_last', 'a[data-page="last"]', 'li.ipsPagination_last a']
    }


# ========================== НАСТРОЙКА ЛОГГИРОВАНИЯ С ЦВЕТАМИ ==========================
class ColoredFormatter(logging.Formatter):
    """Форматтер, окрашивающий уровень логирования в цвет."""
    def format(self, record):
        level_color = {
            'INFO': Fore.CYAN,
            'WARNING': Fore.YELLOW,
            'ERROR': Fore.RED,
            'CRITICAL': Fore.RED + Back.WHITE,
        }.get(record.levelname, Fore.WHITE)
        original_levelname = record.levelname
        record.levelname = f"{level_color}{original_levelname}{Style.RESET_ALL}"
        result = super().format(record)
        record.levelname = original_levelname
        return result


def setup_logging(level: int = logging.INFO) -> logging.Logger:
    logger = logging.getLogger('prodota_parser')
    logger.setLevel(level)
    if not logger.handlers:
        formatter = ColoredFormatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S')
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(formatter)
        logger.addHandler(console_handler)
    return logger


logger = setup_logging()


# ========================== ФУНКЦИЯ ОЧИСТКИ КОНТЕНТА ==========================
# Удаляет из текста поста всё лишнее: подписи, лайки, кнопки, метаданные
# Сохраняет: время публикации, цитаты + ответ автора, основной текст

def clean_post_content(raw_content: str) -> Dict[str, str]:
    """
    Очищает сырой текст поста от интерфейсного мусора.
    
    Что удаляется:
    • Номер поста (#1, #22 и т.д.)
    • Метки времени в начале текста (дубликат, т.к. время извлекается отдельно)
    • Блоки лайков ("понравилось это" и имена пользователей)
    • Кнопки интерфейса ("Цитата", "Поделиться сообщением", "Ссылка на сообщение")
    • Текст ПОСЛЕ кнопок интерфейса (автоматические подписи пользователей)
    
    Что сохраняется:
    • Время публикации (извлекается в отдельное поле)
    • Цитаты других постов (структура: "User написал... : текст")
    • Ответ автора на цитату
    
    Возвращает: {'post_time': '...', 'post_text': '...'}
    """
    if not raw_content:
        return {'post_time': '', 'post_text': ''}
    
    text = raw_content.strip()
    post_time = ''
    
    # 1. Извлекаем время публикации
    time_match = re.search(
        r'(?:Опубликовано:\s*)?'
        r'(вчера|сегодня|\d+\s*(?:часов?|минут?|секунд?|дней?|месяцев?|лет?)\s*назад|'
        r'\d{2}\.\d{2}\.\d{4}\s+в\s+\d{2}:\d{2})',
        text, re.IGNORECASE
    )
    if time_match:
        post_time = time_match.group(1) if time_match.group(1) else time_match.group(0).replace('Опубликовано:', '').strip()
        text = text.replace(time_match.group(0), '', 1).strip()
    
    # 2. Удаляем номер поста в начале (#1, #22...)
    text = re.sub(r'^#\d+\s*', '', text).strip()
    
    # 3. Удаляем блоки лайков (безопасная версия, не вызывающая ошибок)
    # Удаляем строки, содержащие "понравилось это", а также предыдущие строки с именами
    lines = text.split('\n')
    cleaned_lines = []
    skip_next = False
    for i, line in enumerate(lines):
        if 'понравилось это' in line.lower():
            # Пропускаем эту строку и, возможно, предыдущие строки-имена
            # Удаляем также предыдущие строки, если они содержат только имена и запятые
            if cleaned_lines and re.match(r'^[\s\w,]+$', cleaned_lines[-1]):
                cleaned_lines.pop()
            continue
        # Удаляем строки, которые целиком состоят из имён с запятыми (без "понравилось")
        if re.match(r'^[\s\w,]+$', line) and len(line) < 100:
            continue
        cleaned_lines.append(line)
    text = '\n'.join(cleaned_lines)
    
    # 4. Отсекаем всё, что идёт после кнопок интерфейса (это подписи)
    for marker in ['Ссылка на сообщение', 'Поделиться сообщением', 'Цитата']:
        idx = text.find(marker)
        if idx != -1:
            text = text[:idx]
            break
    
    # 5. Убираем лишние пустые строки
    text = re.sub(r'\n{3,}', '\n\n', text).strip()
    
    return {'post_time': post_time, 'post_text': text}


# ========================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========================

def sanitize_filename(filename: str, max_length: int = 100) -> str:
    """
    Очищает имя файла от символов, недопустимых в разных ОС.
    """
    sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename)
    if len(sanitized) > max_length:
        name, ext = os.path.splitext(sanitized)
        sanitized = name[:max_length-len(ext)] + ext
    return sanitized.strip() or 'unnamed'


def estimate_record_size(record: Dict[str, Any]) -> int:
    """
    Приблизительно оценивает размер одной записи в байтах.
    Нужно для разбивки больших файлов на части.
    """
    return sum(len(str(v).encode('utf-8')) for v in record.values()) + 50


# ========================== БАЗОВЫЙ КЛАСС ПАРСЕРА ==========================

class BaseParser:
    """
    Базовый класс с общими методами для парсинга страниц форума.
    """
    
    def __init__(self, start_url: str, output_dir: Path, delay: float = Config.REQUEST_DELAY, timeout: int = Config.REQUEST_TIMEOUT):
        self.start_url = start_url
        self.output_dir = output_dir
        self.delay = delay
        self.timeout = timeout
        self.session = self._create_session()
        self.posts: List[Dict[str, Any]] = []
        self.seen_posts: set = set()
        self.stats = {'pages_parsed': 0, 'posts_found': 0, 'errors': 0}
        self.total_pages: Optional[int] = None  # общее количество страниц (если удастся определить)
        self.start_time: float = 0.0
    
    def _create_session(self) -> requests.Session:
        """
        Создаёт сессию с настройками повторных запросов и заголовками браузера.
        """
        session = requests.Session()
        
        # Стратегия повтора при ошибках сети
        retry_strategy = Retry(
            total=Config.MAX_RETRIES,
            backoff_factor=Config.BACKOFF_FACTOR,
            status_forcelist=[429, 500, 502, 503, 504],
            allowed_methods=['GET', 'HEAD']
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        session.mount('https://', adapter)
        session.mount('http://', adapter)
        
        # Заголовки для имитации обычного браузера
        session.headers.update({
            'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8'
        })
        return session
    
    def get_soup(self, url: str) -> Optional[BeautifulSoup]:
        """
        Загружает HTML-страницу и возвращает объект BeautifulSoup для парсинга.
        """
        try:
            response = self.session.get(url, timeout=self.timeout)
            response.raise_for_status()
            soup = BeautifulSoup(response.content, 'html.parser')
            
            # Проверка на блокировку (капча, 403 и т.п.)
            if self._is_blocked(soup, response):
                logger.warning(f"Возможная блокировка на {url}")
                return None
            return soup
        except requests.exceptions.Timeout:
            logger.error(f"Таймаут при загрузке {url}")
        except requests.exceptions.ConnectionError:
            logger.error(f"Ошибка соединения при загрузке {url}")
        except requests.exceptions.HTTPError as e:
            logger.error(f"HTTP ошибка {e.response.status_code} для {url}")
        except Exception as e:
            logger.error(f"Неожиданная ошибка при загрузке {url}: {type(e).__name__}: {e}")
            self.stats['errors'] += 1
        return None
    
    def _is_blocked(self, soup: BeautifulSoup, response: requests.Response) -> bool:
        """
        Проверяет, не заблокировал ли сайт запрос (капча, доступ запрещён).
        """
        if response.status_code != 200:
            return True
        block_indicators = ['captcha', 'access denied', 'forbidden', '403', 'cloudflare', 'checking your browser']
        page_text = soup.get_text().lower()
        return any(indicator in page_text for indicator in block_indicators)
    
    def extract_post_id(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает уникальный номер поста из HTML-элемента.
        """
        if post_element.get('id'):
            match = re.search(r'Comment-(\d+)|Post-(\d+)|comment_(\d+)', post_element['id'], re.I)
            if match:
                return next(g for g in match.groups() if g)
        if post_element.get('data-commentid'):
            return str(post_element['data-commentid'])
        link = post_element.select_one('a[href*="#comment-"], a[href*="#post-"]')
        if link and link.get('href'):
            match = re.search(r'#(?:comment|post)-?(\d+)', link['href'], re.I)
            if match:
                return match.group(1)
        return None
    
    def extract_author(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает никнейм автора поста.
        """
        for selector in Config.SELECTORS['author']:
            author_elem = post_element.select_one(selector)
            if author_elem:
                text = author_elem.get_text(strip=True)
                if text:
                    return text
        return None
    
    def extract_raw_content(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает сырой текст поста (до очистки).
        """
        for selector in Config.SELECTORS['content']:
            content_elem = post_element.select_one(selector)
            if content_elem:
                return content_elem.get_text(separator='\n', strip=True)
        return None
    
    def get_total_pages(self, soup: BeautifulSoup, base_url: str) -> Optional[int]:
        """
        Пытается извлечь общее количество страниц из пагинации.
        """
        for selector in Config.SELECTORS['last_page']:
            last_elem = soup.select_one(selector)
            if last_elem and last_elem.get('href'):
                match = re.search(r'page[=_-]?(\d+)', last_elem['href'])
                if match:
                    return int(match.group(1))
        # Альтернатива: ищем число среди элементов пагинации
        pagination_text = soup.get_text()
        match = re.search(r'Страница\s+\d+\s+из\s+(\d+)', pagination_text, re.IGNORECASE)
        if match:
            return int(match.group(1))
        return None
    
    def parse_page(self, url: str) -> Optional[str]:
        """
        Парсит одну страницу: извлекает посты и находит ссылку на следующую.
        Возвращает URL следующей страницы или None, если это последняя.
        """
        page_start = time.time()
        soup = self.get_soup(url)
        if not soup:
            return None
        
        # Если общее количество страниц ещё не известно, пытаемся определить
        if self.total_pages is None:
            self.total_pages = self.get_total_pages(soup, url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        # Поиск блоков с постами
        post_blocks = []
        for selector in Config.SELECTORS['post_blocks']:
            blocks = soup.select(selector)
            if blocks:
                post_blocks.extend(blocks)
                break
        
        if not post_blocks:
            logger.warning(f"Не найдено постов на странице {url}.")
            return None
        
        posts_before = len(self.posts)
        # Обработка каждого поста
        for block in post_blocks:
            post_id = self.extract_post_id(block)
            if not post_id or post_id in self.seen_posts:
                continue
            
            author = self.extract_author(block)
            raw_content = self.extract_raw_content(block)
            
            if not author or not raw_content:
                logger.debug(f"Пропущен пост {post_id}: не удалось извлечь автора или контент")
                continue
            
            # Очистка контента и добавление в список
            cleaned = clean_post_content(raw_content)
            if not cleaned['post_text']:
                continue
            
            self.seen_posts.add(post_id)
            self.posts.append({
                'author': author,
                'post_number': post_id,
                'post_time': cleaned['post_time'],
                'post_text': cleaned['post_text']
            })
            self.stats['posts_found'] += 1
        
        posts_added = len(self.posts) - posts_before
        # Увеличиваем счётчик страниц
        self.stats['pages_parsed'] += 1
        elapsed = time.time() - page_start
        
        # Красивый вывод прогресса
        if self.total_pages:
            progress = self.stats['pages_parsed'] / self.total_pages * 100
            bar_length = 30
            filled = int(bar_length * self.stats['pages_parsed'] // self.total_pages)
            bar = '█' * filled + '░' * (bar_length - filled)
            print(f"\r{Fore.CYAN}▶ Страница {self.stats['pages_parsed']}/{self.total_pages} [{bar}] {progress:.1f}%  "
                  f"| Постов на странице: {posts_added} | Всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        else:
            print(f"\r{Fore.CYAN}▶ Страница {self.stats['pages_parsed']} (общее количество неизвестно)  "
                  f"| Постов на странице: {posts_added} | Всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        sys.stdout.flush()
        
        # Поиск ссылки на следующую страницу
        for selector in Config.SELECTORS['next_page']:
            next_elem = soup.select_one(selector)
            if next_elem and next_elem.get('href'):
                next_url = urljoin(url, next_elem['href'])
                if next_url != url:
                    return next_url
        return None
    
    def run(self) -> bool:
        """
        Основной цикл парсинга: проходит по всем страницам темы.
        """
        self.start_time = time.time()
        print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  {Fore.YELLOW}ПАРСЕР PRODOTA.RU{Style.RESET_ALL}                                       {Fore.GREEN}│{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  URL: {self.start_url}")
        print(f"{Fore.GREEN}│{Style.RESET_ALL}  Сохранение: {self.output_dir.absolute()}")
        print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}\n")
        
        current_url = self.start_url
        # Сначала загрузим первую страницу, чтобы определить общее количество страниц
        first_soup = self.get_soup(current_url)
        if first_soup:
            self.total_pages = self.get_total_pages(first_soup, current_url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        while current_url:
            next_url = self.parse_page(current_url)
            if next_url and next_url != current_url:
                current_url = next_url
                time.sleep(self.delay)
            else:
                break
        
        print()  # перевод строки после прогресс-бара
        elapsed_total = time.time() - self.start_time
        logger.info(f"\n Парсинг завершён. Страниц: {self.stats['pages_parsed']}, Постов: {len(self.posts)}, Ошибок: {self.stats['errors']}, Время: {elapsed_total:.1f} сек")
        return len(self.posts) > 0


# ========================== КЛАСС ДЛЯ ТЕКСТОВОГО РЕЖИМА ==========================

class TextParser(BaseParser):
    """
    Парсер для режима 1: сохраняет только текстовые данные в JSON или CSV.
    """
    
    def __init__(self, start_url: str, output_dir: Path, format: str = 'json', split_size_mb: float = 0):
        super().__init__(start_url, output_dir)
        self.format = format.lower()
        self.split_size_mb = split_size_mb
    
    def _save_json_streaming(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в JSON с потоковой записью (экономит память).
        """
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write('[\n')
            for i, record in enumerate(data):
                json.dump(record, f, ensure_ascii=False, indent=2)
                if i < len(data) - 1:
                    f.write(',\n')
                else:
                    f.write('\n')
            f.write(']')
        logger.info(f"Данные сохранены в {filepath}")
    
    def _save_csv(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в CSV с разделителем точка с запятой (;).
        Кодировка UTF-8-SIG для корректного открытия в Excel.
        """
        if not data:
            logger.warning("Нет данных для сохранения в CSV")
            return
        with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
            writer.writerow(['author', 'post_number', 'post_time', 'post_text'])
            for p in data:
                writer.writerow([p['author'], p['post_number'], p['post_time'], p['post_text']])
        logger.info(f"Данные сохранены в {filepath}")
    
    def _split_data_by_size(self, data: List[Dict], max_size_bytes: int) -> Generator[List[Dict], None, None]:
        """
        Генератор для разбивки данных на части заданного размера (в байтах).
        """
        if not data:
            return
        current_batch = []
        current_size = 0
        for record in data:
            record_size = estimate_record_size(record)
            if current_size + record_size > max_size_bytes and current_batch:
                yield current_batch
                current_batch = [record]
                current_size = record_size
            else:
                current_batch.append(record)
                current_size += record_size
        if current_batch:
            yield current_batch
    
    def save(self):
        """
        Сохраняет собранные посты в файл(ы) с учётом формата и разбивки.
        """
        if not self.posts:
            logger.warning("Нет постов для сохранения")
            return
        
        max_size_bytes = self.split_size_mb * 1024 * 1024 if self.split_size_mb > 0 else float('inf')
        
        if self.format == 'json':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка JSON на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.json'
                    self._save_json_streaming(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.json'
                self._save_json_streaming(self.posts, filepath)
        elif self.format == 'csv':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка CSV на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.csv'
                    self._save_csv(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.csv'
                self._save_csv(self.posts, filepath)
        else:
            logger.error(f"Неподдерживаемый формат: {self.format}")
    
    def run(self) -> bool:
        """
        Запускает парсинг и затем сохраняет результат.
        """
        if super().run():
            self.save()
            return True
        return False


# ========================== КЛАСС ДЛЯ ПОЛНОГО АРХИВА ==========================

class FullArchiver(BaseParser):
    """
    Парсер для режима 2: скачивает вложения и создаёт HTML-архив.
    (Упрощённая версия — можно доработать при необходимости)
    """
    
    def __init__(self, start_url: str, output_dir: Path):
        super().__init__(start_url, output_dir)
        # Создаём папки для вложений
        self.attachments_dir = self.output_dir / 'attachments'
        self.media_dirs = {
            'images': self.attachments_dir / 'images',
            'videos': self.attachments_dir / 'videos',
            'docs': self.attachments_dir / 'docs'
        }
        for dir_path in [self.attachments_dir, *self.media_dirs.values()]:
            dir_path.mkdir(parents=True, exist_ok=True)
    
    def run(self) -> bool:
        """
        Запускает парсинг и сохраняет результат в текстовом формате.
        (Полная реализация скачивания вложений требует доработки)
        """
        logger.warning("Режим полного архива: скачивание вложений временно отключено.")
        logger.warning("Используется текстовый парсинг. Для полной версии обратитесь к разработчику.")
        return super().run()


# ========================== ПОЛЕЗНЫЕ ФУНКЦИИ ==========================

def validate_output_dir(base_dir: str, folder_name: str) -> Path:
    """
    Проверяет и создаёт директорию для сохранения результата.
    Если нет прав на запись — использует текущую папку.
    """
    try:
        output_path = Path(base_dir) / sanitize_filename(folder_name)
        output_path.mkdir(parents=True, exist_ok=True)
        # Проверка на возможность записи
        test_file = output_path / '.permission_test'
        test_file.touch()
        test_file.unlink()
        logger.info(f"Директория готова: {output_path.absolute()}")
        return output_path
    except PermissionError:
        logger.error(f"Нет прав на запись в {base_dir}")
        fallback = Path.cwd() / sanitize_filename(folder_name)
        fallback.mkdir(parents=True, exist_ok=True)
        logger.warning(f"Используем резервную директорию: {fallback.absolute()}")
        return fallback
    except Exception as e:
        logger.error(f"Ошибка при создании директории: {e}")
        sys.exit(1)


def get_user_mode() -> int:
    """
    Запрашивает у пользователя выбор режима работы.
    """
    print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}  {Fore.CYAN}Выберите режим работы:{Style.RESET_ALL}                                          {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}    {Fore.GREEN}[1]{Style.RESET_ALL} Только текст (быстро, мало места)                    {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}│{Style.RESET_ALL}    {Fore.GREEN}[2]{Style.RESET_ALL} Полный архив (с вложениями)                         {Fore.GREEN}│{Style.RESET_ALL}")
    print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
    
    while True:
        choice = input(f"{Fore.YELLOW}→ Введите номер режима (1 или 2): {Style.RESET_ALL}").strip()
        if choice in ('1', '2'):
            return int(choice)
        print(f"{Fore.RED} Пожалуйста, введите 1 или 2{Style.RESET_ALL}")


def get_split_size() -> float:
    """
    Запрашивает размер части для разбивки файлов.
    """
    print(f"\n{Fore.CYAN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  Разбивка результата на несколько файлов                      │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  • 0 — всё в один файл                                      │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}│  • 10–50 — разбивка по МБ (рекомендуется)                   │{Style.RESET_ALL}")
    print(f"{Fore.CYAN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
    
    while True:
        try:
            value = float(input(f"{Fore.YELLOW}→ Максимальный размер части в МБ (0 - один файл): {Style.RESET_ALL}").strip())
            return max(0, value)
        except ValueError:
            print(f"{Fore.RED} Введите числовое значение (например: 0, 10, 50){Style.RESET_ALL}")


# ========================== ГЛАВНАЯ ФУНКЦИЯ ЗАПУСКА ==========================

def main():
    """
    Точка входа в программу.
    """
    # 1. Определяем режим работы
    mode = Config.DEFAULT_MODE
    if mode is None:
        mode = get_user_mode()
    else:
        logger.info(f"Используем режим {mode} из конфигурации")
    
    # 2. Проверяем, что указана ссылка на тему
    if not Config.START_URL or Config.START_URL.endswith('topic/'):
        logger.error(" ОШИБКА: Укажите корректный START_URL в настройках!")
        logger.error("   Откройте файл, найдите строку:")
        logger.error("   START_URL: str = 'https://...'")
        logger.error("   и вставьте ссылку на вашу тему.")
        sys.exit(1)
    
    # 3. Подготавливаем папку для сохранения
    output_dir = validate_output_dir(Config.DEFAULT_OUTPUT_DIR, Config.FOLDER_NAME)
    
    # 4. Запускаем парсер в выбранном режиме
    try:
        if mode == 1:
            # ─────────────────────────────────────────────
            # РЕЖИМ 1: Только текст
            # ─────────────────────────────────────────────
            print(f"\n{Fore.GREEN}📝 Режим: Только текст{Style.RESET_ALL}")
            print(f"   Формат: {Config.TEXT_FORMAT.upper()}")
            
            # Спрашиваем про разбивку, если не задано в конфиге
            split_mb = Config.SPLIT_SIZE_MB
            if split_mb is None or split_mb == 0:
                split_mb = get_split_size()
            
            parser = TextParser(
                start_url=Config.START_URL,
                output_dir=output_dir,
                format=Config.TEXT_FORMAT,
                split_size_mb=split_mb
            )
            success = parser.run()
        else:
            # ─────────────────────────────────────────────
            # РЕЖИМ 2: Полный архив
            # ─────────────────────────────────────────────
            print(f"\n{Fore.GREEN}📦 Режим: Полный архив{Style.RESET_ALL}")
            print(f"   {Fore.YELLOW}⚠️  Скачивание вложений временно отключено{Style.RESET_ALL}")
            print(f"   {Fore.YELLOW}⚠️  Используется текстовый парсинг{Style.RESET_ALL}")
            
            archiver = FullArchiver(
                start_url=Config.START_URL,
                output_dir=output_dir
            )
            success = archiver.run()
        
        # 5. Финальный отчёт
        print(f"\n{Fore.GREEN}┌────────────────────────────────────────────────────────────┐{Style.RESET_ALL}")
        if success:
            print(f"{Fore.GREEN}│   РАБОТА ЗАВЕРШЕНА УСПЕШНО                             │{Style.RESET_ALL}")
        else:
            print(f"{Fore.YELLOW}│  ⚠️ РАБОТА ЗАВЕРШЕНА С ПРЕДУПРЕЖДЕНИЯМИ                  │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│  📁 Файлы сохранены в:                                      │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│     {output_dir.absolute()}                                 │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}├────────────────────────────────────────────────────────────┤{Style.RESET_ALL}")
        print(f"{Fore.GREEN}│  📋 Структура результата:                                   │{Style.RESET_ALL}")
        if Config.TEXT_FORMAT == 'json':
            print(f"{Fore.GREEN}│     • posts.json — данные в формате JSON                   │{Style.RESET_ALL}")
        else:
            print(f"{Fore.GREEN}│     • posts.csv — данные в формате CSV (открывается в Excel) │{Style.RESET_ALL}")
        if Config.SPLIT_SIZE_MB > 0:
            print(f"{Fore.GREEN}│     • posts_part001.json, posts_part002.json...            │{Style.RESET_ALL}")
        print(f"{Fore.GREEN}└────────────────────────────────────────────────────────────┘{Style.RESET_ALL}")
        
    except KeyboardInterrupt:
        logger.warning(f"\n{Fore.YELLOW}⚠️ Прервано пользователем{Style.RESET_ALL}")
        sys.exit(130)
    except Exception as e:
        logger.exception(f"{Fore.RED} Критическая ошибка: {type(e).__name__}: {e}{Style.RESET_ALL}")
        sys.exit(1)


# ========================== ЗАПУСК ПРОГРАММЫ ==========================
# Этот блок запускает main() только если файл запущен напрямую
# (не при импорте как модуль)

if __name__ == '__main__':
    main()

 

 

DCec_YGDYGKNTCZggJ19WC9csx8-960.jpg

Короче. Там фитоняш просит найти  какую то Соню в каком то топане. Спросить всю ту тему. За ем полученный файл с постами закинуть в нейронку для составления психологического портрета человека. Типа: Привет! Проанализируй пожалуйста все посты и особенно выдели пользователя Соня, тщательно проанализируй его посты. Затем составь подробный психологический портрет, основываясь на полученных данных.

 

 

 

 

Screenshot_2026-04-02-23-31-53-302_ru.iiec.pydroid3.jpg


Изменено пользователем yellyex

moonfangtopich написал 29.08.2019 в 14:57:
У вас недостаточно широкий кругозор, пацаны

Я странствия этого еблана видел в покерных топанах, а потом в таверне - это один из самых безумных людей на форуме. Я искренне надеялся, что его зов о помощи останется незамеченным, но нет, нашелся доброволец и вот уже три страницы мы пожинаем плоды

Поделиться сообщением


Ссылка на сообщение
Скрытый текст

# -*- coding: utf-8 -*-
"""
================================================================================
ПАРСЕР ФОРУМА PRODOTA.RU (версия 5.0 — только текст, цветной интерфейс)
================================================================================

📋 ОПИСАНИЕ:
Программа скачивает сообщения с указанной темы форума prodota.ru и сохраняет
их в удобном формате (только текст, без вложений).

🔹 РЕЖИМ — Только текст:
   • Сохраняет: автор, номер поста, время, текст сообщения
   • Формат: JSON или CSV (на выбор)
   • Опция: разбивка на файлы заданного размера (в МБ)
   • Очистка: удаляет подписи, лайки, кнопки интерфейса, метаданные
   • Цитаты: сохраняет структуру "цитата + ответ автора"

📁 ВЫХОДНЫЕ ДАННЫЕ:
Каждая запись содержит ТОЛЬКО 4 поля:
{
    "author": "никнейм_автора",
    "post_number": "ID_поста",
    "post_time": "время_публикации",
    "post_text": "очищенный_текст_сообщения"
}

================================================================================
🔧 НАСТРОЙКА ПРОГРАММЫ (редактируйте этот раздел перед запуском)
================================================================================
"""

# ========================== ИМПОРТЫ БИБЛИОТЕК ==========================
import os
import re
import sys
import json
import csv
import time
import random
import logging
from pathlib import Path
from urllib.parse import urljoin
from typing import Optional, List, Dict, Any, Generator

import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
from bs4 import BeautifulSoup, Tag

# Цветной вывод в консоль (яркие цвета для интерфейса)
try:
    from colorama import init, Fore, Back, Style
    init(autoreset=True)
except ImportError:
    class Fore:
        RED = GREEN = YELLOW = CYAN = MAGENTA = WHITE = RESET = ''
        BLUE = BLACK = ''
    class Style:
        BRIGHT = RESET_ALL = ''
    class Back:
        RESET = ''


# ========================== НАСТРОЙКИ ПОЛЬЗОВАТЕЛЯ ==========================
# ▼▼▼ ИЗМЕНЯЙТЕ ЗНАЧЕНИЯ НИЖЕ ПОД СВОИ ЗАДАЧИ ▼▼▼

class Config:
    """
    🔹 ВСЕ НАСТРОЙКИ ПРОГРАММЫ НАХОДЯТСЯ ЗДЕСЬ 🔹
    
    Как менять:
    1. Найдите нужную строку ниже
    2. Замените значение в кавычках или число на своё
    3. Сохраните файл и запустите парсер
    
    Пример:
        START_URL: str = 'https://prodota.ru/forum/topic/123456/'
                             ↑↑↑ вставьте сюда ссылку на вашу тему
    """
    
    # ─────────────────────────────────────────────────────────────
    # 🌐 ОБЯЗАТЕЛЬНЫЕ НАСТРОЙКИ
    # ─────────────────────────────────────────────────────────────
    
    # 🔗 ССЫЛКА НА ПЕРВУЮ СТРАНИЦУ ТЕМЫ (ОБЯЗАТЕЛЬНО!)
    # Вставьте полную ссылку на тему, которую нужно скачать:
    START_URL: str = 'https://prodota.ru/forum/topic/223189/#comments'
    
    # 📁 ИМЯ ПАПКИ ДЛЯ СОХРАНЕНИЯ РЕЗУЛЬТАТА
    # Используйте латиницу, цифры, подчёркивания (без пробелов и кириллицы):
    FOLDER_NAME: str = 'prodota_half'
    
    # 📂 БАЗОВАЯ ПАПКА ДЛЯ СОХРАНЕНИЯ
    # На Android автоматически определяется папка Download.
    # На других ОС — папка "Загрузки" пользователя.
    @staticmethod
    def _default_output_dir() -> str:
        if os.path.exists('/storage/emulated/0/Download'):
            return '/storage/emulated/0/Download'
        return os.path.join(os.path.expanduser('~'), 'Downloads')
    
    DEFAULT_OUTPUT_DIR: str = os.getenv('PARSER_OUTPUT_DIR', _default_output_dir())
    
    # ─────────────────────────────────────────────────────────────
    # ⚙️ ДОПОЛНИТЕЛЬНЫЕ НАСТРОЙКИ (можно не менять)
    # ─────────────────────────────────────────────────────────────
    
    # Минимальная и максимальная задержка между запросами (сек) – для имитации человека
    MIN_DELAY: float = 3.0
    MAX_DELAY: float = 7.0
    
    # Таймаут ожидания ответа от сайта (в секундах)
    REQUEST_TIMEOUT: int = 20
    
    # Количество повторных попыток при ошибке соединения или блокировке
    MAX_RETRIES: int = 5
    
    # Множитель экспоненциальной задержки между повторными попытками
    BACKOFF_FACTOR: float = 2.0
    
    # Формат сохранения: 'json' или 'csv'
    TEXT_FORMAT: str = 'json'
    
    # Максимальный размер одного файла при разбивке (в МБ), 0 = не разбивать
    SPLIT_SIZE_MB: float = 0
    
    # Список User-Agent для ротации (имитируем разные браузеры)
    USER_AGENTS = [
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
        'Mozilla/5.0 (Windows NT 10.0; rv:109.0) Gecko/20100101 Firefox/119.0',
        'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
    ]
    
    # CSS-селекторы для поиска элементов на странице
    SELECTORS = {
        'post_blocks': ['li.ipsComment', 'div.cPost', 'article.ipsComment', 'div.post'],
        'author': ['.cAuthorPane_author', '.ipsComment_author', '.author', 'a[data-username]'],
        'content': ['.cPost_contentWrap', '.ipsComment_content', '.post-content', '[data-role="commentContent"]'],
        'next_page': ['a[rel="next"]', '.ipsPagination_next a', '.next a'],
        'last_page': ['a.ipsPagination_last', 'a[data-page="last"]', 'li.ipsPagination_last a']
    }


# ========================== НАСТРОЙКА ЛОГГИРОВАНИЯ ==========================
def setup_logging(level: int = logging.INFO) -> logging.Logger:
    logger = logging.getLogger('prodota_parser')
    logger.setLevel(level)
    if not logger.handlers:
        console_handler = logging.StreamHandler(sys.stdout)
        console_handler.setFormatter(logging.Formatter('%(asctime)s [%(levelname)s] %(message)s', datefmt='%H:%M:%S'))
        logger.addHandler(console_handler)
    return logger

logger = setup_logging()


# ========================== ФУНКЦИЯ ОЧИСТКИ КОНТЕНТА ==========================
# Удаляет из текста поста всё лишнее: подписи, лайки, кнопки, метаданные
# Сохраняет: время публикации, цитаты + ответ автора, основной текст

def clean_post_content(raw_content: str) -> Dict[str, str]:
    """
    Очищает сырой текст поста от интерфейсного мусора.
    
    Что удаляется:
    • Номер поста (#1, #22 и т.д.)
    • Метки времени в начале текста (дубликат, т.к. время извлекается отдельно)
    • Блоки лайков ("понравилось это" и имена пользователей)
    • Кнопки интерфейса ("Цитата", "Поделиться сообщением", "Ссылка на сообщение")
    • Текст ПОСЛЕ кнопок интерфейса (автоматические подписи пользователей)
    
    Что сохраняется:
    • Время публикации (извлекается в отдельное поле)
    • Цитаты других постов (структура: "User написал... : текст")
    • Ответ автора на цитату
    
    Возвращает: {'post_time': '...', 'post_text': '...'}
    """
    if not raw_content:
        return {'post_time': '', 'post_text': ''}
    
    text = raw_content.strip()
    post_time = ''
    
    # 1. Извлекаем время публикации
    time_match = re.search(
        r'(?:Опубликовано:\s*)?'
        r'(вчера|сегодня|\d+\s*(?:часов?|минут?|секунд?|дней?|месяцев?|лет?)\s*назад|'
        r'\d{2}\.\d{2}\.\d{4}\s+в\s+\d{2}:\d{2})',
        text, re.IGNORECASE
    )
    if time_match:
        post_time = time_match.group(1) if time_match.group(1) else time_match.group(0).replace('Опубликовано:', '').strip()
        text = text.replace(time_match.group(0), '', 1).strip()
    
    # 2. Удаляем номер поста в начале (#1, #22...)
    text = re.sub(r'^#\d+\s*', '', text).strip()
    
    # 3. Удаляем блоки лайков (безопасная версия, не вызывающая ошибок)
    lines = text.split('\n')
    cleaned_lines = []
    for line in lines:
        if 'понравилось это' in line.lower():
            if cleaned_lines and re.match(r'^[\s\w,]+$', cleaned_lines[-1]):
                cleaned_lines.pop()
            continue
        if re.match(r'^[\s\w,]+$', line) and len(line) < 100:
            continue
        cleaned_lines.append(line)
    text = '\n'.join(cleaned_lines)
    
    # 4. Отсекаем всё, что идёт после кнопок интерфейса (это подписи)
    for marker in ['Ссылка на сообщение', 'Поделиться сообщением', 'Цитата']:
        idx = text.find(marker)
        if idx != -1:
            text = text[:idx]
            break
    
    # 5. Убираем лишние пустые строки
    text = re.sub(r'\n{3,}', '\n\n', text).strip()
    
    return {'post_time': post_time, 'post_text': text}


# ========================== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========================

def sanitize_filename(filename: str, max_length: int = 100) -> str:
    """
    Очищает имя файла от символов, недопустимых в разных ОС.
    """
    sanitized = re.sub(r'[<>:"/\\|?*\x00-\x1f]', '_', filename)
    if len(sanitized) > max_length:
        name, ext = os.path.splitext(sanitized)
        sanitized = name[:max_length-len(ext)] + ext
    return sanitized.strip() or 'unnamed'


def estimate_record_size(record: Dict[str, Any]) -> int:
    """
    Приблизительно оценивает размер одной записи в байтах.
    Нужно для разбивки больших файлов на части.
    """
    return sum(len(str(v).encode('utf-8')) for v in record.values()) + 50


# ========================== БАЗОВЫЙ КЛАСС ПАРСЕРА ==========================

class Parser:
    """
    Основной класс парсера для скачивания текстовых сообщений.
    """
    
    def __init__(self, start_url: str, output_dir: Path, timeout: int = Config.REQUEST_TIMEOUT):
        self.start_url = start_url
        self.output_dir = output_dir
        self.timeout = timeout
        self.session = self._create_session()
        self.posts: List[Dict[str, Any]] = []
        self.seen_posts: set = set()
        self.stats = {'pages_parsed': 0, 'posts_found': 0, 'errors': 0}
        self.total_pages: Optional[int] = None
        self.start_time: float = 0.0
    
    def _create_session(self) -> requests.Session:
        """
        Создаёт сессию с настройками повторных запросов и заголовками браузера.
        """
        session = requests.Session()
        retry_strategy = Retry(
            total=Config.MAX_RETRIES,
            backoff_factor=Config.BACKOFF_FACTOR,
            status_forcelist=[500, 502, 503, 504],
            allowed_methods=['GET', 'HEAD']
        )
        adapter = HTTPAdapter(max_retries=retry_strategy)
        session.mount('https://', adapter)
        session.mount('http://', adapter)
        session.headers.update({
            'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
            'Accept-Language': 'ru-RU,ru;q=0.9,en;q=0.8',
            'Connection': 'keep-alive',
        })
        return session
    
    def _get_random_user_agent(self) -> str:
        """Возвращает случайный User-Agent из списка."""
        return random.choice(Config.USER_AGENTS)
    
    def get_soup(self, url: str, retry_count: int = 0) -> Optional[BeautifulSoup]:
        """
        Загружает HTML-страницу с повторными попытками при блокировке.
        """
        try:
            current_ua = self._get_random_user_agent()
            self.session.headers.update({'User-Agent': current_ua})
            response = self.session.get(url, timeout=self.timeout)
            
            if response.status_code in (403, 429) or response.status_code >= 500:
                print(f"{Fore.RED}⚠️ Блокировка {response.status_code}. Пауза...{Style.RESET_ALL}")
                if retry_count < Config.MAX_RETRIES:
                    sleep_time = 30 * (2 ** retry_count)
                    time.sleep(sleep_time)
                    return self.get_soup(url, retry_count + 1)
                else:
                    logger.error(f"Не удалось загрузить {url} после {Config.MAX_RETRIES} попыток")
                    return None
            
            response.raise_for_status()
            soup = BeautifulSoup(response.content, 'html.parser')
            
            if self._is_blocked(soup, response):
                print(f"{Fore.RED}⚠️ Обнаружена капча или блокировка. Пауза...{Style.RESET_ALL}")
                if retry_count < Config.MAX_RETRIES:
                    sleep_time = 60 * (2 ** retry_count)
                    time.sleep(sleep_time)
                    return self.get_soup(url, retry_count + 1)
                else:
                    return None
            return soup
            
        except requests.exceptions.Timeout:
            logger.error(f"Таймаут при загрузке {url}")
        except requests.exceptions.ConnectionError:
            logger.error(f"Ошибка соединения при загрузке {url}")
        except requests.exceptions.HTTPError as e:
            logger.error(f"HTTP ошибка {e.response.status_code} для {url}")
        except Exception as e:
            logger.error(f"Неожиданная ошибка при загрузке {url}: {type(e).__name__}: {e}")
            self.stats['errors'] += 1
        
        if retry_count < Config.MAX_RETRIES:
            sleep_time = 10 * (2 ** retry_count)
            time.sleep(sleep_time)
            return self.get_soup(url, retry_count + 1)
        return None
    
    def _is_blocked(self, soup: BeautifulSoup, response: requests.Response) -> bool:
        """
        Проверяет, не заблокировал ли сайт запрос (капча, доступ запрещён).
        """
        if response.status_code != 200:
            return True
        block_indicators = ['captcha', 'access denied', 'forbidden', 'cloudflare', 'checking your browser', 'ddos-guard']
        page_text = soup.get_text().lower()
        return any(indicator in page_text for indicator in block_indicators)
    
    def extract_post_id(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает уникальный номер поста из HTML-элемента.
        """
        if post_element.get('id'):
            match = re.search(r'Comment-(\d+)|Post-(\d+)|comment_(\d+)', post_element['id'], re.I)
            if match:
                return next(g for g in match.groups() if g)
        if post_element.get('data-commentid'):
            return str(post_element['data-commentid'])
        link = post_element.select_one('a[href*="#comment-"], a[href*="#post-"]')
        if link and link.get('href'):
            match = re.search(r'#(?:comment|post)-?(\d+)', link['href'], re.I)
            if match:
                return match.group(1)
        return None
    
    def extract_author(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает никнейм автора поста.
        """
        for selector in Config.SELECTORS['author']:
            author_elem = post_element.select_one(selector)
            if author_elem:
                text = author_elem.get_text(strip=True)
                if text:
                    return text
        return None
    
    def extract_raw_content(self, post_element: Tag) -> Optional[str]:
        """
        Извлекает сырой текст поста (до очистки).
        """
        for selector in Config.SELECTORS['content']:
            content_elem = post_element.select_one(selector)
            if content_elem:
                return content_elem.get_text(separator='\n', strip=True)
        return None
    
    def get_total_pages(self, soup: BeautifulSoup, base_url: str) -> Optional[int]:
        """
        Пытается извлечь общее количество страниц из пагинации.
        """
        for selector in Config.SELECTORS['last_page']:
            last_elem = soup.select_one(selector)
            if last_elem and last_elem.get('href'):
                match = re.search(r'page[=_-]?(\d+)', last_elem['href'])
                if match:
                    return int(match.group(1))
        pagination_text = soup.get_text()
        match = re.search(r'Страница\s+\d+\s+из\s+(\d+)', pagination_text, re.IGNORECASE)
        if match:
            return int(match.group(1))
        return None
    
    def parse_page(self, url: str) -> Optional[str]:
        """
        Парсит одну страницу: извлекает посты и находит ссылку на следующую.
        Возвращает URL следующей страницы или None, если это последняя.
        """
        page_start = time.time()
        soup = self.get_soup(url)
        if not soup:
            return None
        
        if self.total_pages is None:
            self.total_pages = self.get_total_pages(soup, url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        post_blocks = []
        for selector in Config.SELECTORS['post_blocks']:
            blocks = soup.select(selector)
            if blocks:
                post_blocks.extend(blocks)
                break
        
        if not post_blocks:
            logger.warning(f"Не найдено постов на странице {url}.")
            return None
        
        posts_before = len(self.posts)
        for block in post_blocks:
            post_id = self.extract_post_id(block)
            if not post_id or post_id in self.seen_posts:
                continue
            
            author = self.extract_author(block)
            raw_content = self.extract_raw_content(block)
            
            if not author or not raw_content:
                logger.debug(f"Пропущен пост {post_id}: не удалось извлечь автора или контент")
                continue
            
            cleaned = clean_post_content(raw_content)
            if not cleaned['post_text']:
                continue
            
            self.seen_posts.add(post_id)
            self.posts.append({
                'author': author,
                'post_number': post_id,
                'post_time': cleaned['post_time'],
                'post_text': cleaned['post_text']
            })
            self.stats['posts_found'] += 1
        
        posts_added = len(self.posts) - posts_before
        self.stats['pages_parsed'] += 1
        elapsed = time.time() - page_start
        
        # Цветной прогресс (только числа, без бара)
        if self.total_pages:
            progress = self.stats['pages_parsed'] / self.total_pages * 100
            print(f"\r{Fore.CYAN}Страница {self.stats['pages_parsed']}/{self.total_pages} | "
                  f"{Fore.GREEN}{progress:.1f}%{Fore.CYAN} | постов: {posts_added} | "
                  f"всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        else:
            print(f"\r{Fore.CYAN}Страница {self.stats['pages_parsed']} (всего неизвестно) | "
                  f"постов: {posts_added} | всего: {len(self.posts)} | {elapsed:.1f}с{Style.RESET_ALL}", end='')
        sys.stdout.flush()
        
        for selector in Config.SELECTORS['next_page']:
            next_elem = soup.select_one(selector)
            if next_elem and next_elem.get('href'):
                next_url = urljoin(url, next_elem['href'])
                if next_url != url:
                    return next_url
        return None
    
    def run(self) -> bool:
        """
        Основной цикл парсинга: проходит по всем страницам темы.
        """
        self.start_time = time.time()
        current_url = self.start_url
        first_soup = self.get_soup(current_url)
        if first_soup:
            self.total_pages = self.get_total_pages(first_soup, current_url)
            if self.total_pages:
                logger.info(f"Общее количество страниц в теме: {self.total_pages}")
        
        while current_url:
            next_url = self.parse_page(current_url)
            if next_url and next_url != current_url:
                current_url = next_url
                sleep_time = random.uniform(Config.MIN_DELAY, Config.MAX_DELAY)
                time.sleep(sleep_time)
            else:
                break
        
        print()
        elapsed_total = time.time() - self.start_time
        logger.info(f"Парсинг завершён. Страниц: {self.stats['pages_parsed']}, Постов: {len(self.posts)}, Ошибок: {self.stats['errors']}, Время: {elapsed_total:.1f} сек")
        return len(self.posts) > 0


# ========================== КЛАСС ДЛЯ СОХРАНЕНИЯ ==========================

class Saver:
    """
    Сохраняет собранные посты в JSON или CSV с возможностью разбивки.
    """
    
    def __init__(self, posts: List[Dict], output_dir: Path, format: str = 'json', split_size_mb: float = 0):
        self.posts = posts
        self.output_dir = output_dir
        self.format = format.lower()
        self.split_size_mb = split_size_mb
    
    def _save_json_streaming(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в JSON с потоковой записью (экономит память).
        """
        with open(filepath, 'w', encoding='utf-8') as f:
            f.write('[\n')
            for i, record in enumerate(data):
                json.dump(record, f, ensure_ascii=False, indent=2)
                if i < len(data) - 1:
                    f.write(',\n')
                else:
                    f.write('\n')
            f.write(']')
        logger.info(f"Данные сохранены в {filepath}")
    
    def _save_csv(self, data: List[Dict], filepath: Path):
        """
        Сохраняет данные в CSV с разделителем точка с запятой (;).
        Кодировка UTF-8-SIG для корректного открытия в Excel.
        """
        if not data:
            logger.warning("Нет данных для сохранения в CSV")
            return
        with open(filepath, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
            writer.writerow(['author', 'post_number', 'post_time', 'post_text'])
            for p in data:
                writer.writerow([p['author'], p['post_number'], p['post_time'], p['post_text']])
        logger.info(f"Данные сохранены в {filepath}")
    
    def _split_data_by_size(self, data: List[Dict], max_size_bytes: int) -> Generator[List[Dict], None, None]:
        """
        Генератор для разбивки данных на части заданного размера (в байтах).
        """
        if not data:
            return
        current_batch = []
        current_size = 0
        for record in data:
            record_size = estimate_record_size(record)
            if current_size + record_size > max_size_bytes and current_batch:
                yield current_batch
                current_batch = [record]
                current_size = record_size
            else:
                current_batch.append(record)
                current_size += record_size
        if current_batch:
            yield current_batch
    
    def save(self):
        """
        Сохраняет посты в файл(ы) с учётом формата и разбивки.
        """
        if not self.posts:
            logger.warning("Нет постов для сохранения")
            return
        
        max_size_bytes = self.split_size_mb * 1024 * 1024 if self.split_size_mb > 0 else float('inf')
        
        if self.format == 'json':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка JSON на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.json'
                    self._save_json_streaming(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.json'
                self._save_json_streaming(self.posts, filepath)
        elif self.format == 'csv':
            if self.split_size_mb > 0:
                logger.info(f"Разбивка CSV на части по {self.split_size_mb} МБ")
                for part_num, batch in enumerate(self._split_data_by_size(self.posts, max_size_bytes), 1):
                    filepath = self.output_dir / f'posts_part{part_num:03d}.csv'
                    self._save_csv(batch, filepath)
            else:
                filepath = self.output_dir / 'posts.csv'
                self._save_csv(self.posts, filepath)
        else:
            logger.error(f"Неподдерживаемый формат: {self.format}")


# ========================== ПОЛЕЗНЫЕ ФУНКЦИИ ==========================

def validate_output_dir(base_dir: str, folder_name: str) -> Path:
    """
    Проверяет и создаёт директорию для сохранения результата.
    Если нет прав на запись — использует текущую папку.
    """
    try:
        output_path = Path(base_dir) / sanitize_filename(folder_name)
        output_path.mkdir(parents=True, exist_ok=True)
        test_file = output_path / '.permission_test'
        test_file.touch()
        test_file.unlink()
        logger.info(f"Директория готова: {output_path.absolute()}")
        return output_path
    except PermissionError:
        logger.error(f"Нет прав на запись в {base_dir}")
        fallback = Path.cwd() / sanitize_filename(folder_name)
        fallback.mkdir(parents=True, exist_ok=True)
        logger.warning(f"Используем резервную директорию: {fallback.absolute()}")
        return fallback
    except Exception as e:
        logger.error(f"Ошибка при создании директории: {e}")
        sys.exit(1)


def print_header():
    """Выводит цветное название парсера и краткое описание."""
    print(f"{Fore.MAGENTA}{Style.BRIGHT}══════════════════════════════════════════════════════════════════════{Style.RESET_ALL}")
    print(f"{Fore.YELLOW}{Style.BRIGHT}                        ПАРСЕР ФОРУМА PRODOTA.RU{Style.RESET_ALL}")
    print(f"{Fore.MAGENTA}{Style.BRIGHT}══════════════════════════════════════════════════════════════════════{Style.RESET_ALL}")
    print(f"{Fore.CYAN}Скачивает сообщения из темы, очищает от подписей, лайков и кнопок.{Style.RESET_ALL}")
    print(f"{Fore.CYAN}Сохраняет в JSON или CSV. Поддерживает разбивку на файлы.{Style.RESET_ALL}")
    print()


def get_split_size() -> float:
    """
    Запрашивает размер части для разбивки файлов.
    """
    print(f"{Fore.YELLOW}{Style.BRIGHT}РАЗБИВКА ФАЙЛОВ:{Style.RESET_ALL}")
    print(f"{Fore.CYAN}Если тема очень большая, можно разбить результат на несколько частей.{Style.RESET_ALL}")
    print(f"{Fore.CYAN}• 0 — всё в один файл{Style.RESET_ALL}")
    print(f"{Fore.CYAN}• 10–50 — разбивка по МБ (рекомендуется){Style.RESET_ALL}")
    while True:
        try:
            value = float(input(f"{Fore.GREEN}→ Максимальный размер части в МБ (0 = один файл): {Style.RESET_ALL}").strip())
            if value < 0:
                print(f"{Fore.RED}Ошибка: число не может быть отрицательным{Style.RESET_ALL}")
                continue
            return value
        except ValueError:
            print(f"{Fore.RED}Ошибка: введите число (0, 10, 50...){Style.RESET_ALL}")


# ========================== ГЛАВНАЯ ФУНКЦИЯ ЗАПУСКА ==========================

def main():
    """
    Точка входа в программу.
    """
    # 1. Выводим заголовок
    print_header()
    
    # 2. Проверяем, что указана ссылка на тему
    if not Config.START_URL or Config.START_URL.endswith('topic/'):
        logger.error(f"{Fore.RED}Ошибка: укажите корректный START_URL в настройках!{Style.RESET_ALL}")
        sys.exit(1)
    
    # 3. Подготавливаем папку для сохранения
    output_dir = validate_output_dir(Config.DEFAULT_OUTPUT_DIR, Config.FOLDER_NAME)
    
    # 4. Запрашиваем размер разбивки (если не задан в конфиге)
    split_mb = Config.SPLIT_SIZE_MB
    if split_mb is None or split_mb == 0:
        split_mb = get_split_size()
    else:
        print(f"{Fore.CYAN}→ Используется размер разбивки {split_mb} МБ из конфигурации{Style.RESET_ALL}\n")
    
    # 5. Запускаем парсинг
    try:
        parser = Parser(
            start_url=Config.START_URL,
            output_dir=output_dir
        )
        success = parser.run()
        
        # 6. Сохраняем результаты
        if success and parser.posts:
            saver = Saver(
                posts=parser.posts,
                output_dir=output_dir,
                format=Config.TEXT_FORMAT,
                split_size_mb=split_mb
            )
            saver.save()
            
            file_path = output_dir / ('posts.json' if Config.TEXT_FORMAT == 'json' else 'posts.csv')
            print(f"\n{Fore.GREEN}{Style.BRIGHT} ГОТОВО.{Style.RESET_ALL} {Fore.CYAN}Файлы сохранены в:{Style.RESET_ALL}")
            print(f"{Fore.YELLOW}   {file_path}{Style.RESET_ALL}")
        else:
            print(f"\n{Fore.YELLOW}{Style.BRIGHT}⚠️ ЗАВЕРШЕНО С ПРЕДУПРЕЖДЕНИЯМИ.{Style.RESET_ALL} {Fore.CYAN}Проверьте логи выше.{Style.RESET_ALL}")
        
    except KeyboardInterrupt:
        print(f"\n{Fore.YELLOW}⚠️ Прервано пользователем{Style.RESET_ALL}")
        sys.exit(130)
    except Exception as e:
        logger.exception(f"{Fore.RED} Критическая ошибка: {type(e).__name__}: {e}{Style.RESET_ALL}")
        sys.exit(1)


# ========================== ЗАПУСК ПРОГРАММЫ ==========================
if __name__ == '__main__':
    main()

 

 

Парсер то и не нужен с таким помощником.

 

Screenshot_2026-04-03-09-09-06-414_com.yandex.searchapp.jpg


moonfangtopich написал 29.08.2019 в 14:57:
У вас недостаточно широкий кругозор, пацаны

Я странствия этого еблана видел в покерных топанах, а потом в таверне - это один из самых безумных людей на форуме. Я искренне надеялся, что его зов о помощи останется незамеченным, но нет, нашелся доброволец и вот уже три страницы мы пожинаем плоды

Поделиться сообщением


Ссылка на сообщение

Имагине ебало Дефреля когда такие вот ИИ агенты форум задудосят. :monkagiga:

yellyex понравилось это

Поделиться сообщением


Ссылка на сообщение
Index написал 1 час назад:

Имагине ебало Дефреля когда такие вот ИИ агенты форум задудосят. :monkagiga:

так это происходит периодически

я часто вижу ап каких то мертвых тем, где какая то дженерик хуйня в новом посте, которая типо таргетно должна на какой то ресурс увести юзера

 

Поделиться сообщением


Ссылка на сообщение

парсер постов на продоте

вопрос только нахуя

Поделиться сообщением


Ссылка на сообщение

просто поблагодарите 

ситуация рабочая 

mingrevone понравилось это

Поделиться сообщением


Ссылка на сообщение
godspeed написал 15 минут назад:

парсер постов на продоте

вопрос только нахуя

Ну прикольно когда нейронка может психологический портрет составить на биг дате, не? 

Поделиться сообщением


Ссылка на сообщение
Index написал 21 час назад:

не досписал

Я вот себя пиздец конечно тупым не считаю, но у меня напрочь отсутствует скилл визуализации в голове. 

Я проблемы или тех-риски решений которые обсуждают на созвонах начинаю замечать только тогда, когда берусь за клавиатуру. Я могу придумать охуенное решение только тогда, когда возьмусь за клавиатуру а без доски чисто фейс-ту-фейс с меня брейнштормер как со свиньи в лужу хрюкающей.

 

Если обсуждается домен в который я не коммитал я не могу на словах ничего дирижировать.

И очень часто я просто киваю головой и соглашаюсь с какой-то мыслью а потом сев за клаву и раскурив её бегу подсвечивать в чате техриски или лучшие решения.

 

К чему я это веду. Обычно я раскрываюсь через какое-то время на работе, от средне-нормиса до крутого специалиста который подмечает тонкие моменты, нужно там пару месяцев хуе мое.

Но эту характеристику очень тяжело как-то продать на ебучих собесах. На собесах я себя пиздец тупым ощущаю и в условиях текущего рынка даже как-то стремно оказаться в нем. :corona:

 

хахахаха ну ты и дебил, тупой да, тупорез, дуроёб, имбецил, даун, кретин, олигофрен, тупездень


 

Жиза для любопытных

Чекнул = пидор

 

Поделиться сообщением


Ссылка на сообщение
Qreeq написал 1 час назад:
godspeed написал 1 час назад:

парсер постов на продоте

вопрос только нахуя

Ну прикольно когда нейронка может психологический портрет составить на биг дате, не? 

не

Поделиться сообщением


Ссылка на сообщение

ну там кому-то не лень было результат почитать в хохлотопике

оказалось, что на выходе получилась хуйня, которая миксует разных людей


Торжество разума в том, чтобы уживаться с теми, у кого этого разума нет. Вольтер.
Чтобы хорошо высыпаться, нужно спать 8 часов в день. И еще столько же ночью.

Поделиться сообщением


Ссылка на сообщение

Присоединяйтесь к обсуждению

Вы можете опубликовать сообщение сейчас, а зарегистрироваться позже. Если у вас есть аккаунт, войдите в него для написания от своего имени.

Гость
Ответить в тему...

×   Вставлено в виде отформатированного текста.   Восстановить форматирование

  Разрешено не более 75 эмодзи.

×   Ваша ссылка была автоматически встроена.   Отобразить как ссылку

×   Ваш предыдущий контент был восстановлен.   Очистить редактор

×   Вы не можете вставить изображения напрямую. Загрузите или вставьте изображения по ссылке.

Загрузка...

×
×
  • Создать...