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

Rooster

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

var  

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

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

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

Kant написал 29 минут назад:

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

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

Лучше почитать про Соняшу и про него самого, потому что сейчас он Парсинг не через вайбкодерскую свою прогу делает, а нейронку просит парсить. Она ему конечно обещает все сделать хорошо, но мы то знаем.

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


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

не досписал

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

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

 

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

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

 

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

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

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

Если наблюдается невозможность сконцентрироваться в компании людей, то такие недостатки надо с возрастом преодолевать


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

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


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

Я сам просил нейронку делать конфликт между людьми. Она не глючила. Типа доказательства расстройства. 


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

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

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


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

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

Как по мне это актуально только если что-то делаешь с нуля и правильно.

 

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

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


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

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

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

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


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

Заменит ли ИИ постеров этого топана?


Russia.png.ea080e101222d708d08842dae4ccc684.png USA.png.9b863586cd992fb91bf92ed1de03e4cc.png Israel.png.f27d10a4455f05cd161a8c2c52b8b5e1.png

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


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

Нет, такую хуйню писать - это талант

Не хватит данных воспроизвести 


VyMEtE8XtOI.jpg

  лучшая цитата финта+жизненная

XbkBCDXetHY.jpg

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


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

Заменит ли ИИ постеров этого топана?

Меня - да, так как я пишу базу

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


Ссылка на сообщение
Grohuf написал 5 часов назад:
Rooster написал 5 часов назад:

Заменит ли ИИ постеров этого топана?

Меня - да, так как я пишу базу

тебя бы и собака заменила


 

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

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

 

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


Ссылка на сообщение
(изменено)

Работаю над парсером для сайта prodota.ru. За не имением настольного ПК, я работаю только со смартфона через ИИ Агента GLM 5.1 turbo.

Пока тестирую все посты из темы сисек для Linde.

Но ещё много работы предстоит.

1. Шлифовка, чтобы контент скачивался безупречно, без дубликатов и прочего мусора.

2. Работа над GUI интерфейсом. Левый верхний квадрат настройки. Нижний левый квадрат логи и текущая деятельность. Правая сторона - выходные данные. Все посты в хронологическом порядке выбранной темы и конкретного пользователя через скроллинг.

3. Поиск онлайн упаковщиков APK

 

 

Screenshot_2026-04-04-17-30-14-982_ru.iiec.pydroid3.jpg


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

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

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

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


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

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

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

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


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

Такое нельзя оставлять безнаказанным

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

 

 

doc137399519_225147917?hash=61dbe6de1d450a42e7&dl=3361c54861ae5a77cb&wnd=1

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


Ссылка на сообщение
(изменено)

Привет! Я сделал парсер для продоты, но так как у меня нет настолько мощного ПК с Windows, а только смартфон, продолжить делать парсер не могу. Я подошёл к такой стадии, что нужно делать GUI для парсера. GUI красивый, стильный и понятный, в тёмных тонах. Но без ПК невозможно. Поэтому прошу помощи зала и тех, кто может такое сделать, например через Visual Studio.

 

 

 

Как я вижу простенький интерфейс: представьте квадрат. Он поделён пополам по вертикали линией. Левая половина поделена горизонтальной линией посередине. Левая верхняя часть — это все настройки парсера. Нижняя левая часть — это логи и ход процесса парсинга. Вся правая часть — это выходные данные, где после парсинга пользователь сможет просматривать фото, видео и посты пользователей согласно указанным им настройкам. Возможно, придётся добавить в код обращение к папке, в которую парсятся данные, а потом отображать их в правой половине графического интерфейса. Ну и в конце скомпилировать APK и Exe-файлы.

 

 

 

Цели всего две:

 

 

 

1. Создать прекрасный и удобный интерфейс, которым будет удобно пользоваться.

 

2. Создать APK-файл и Exe-файл

 

 

 

Спасибо тем, кто откликнется. ♥️♥️♥️

 

Исходный код парсера:

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

# -*- coding: utf-8 -*-

import os
import re
import sys
import json
import csv
import time
import uuid
import random
import threading
import traceback
import logging
import logging.handlers
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from pathlib import Path
from collections import OrderedDict
from urllib.parse import urljoin, urlparse

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


# ----------------------------------------------------------------------
# Настройка логирования
# ----------------------------------------------------------------------

# Папка, в которой находится скрипт
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Файл для записи ошибок парсера
ERROR_LOG_FILE = os.path.join(_SCRIPT_DIR, 'parser_errors.log')
# Файл для записи критических сбоев (неперехваченных исключений)
_CRASH_LOG = os.path.join(_SCRIPT_DIR, 'parser_crash.log')

# Создаём обработчик для ротации логов: максимальный размер 2 МБ, 3 бэкапа, кодировка utf-8
_file_handler = logging.handlers.RotatingFileHandler(
    ERROR_LOG_FILE, maxBytes=2_000_000, backupCount=3,
    encoding='utf-8', errors='replace'
)
_file_handler.setLevel(logging.DEBUG)
_file_handler.setFormatter(logging.Formatter(
    '%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
))
# Создаём логгер с именем 'prodota_parser'
logger = logging.getLogger('prodota_parser')
logger.setLevel(logging.DEBUG)
logger.addHandler(_file_handler)
logger.propagate = False  # не передавать события выше

# Консольный обработчик для отладки
_console_handler = logging.StreamHandler(sys.stdout)
_console_handler.setLevel(logging.DEBUG)
_console_handler.setFormatter(logging.Formatter(
    '%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
))
logger.addHandler(_console_handler)


def _crash_handler(exc_type, exc_value, exc_tb):
    """Обработчик неперехваченных исключений: записывает в отдельный файл и в лог."""
    crash_msg = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
    try:
        with open(_CRASH_LOG, 'a', encoding='utf-8') as f:
            f.write(f"\n{'='*70}\n{time.strftime('%Y-%m-%d %H:%M:%S')}\n{crash_msg}")
    except Exception:
        pass
    logger.critical("НЕПЕРЕХВАЧЕННОЕ ИСКЛЮЧЕНИЕ:\n%s", crash_msg)
    sys.__excepthook__(exc_type, exc_value, exc_tb)


# Устанавливаем глобальный обработчик исключений
sys.excepthook = _crash_handler
logger.info("Парсер запущен, лог: %s", ERROR_LOG_FILE)


# ----------------------------------------------------------------------
# Конфигурационные настройки
# ----------------------------------------------------------------------

class Config:
    """Статический класс, содержащий все настройки парсера."""
    # Стартовый URL темы форума (по умолчанию)
    START_URL = 'https://prodota.ru/forum/topic/150153/#comments'
    # Папка для сохранения по умолчанию (если существует /storage/emulated/0/Download – для Android, иначе ~/Downloads)
    DEFAULT_OUTPUT = (
        '/storage/emulated/0/Download'
        if os.path.exists('/storage/emulated/0/Download')
        else os.path.expanduser('~/Downloads')
    )
    # Задержки между запросами страниц (секунды) — Bug 3 fix: reduced
    MIN_DELAY, MAX_DELAY = 1.0, 2.5
    # Таймаут HTTP-запроса
    REQUEST_TIMEOUT = 30
    # Максимальное количество повторных попыток при ошибках соединения/HTTP
    MAX_RETRIES = 2
    # Базовая задержка между попытками загрузки страницы
    PAGE_RETRY_BASE_DELAY = 3
    # Количество попыток загрузить страницу при ошибках
    PAGE_RETRY_COUNT = 3
    # Максимальное количество последовательных ошибок, после чего увеличивается пауза
    MAX_CONSECUTIVE_FAILURES = 5
    # Максимальная пауза при серии ошибок (секунды)
    SEVERE_DELAY_CAP = 30
    # Настройки повторных попыток для скачивания файлов
    DOWNLOAD_MAX_RETRIES = 3
    DOWNLOAD_RETRY_DELAY = 2
    # Список User-Agent для ротации
    USER_AGENTS = [
        'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.196 Mobile Safari/537.36',
        'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36',
        'Mozilla/5.0 (Linux; Android 12; Samsung Galaxy S21) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.178 Mobile Safari/537.36',
        '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; rv:109.0) Gecko/20100101 Firefox/119.0',
        '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',
    ]
    # Селекторы 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', 'a[rel="last"]'],
        'topic_title': ['h1.ipsType_pageTitle'],
    }
    # Критические URL‑фильтры: если подходит – сразу отбрасываем
    URL_FILTER_CRITICAL = [
        r'/uploads/emoticons/',
        r'/emoticons?/',
        r'/emoticons?/',
        r'/spacer\.(gif|png)',
        r'1x1\.(gif|png|jpg)',
        r'transparent\.(gif|png)',
        r'clear\.gif',
        r'/pixel\.',
        r'/blank\.',
        r'^data:',
    ]
    # Фильтры для URL, которые нужно исключить (например, аватары, иконки)
    URL_FILTER_OUT = [
        r'/uploads/profile/',
        r'/uploads/monthly_\d+/\d{4}/',
        r'_avatar\.',
        r'avatar_\d+',
        r'photo-\d+-\d+\.(jpg|jpeg|png|gif)',
        r'/emoji/',
        r'/smil(?:ey|ie)?/',
        r'/react/',
        r'/reactions/',
        r'reaction-',
        r'/icon/',
        r'/icon\.',
        r'/badge',
        r'/rank',
        r'/ribbon',
        r'/group',
        r'/sprite',
        r'/theme/',
        r'/core/',
        r'/applications/',
        r'/uploads/set_resources_',
        r'/uploads/monthly_.*?(?:icon|logo|banner|bg|background)',
        r'/loading',
        r'/ajax',
        r'/sharedmedia/',
        r'/javascript/',
        r'/css/',
        r'/global/',
    ]
    # Белый список URL, которые разрешены, даже если попали под FILTER_OUT
    URL_ALWAYS_ALLOW = [
        r'/uploads/monthly_.*?\.(jpg|jpeg|png|gif|webp|bmp)',
        r'/uploads/.*?\.(jpg|jpeg|png|gif|webp|bmp|mp4|webm)',
        r'\.attachments/',
        r'/attached/',
    ]
    # Минимальный размер изображения в байтах, чтобы не скачивать пустышки
    MIN_IMAGE_SIZE = 1024


def _compile_filter_patterns(patterns):
    """Компилирует список строк в список регулярных выражений (регистронезависимых)."""
    return [re.compile(p, re.I) for p in patterns]


# Предварительная компиляция фильтров URL для ускорения работы
_FILTER_CRITICAL_COMPILED = _compile_filter_patterns(Config.URL_FILTER_CRITICAL)
_FILTER_OUT_COMPILED = _compile_filter_patterns(Config.URL_FILTER_OUT)
_ALWAYS_ALLOW_COMPILED = _compile_filter_patterns(Config.URL_ALWAYS_ALLOW)


# ----------------------------------------------------------------------
# Вспомогательные функции
# ----------------------------------------------------------------------

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


def _try_remove_empty_dir(dirpath):
    """Пытается удалить пустую директорию, логирует результат."""
    try:
        if dirpath.exists() and dirpath.is_dir():
            # Check if empty (no files, only possibly empty subdirs)
            try:
                next(dirpath.iterdir())
                return  # Not empty
            except StopIteration:
                dirpath.rmdir()
                logger.debug("Удалена пустая папка: %s", dirpath)
    except Exception as e:
        logger.debug("Не удалось удалить папку %s: %s", dirpath, e)


# Регулярное выражение для удаления эмодзи и прочих не‑BMP символов
_EMOJI_PATTERN = re.compile(
    "["
    "\U0001F600-\U0001F64F"
    "\U0001F300-\U0001F5FF"
    "\U0001F680-\U0001F6FF"
    "\U0001F700-\U0001F77F"
    "\U0001F780-\U0001F7FF"
    "\U0001F800-\U0001F8FF"
    "\U0001F900-\U0001F9FF"
    "\U0001FA00-\U0001FA6F"
    "\U0001FA70-\U0001FAFF"
    "\U00002702-\U000027B0"
    "\u24C2-\u24FF"
    "\U0001f926-\U0001f937"
    "\u2640-\u2642"
    "\u2600-\u2B55"
    "\u200d"
    "\u23cf"
    "\u23e9"
    "\u231a"
    "\ufe0f"
    "\u3030"
    "\u00a9"
    "\u00ae"
    "\u2122"
    "\u2b50"
    "\u2b55"
    "\u231b"
    "\u2318"
    "\u23ea"
    "\u23eb"
    "\u23ec"
    "\u23ed"
    "\u23ee"
    "\u23ef"
    "\u23f0"
    "\u23f1"
    "\u23f2"
    "\u23f3"
    "\u25fb"
    "\u25fc"
    "\u25fd"
    "\u25fe"
    "\u2614"
    "\u2615"
    "\u2648-\u2653"
    "\u267f"
    "\u2693"
    "\u26a1"
    "\u26aa"
    "\u26ab"
    "\u26bd"
    "\u26be"
    "\u26c4"
    "\u26c5"
    "\u26ce"
    "\u26d4"
    "\u26ea"
    "\u26f2"
    "\u26f3"
    "\u26f5"
    "\u26fa"
    "\u26fd"
    "\u2702"
    "\u2705"
    "\u2708-\u270d"
    "\u270f"
    "\u2712"
    "\u2714"
    "\u2716"
    "\u271d"
    "\u2721"
    "\u2728"
    "\u2733"
    "\u2734"
    "\u2744"
    "\u2747"
    "\u274c"
    "\u274e"
    "\u2753-\u2755"
    "\u2757"
    "\u2763"
    "\u2764"
    "\u2795-\u2797"
    "\u27a1"
    "\u27b0"
    "\u27bf"
    "\u2934"
    "\u2935"
    "\u2b05-\u2b07"
    "\u2b1b"
    "\u2b1c"
    "\u303d"
    "\u3297"
    "\u3299"
    "\U0001f004"
    "\U0001f0cf"
    "\U0001f170-\U0001f171"
    "\U0001f17e-\U0001f17f"
    "\U0001f18e"
    "\U0001f191-\U0001f19a"
    "\U0001f1ad-\U0001f1e5"
    "\U0001f201-\U0001f20f"
    "\U0001f21a"
    "\U0001f22f"
    "\U0001f232-\U0001f23a"
    "\U0001f23c-\U0001f23f"
    "\U0001f249-\U0001f3fa"
    "\U0001f400-\U0001f53d"
    "\U0001f546-\U0001f64f"
    "]+",
    flags=re.UNICODE
)


def clean_post_content(raw):
    """Очищает текст поста от HTML‑сущностей, эмодзи, служебных строк (дата, цитаты, подписи)."""
    if not raw:
        return ''
    text = raw.strip()

    # Заменяем неразрывные пробелы (\u00a0) на обычные — BeautifulSoup
    # конвертирует &nbsp; в \u00a0, и на Android они отображаются как точки ·
    text = text.replace('\u00a0', ' ')
    # Также заменяем другие Unicode-пробелы
    # Bug 2 fix: do NOT remove \u200d (Zero Width Joiner) — needed for Unicode rendering
    for ws_char in ['\u200b', '\u200c', '\ufeff', '\u202f', '\u205f', '\u3000']:
        text = text.replace(ws_char, ' ')

    # Декодирование HTML-сущностей
    text = text.replace('&nbsp;', ' ')
    text = text.replace('&middot;', '·')
    text = text.replace('&lt;', '<')
    text = text.replace('&gt;', '>')
    text = text.replace('&quot;', '"')
    text = text.replace('&#39;', "'")
    text = text.replace('&#039;', "'")
    text = text.replace('&apos;', "'")
    text = text.replace('&mdash;', '—')
    text = text.replace('&ndash;', '–')
    text = text.replace('&laquo;', '«')
    text = text.replace('&raquo;', '»')
    text = text.replace('&hellip;', '...')
    text = text.replace('&bull;', '•')
    # Числовые сущности вида &#NNN; (валидный Unicode: 0–0x10FFFF)
    def _decode_numeric_entity(m):
        try:
            codepoint = int(m.group(1))
            if 0 <= codepoint <= 0x10FFFF:
                return chr(codepoint)
        except (ValueError, OverflowError):
            pass
        return ''
    text = re.sub(r'&#(\d+);', _decode_numeric_entity, text)
    text = text.replace('&amp;', '&')  # после числовых, чтобы не сломать их

    # Удаляем эмодзи
    text = _EMOJI_PATTERN.sub('', text)

    # Удаляем строки с "said On MM/DD/YYYY at HH:MM AM/PM:"
    text = re.sub(
        r'(>)?\s*\S+\s+said\s+On\s+\d{2}/\d{2}/\d{4}\s+at\s+\d{2}:\d{2}\s*(?:AM|PM)?\s*:?\s*\n?',
        '', text, flags=re.I
    )

    # Удаляем различные отметки времени и фразы "Опубликовано:"
    for pat in [r'Опубликовано:\s*\S+',
                r'(?:вчера|сегодня|\d+\s*(?:часов?|минут?|секунд?|дней?|месяцев?|лет?)\s*назад)',
                r'\d{2}\.\d{2}\.\d{4}\s+в\s+\d{2}:\d{2}',
                r'Posted\s+\w+\s+\d{1,2}',
                r'Posted\s+\d{1,2}\s+\w+',
                r'Posted\s+\w+',
                r'(?:написал|писал|написала|сказал|сказала)\s+.*?(?:назад|вчера|сегодня)',
                r'(?:\d+\s*(?:час|часа|часов|минут[ауы]?|секунд[ауы]?|день|дня|дней|неделю|недели|месяц|месяца|месяцев|год|года|лет)\s*)+назад']:
        text = re.sub(pat, '', text, flags=re.I)

    # Удаляем "#123" в начале каждой строки
    text = re.sub(r'^#\d+\s*', '', text, flags=re.M).strip()

    # Удаляем паттерны "X, Y and Z like this"
    text = re.sub(
        r'^[\w\s,]+(?:and\s+\d+\s+others?\s+)?like\s+this\s*$',
        '', text, flags=re.I | re.M
    )
    text = re.sub(
        r'^[\w\s]+and\s+[\w\s]+like\s+this\s*$',
        '', text, flags=re.I | re.M
    )

    # Разбиваем на строки и фильтруем служебные строки
    lines = text.split('\n')
    cleaned = []
    for line in lines:
        stripped = line.strip()

        # Don't touch quote formatting lines (─, │, Цитата)
        if stripped.startswith(("\u2500", "\u2502", "\u0426\u0438\u0442\u0430\u0442\u0430")):
            cleaned.append(stripped)
            continue

        # Сохраняем цитаты (начинаются с '>')
        if stripped.startswith('>'):
            cleaned.append(stripped)
            continue

        # Пропускаем строки "понравилось это" вместе с предыдущей строкой, если она была просто именем
        if 'понравилось это' in stripped.lower():
            if cleaned and re.match(r'^[\s\w,]+$', cleaned[-1]):
                cleaned.pop()
            continue

        # Пропускаем строки, состоящие только из имён (короткие, запятые)
        if re.match(r'^[\w\s]+(,\s*[\w\s]+)+$', stripped, re.UNICODE) and len(stripped) < 100:
            continue

        # Список паттернов для полного игнорирования строки
        skip_patterns = [
            r'^Показать содержимое$',
            r'^Ссылка на сообщение$',
            r'^Поделиться сообщением$',
            r'^Поделиться$',
            r'^Редактировать$',
            r'^Цитата$',
            r'^Quote$',
            r'^said\s+On\s+',
            r'^\d+\s+members?\s+like\s+this$',
            r'^\d+\s+чел\.\s+понравилось$',
            r'^Нравится$',
            r'^Ответить$',
            r'\s+reacted to this$',
            r'^Жалоба$',
            r'^Пожаловаться$',
            r'^Отредактировано\s*$',
            r'^Изменено\s*$',
            r'^Модератор\s*$',
            r'^Предупреждение',
            r'^Награда',
            r'^Изменено пользователем',
            r'^Отредактировано пользователем',
            r'^Получить ссылку$',
            r'^Скопировать ссылку$',
            r'^Вставить в цитату$',
            r'^_multiquote',
            r'^ipsQuote',
            r'^Posted\s+\w+',
            r'^Опубликовано',
            r'^\d+\s+others?\s+like\s+this$',
            r'^\d+\s+людям\s+понравилось$',
        ]
        skip = False
        for sp in skip_patterns:
            if re.match(sp, stripped, re.I):
                skip = True
                break
        if skip:
            continue

        # Пропускаем строки, похожие на атрибуты HTML
        if re.match(r'^(data-[a-z]+|class=|id=|style=|href=|src=)', stripped, re.I):
            continue

        # Пропускаем строки, состоящие только из знаков пунктуации
        if re.match(r'^[\s\.\,\;\:\!\?\-\_\=\+\*\#\@\$\%\^\&\(\)\[\]\{\}\/\\]+$', stripped):
            continue

        cleaned.append(stripped)

    text = '\n'.join(cleaned)

    # Обрезаем текст до появления маркеров окончания (кнопки форума)
    # Только если маркер является ЕДИНСТВЕННЫМ текстом на строке (Bug 5 fix)
    end_markers = [
        'ссылка на сообщение', 'поделиться сообщением',
        'редактировать', 'поделиться', 'жалоба', 'пожаловаться',
    ]
    for marker in end_markers:
        for i, line in enumerate(text.split('\n')):
            if line.strip().lower() == marker:
                text = '\n'.join(text.split('\n')[:i]).strip()
                return _final_cleanup(text)

    # Финальная чистка: лишние пробелы и переносы
    return _final_cleanup(text)


def _final_cleanup(text):
    """Финальная чистка текста: лишние пробелы и переносы."""
    text = re.sub(r'  +', ' ', text)
    text = re.sub(r'\n{3,}', '\n\n', text).strip()
    return text


def download_file(url, path, session, timeout=30, on_log=None):
    """Скачивает файл по URL, проверяет минимальный размер для изображений, удаляет недокачанные файлы.
    Включает механизм повторных попыток."""
    max_retries = getattr(Config, 'DOWNLOAD_MAX_RETRIES', 3)
    retry_delay = getattr(Config, 'DOWNLOAD_RETRY_DELAY', 2)
    last_error = None
    logger.debug("Попытка скачивания: %s", url)
    for attempt in range(max_retries + 1):
        try:
            # Bug 4 fix: Set Referer to prodota.ru for prodota.ru domains
            old_referer = session.headers.get('Referer', '')
            parsed_url = urlparse(url)
            if 'prodota.ru' in (parsed_url.netloc or ''):
                session.headers['Referer'] = 'https://prodota.ru/'
            elif parsed_url.netloc:
                session.headers['Referer'] = f"{parsed_url.scheme}://{parsed_url.netloc}/"
            # Bug 3 fix: removed headers={'Connection': 'close'} — no longer force-close TCP
            try:
                r = session.get(url, stream=True, timeout=timeout)
            finally:
                # Restore original Referer
                if old_referer:
                    session.headers['Referer'] = old_referer
                else:
                    session.headers.pop('Referer', None)
            r.raise_for_status()
            # Если сервер вернул HTML вместо файла – отказываемся
            if 'text/html' in r.headers.get('Content-Type', ''):
                return False
            path.parent.mkdir(parents=True, exist_ok=True)
            content_length = int(r.headers.get('Content-Length', 0))
            ext = path.suffix.lower()
            # Для изображений проверяем размер
            if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'):
                if content_length > 0 and content_length < Config.MIN_IMAGE_SIZE:
                    logger.debug("Пропущено (мало байт %d): %s", content_length, url)
                    if on_log:
                        on_log(f"  .. Пропущен (мало байт {content_length}): {os.path.basename(str(path))}", "debug")
                    return False
            # Запись файла
            with open(path, 'wb') as f:
                for chunk in r.iter_content(8192):
                    f.write(chunk)
            # После записи проверяем фактический размер изображения
            if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'):
                actual_size = path.stat().st_size
                if actual_size < Config.MIN_IMAGE_SIZE:
                    path.unlink()
                    logger.debug("Удалено (файл %d байт < %d): %s", actual_size, Config.MIN_IMAGE_SIZE, url)
                    if on_log:
                        on_log(f"  .. Удалён маленький файл ({actual_size} байт): {path.name}", "debug")
                    return False
            if on_log:
                on_log(f"  + Скачан: {path.name}", "debug")
            return True
        except Exception as e:
            last_error = e
            # Удаляем частично скачанный файл
            try:
                if path.exists():
                    path.unlink()
                    logger.debug("Удалён недокачанный файл: %s", path)
            except Exception:
                pass
            if attempt < max_retries:
                time.sleep(retry_delay * (attempt + 1))
                if on_log:
                    on_log(f"  .. Повтор скачивания {attempt + 2}/{max_retries + 1}: {os.path.basename(str(path))}", "debug")
                continue
            # Bug 9: log full URL on failure for debugging
            logger.debug("Ошибка скачивания (все попытки) %s: %s", url, e)
            if on_log:
                on_log(f"  !! Ошибка скачивания: {os.path.basename(str(path))} | {url[:80]}", "debug")
            return False


def is_url_junk(url):
    """Проверяет URL на принадлежность к мусорным (эмодзи, аватары, иконки).
    
    Порядок проверки:
    1. Критический фильтр (data:, spacer и т.п.) → сразу мусор
    2. Белый список (контентные изображения/видео) → НЕ мусор (приоритет!)
    3. Фильтр исключений (аватары, иконки) → мусор
    """
    if not url:
        return True
    # Критический фильтр – сразу отбрасываем
    for pat in _FILTER_CRITICAL_COMPILED:
        if pat.search(url):
            logger.debug("URL отфильтрован (critical): %s -> %s", pat.pattern, url)
            return True
    # Белый список проверяем ДО фильтра исключений!
    # Это исправляет баг: /uploads/monthly_*/2024/*.jpg попадал под FILTER_OUT
    # и блокировался до того, как ALWAYS_ALLOW мог его разрешить
    for pat in _ALWAYS_ALLOW_COMPILED:
        if pat.search(url):
            return False
    # Фильтр исключений – если подходит, отбрасываем
    for pat in _FILTER_OUT_COMPILED:
        if pat.search(url):
            logger.debug("URL отфильтрован (filter_out): %s -> %s", pat.pattern, url)
            return True
    return False


def unthumb_url(url):
    """Преобразует URL с миниатюрой в URL полного изображения."""
    if not url:
        return url
    # Handle prodota.ru: filename.thumb.ext.hash.ext → filename.ext.hash.ext (Bug 2 fix)
    url = re.sub(r'\.thumb(\.(jpg|jpeg|png|gif|webp))\.', r'\1.', url, flags=re.I)
    # Удаляем /thumb-... или _thumb. расширение
    url = re.sub(r'/thumb(-[^/]*\.(jpg|jpeg|png|gif|webp))$', r'\1', url, flags=re.I)
    url = re.sub(r'_thumb(\.(jpg|jpeg|png|gif|webp))$', r'\1', url, flags=re.I)
    url = re.sub(r'[?&]thumb=[^&]*', '', url)
    url = re.sub(r'\?$', '', url)
    return url


# ----------------------------------------------------------------------
# Базовый класс парсера (общая логика загрузки страниц, навигации)
# ----------------------------------------------------------------------

class BaseParser:
    """Базовый класс, реализующий обход страниц, получение супа, определение количества страниц, извлечение авторов и т.д."""

    def __init__(self, start_url, on_log, on_progress, stop_flag, pause_flag,
                 allowed_authors, start_page=0, end_page=0):
        """
        :param start_url: URL темы форума
        :param on_log: функция обратного вызова для логирования (msg, level)
        :param on_progress: функция обратного вызова для обновления прогресса (current, total)
        :param stop_flag: threading.Event для сигнала остановки
        :param pause_flag: threading.Event для паузы
        :param allowed_authors: set имён авторов, если None – все авторы
        :param start_page: номер страницы, с которой начинать (0 – первая)
        :param end_page: номер последней страницы (0 – до конца)
        """
        self.start_url = self._normalize_url(start_url, start_page)
        self.on_log = on_log
        self.on_progress = on_progress
        self.stop_flag = stop_flag
        self.pause_flag = pause_flag
        self.allowed_authors = allowed_authors
        self.end_page = end_page
        self.session = self._create_session()
        self.total_pages = None           # общее количество страниц в теме
        self.current_page_num = start_page if start_page > 1 else 1
        self.pages_parsed = 0             # сколько страниц уже обработано
        self.topic_title = None           # заголовок темы
        self.topic_dir = None             # папка для сохранения результатов
        self.consecutive_failures = 0     # счётчик последовательных ошибок

    def _normalize_url(self, url, start_page):
        """Приводит URL к каноническому виду, удаляет /page/X/ и добавляет /page/start_page/ если нужно."""
        url = url.split('#')[0]
        url = re.sub(r'/page/\d+/?#?', '', url)
        url = re.sub(r'\?page=\d+', '', url)
        url = url.rstrip('/')
        if start_page <= 1:
            return url + '/'
        else:
            return url + f'/page/{start_page}/'

    def _create_session(self):
        """Создаёт requests.Session с настройками повторных попыток и стандартными заголовками."""
        s = requests.Session()
        # Bug 3 fix: backoff_factor=0 — we handle retries ourselves
        retry = Retry(total=0, backoff_factor=0,
                      status_forcelist=[500, 502, 503, 504, 429],
                      redirect=5)
        s.mount('https://', HTTPAdapter(max_retries=retry))
        s.mount('http://', HTTPAdapter(max_retries=retry))
        # Bug 3 fix: removed 'br' from Accept-Encoding (brotli not supported by requests without extra package)
        s.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-US;q=0.8,en;q=0.7',
            'Accept-Encoding': 'gzip, deflate',
            'Connection': 'keep-alive',
            'Referer': 'https://prodota.ru/',
        })
        return s

    def _mount_adapters(self):
        """Переустанавливает адаптеры без пересоздания сессии."""
        retry = Retry(total=0, backoff_factor=0,
                      status_forcelist=[500, 502, 503, 504, 429],
                      redirect=5)
        self.session.mount('https://', HTTPAdapter(max_retries=retry))
        self.session.mount('http://', HTTPAdapter(max_retries=retry))

    def _reset_connections(self):
        """Сбрасывает соединения без полного пересоздания сессии."""
        self._rotate_ua()
        self.session.adapters.clear()
        self._mount_adapters()

    def _get_ua(self):
        """Возвращает случайный User-Agent из списка."""
        return random.choice(Config.USER_AGENTS)

    def _rotate_ua(self):
        """Обновляет User-Agent в текущей сессии."""
        self.session.headers.update({'User-Agent': self._get_ua()})

    def _get_effective_end_page(self):
        """Возвращает фактическую последнюю страницу с учётом self.total_pages и self.end_page."""
        if self.end_page > 0:
            if self.total_pages and self.end_page > self.total_pages:
                return self.total_pages
            return self.end_page
        return self.total_pages

    def _init_topic_dir(self, base_path, subfolder):
        """Создаёт папку для сохранения результатов (тема + подпапка).
        
        Bug 1 fix: when allowed_authors is set, ignore subfolder parameter
        and don't add it to the path. This prevents creating 'text/' inside
        the topic folder when filtering by author.
        """
        if not self.topic_title:
            self.topic_title = 'Без названия'
        topic_folder = sanitize_filename(self.topic_title)
        if self.allowed_authors:
            # Bug 1 fix: no subfolder when filtering by author
            self.topic_dir = Path(base_path) / 'Prodota Parser' / topic_folder
        else:
            self.topic_dir = Path(base_path) / 'Prodota Parser' / topic_folder / subfolder
        try:
            self.topic_dir.mkdir(parents=True, exist_ok=True)
            self.on_log(f"Папка: {self.topic_dir}", "info")
            logger.debug("Создана папка: %s", self.topic_dir)
        except Exception as e:
            self.on_log(f"Ошибка создания папки: {e}", "error")
            import tempfile
            if self.allowed_authors:
                self.topic_dir = Path(tempfile.gettempdir()) / topic_folder
            else:
                self.topic_dir = Path(tempfile.gettempdir()) / topic_folder / subfolder
            self.topic_dir.mkdir(parents=True, exist_ok=True)
            self.on_log(f"Запасная папка: {self.topic_dir}", "warning")
        return self.topic_dir

    def get_soup(self, url, retry=0):
        """
        Загружает страницу, обрабатывает ошибки, блокировки, делает повторные попытки.
        Возвращает BeautifulSoup объект или None.
        """
        # Цикл вместо рекурсии
        while retry <= Config.MAX_RETRIES:
            # Проверка остановки
            if self.stop_flag.is_set():
                return None
            # Ожидание при паузе
            while self.pause_flag.is_set() and not self.stop_flag.is_set():
                time.sleep(0.5)
            if self.stop_flag.is_set():
                return None

            t0 = time.time()
            try:
                self.session.headers.update({'User-Agent': self._get_ua()})
                self.on_log(f"  -> Запрос: {url[:80]}{'...' if len(url) > 80 else ''}", "debug")
                resp = self.session.get(url, timeout=Config.REQUEST_TIMEOUT)
                elapsed = time.time() - t0

                # Bug 3 fix: reduced wait times for HTTP errors
                if resp.status_code in (403, 429) or resp.status_code >= 500:
                    if retry < Config.MAX_RETRIES:
                        wait = 1.5 * (retry + 1) + random.uniform(0.3, 1)
                        self.on_log(f"  !! HTTP {resp.status_code} ({elapsed:.1f}с), повтор через {int(wait)}с [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                        time.sleep(wait)
                        self._rotate_ua()
                        retry += 1
                        continue
                    self.on_log(f"  XX HTTP {resp.status_code} - все попытки исчерпаны", "error")
                    return None

                resp.raise_for_status()
                soup = BeautifulSoup(resp.content, 'html.parser')

                # Проверка на блокировку (captcha, cloudflare)
                if self._is_blocked(soup):
                    if retry < Config.MAX_RETRIES:
                        wait = 5 * (retry + 1) + random.uniform(1, 3)
                        self.on_log(f"  !! Блокировка обнаружена ({elapsed:.1f}с), пауза {int(wait)}с [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                        time.sleep(wait)
                        self._rotate_ua()
                        retry += 1
                        continue
                    self.on_log(f"  XX Блокировка - все попытки исчерпаны", "error")
                    return None

                size_kb = len(resp.content) / 1024
                self.on_log(f"  OK Загружена за {elapsed:.1f}с ({size_kb:.0f} KB)", "debug")
                return soup

            except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
                elapsed = time.time() - t0
                # Bug 3 fix: reduced wait times for connection errors
                if retry < Config.MAX_RETRIES:
                    wait = 1.5 * (retry + 1) + random.uniform(0.3, 1)
                    self.on_log(f"  !! {type(e).__name__} ({elapsed:.1f}с), повтор через {int(wait)}с [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                    time.sleep(wait)
                    self._rotate_ua()
                    retry += 1
                    continue
                self.on_log(f"  XX {type(e).__name__} - все попытки исчерпаны", "error")
                return None
            except Exception as e:
                elapsed = time.time() - t0
                if retry < Config.MAX_RETRIES:
                    wait = 1.5 * (retry + 1)
                    self.on_log(f"  !! Ошибка {type(e).__name__} ({elapsed:.1f}с), повтор [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                    time.sleep(wait)
                    self._rotate_ua()
                    retry += 1
                    continue
                self.on_log(f"  XX Неизвестная ошибка: {type(e).__name__}", "error")
                return None
        return None

    def _is_blocked(self, soup):
        """Проверяет, не заблокирован ли доступ (captcha, cloudflare и т.п.).
        Only check first 3000 chars and require multiple indicators or title match."""
        # Only check beginning of page (block pages show message at top)
        text = soup.get_text()[:3000].lower()
        # Check title specifically
        title = soup.find('title')
        title_text = title.get_text().lower() if title else ''
        block_indicators = ['captcha', 'cloudflare', 'checking your browser',
                            'ddos-guard', 'доступ запрещен', 'access denied',
                            'you have been blocked', 'ip has been blocked']
        # Require multiple indicators (>=3) or title match to avoid false positives
        # Normal posts discussing captcha/cloudflare have 1-2 words, block pages have many
        matches = sum(1 for w in block_indicators if w in text)
        title_match = any(w in title_text for w in block_indicators)
        return matches >= 3 or title_match

    def get_total_pages(self, soup):
        """Извлекает общее количество страниц из супа (через ссылку "последняя" или текст)."""
        for sel in Config.SELECTORS['last_page']:
            el = soup.select_one(sel)
            if el and el.get('href'):
                m = re.search(r'page[/=_-]?(\d+)', el['href'])
                if m:
                    return int(m.group(1))
        link_last = soup.find('link', rel='last')
        if link_last and link_last.get('href'):
            m = re.search(r'page[/=_-]?(\d+)', link_last['href'])
            if m:
                return int(m.group(1))
        m = re.search(r'Страница\s+\d+\s+из\s+(\d+)', soup.get_text(), re.I)
        return int(m.group(1)) if m else None

    def get_post_blocks(self, soup):
        """Находит все блоки постов на странице по заданным селекторам."""
        for sel in Config.SELECTORS['post_blocks']:
            blocks = soup.select(sel)
            if blocks:
                self.on_log(f"По селектору {sel} найдено {len(blocks)} блоков", "debug")
                return blocks
        self.on_log("Не найдено блоков постов ни по одному селектору", "warning")
        return []

    def get_next_url(self, soup, cur):
        """Извлекает URL следующей страницы из супа."""
        for sel in Config.SELECTORS['next_page']:
            el = soup.select_one(sel)
            if el and el.get('href'):
                return urljoin(cur, el['href'])
        link_next = soup.find('link', rel='next')
        if link_next and link_next.get('href'):
            return urljoin(cur, link_next['href'])
        return None

    def extract_post_id(self, el):
        """Извлекает ID поста из HTML-элемента."""
        if el.get('id'):
            m = re.search(r'elComment[_-](\d+)|Comment-(\d+)|Post-(\d+)|comment_(\d+)', el['id'], re.I)
            if m:
                return next((g for g in m.groups() if g), None)
        if el.get('data-commentid'):
            return str(el['data-commentid'])
        link = el.select_one('a[href*="#comment-"], a[href*="#post-"]')
        if link and link.get('href'):
            m = re.search(r'#(?:comment|post)-?(\d+)', link['href'], re.I)
            if m:
                return m.group(1)
        return None

    def extract_author(self, el):
        """Извлекает имя автора из элемента поста."""
        for sel in Config.SELECTORS['author']:
            a = el.select_one(sel)
            if a:
                # Prefer nested <a> tag which contains only the username
                inner_a = a.find('a')
                if inner_a:
                    t = inner_a.get_text(strip=True)
                    if t:
                        return t
                # Fallback: get direct text of the element
                t = a.get_text(strip=True)
                if t:
                    return t
        return None

    def is_author_allowed(self, author):
        """Проверяет, разрешён ли автор (сравнение без учёта регистра)."""
        if self.allowed_authors is None:
            return True
        if not author:
            return False
        author_lower = author.lower()
        for allowed in self.allowed_authors:
            if allowed.lower() == author_lower:
                return True
        return False

    def extract_page_number(self, url):
        """Извлекает номер страницы из URL."""
        m = re.search(r'[?&]page=(\d+)', url)
        if m:
            return int(m.group(1))
        m = re.search(r'/page/(\d+)', url)
        if m:
            return int(m.group(1))
        return 1

    def iterate_pages(self):
        """Генератор, который последовательно выдаёт (soup, url, page_num) для всех страниц темы."""
        cur = self.start_url
        self.current_page_num = self.extract_page_number(cur)

        # Загрузка первой страницы с повторными попытками
        soup = None
        self.on_log(f">> Начало: {cur[:70]}{'...' if len(cur) > 70 else ''}", "info")
        for attempt in range(Config.PAGE_RETRY_COUNT):
            if self.stop_flag.is_set():
                return
            soup = self.get_soup(cur)
            if soup:
                break
            # Bug 8: use Config.PAGE_RETRY_BASE_DELAY
            wait = Config.PAGE_RETRY_BASE_DELAY * (attempt + 1) // 2 + 1
            self.on_log(f"  !! Первая страница не загружена, попытка {attempt + 1}/{Config.PAGE_RETRY_COUNT}, пауза {wait}с", "warning")
            time.sleep(wait)
            self._reset_connections()

        if not soup:
            self.on_log("  XX Не удалось загрузить первую страницу после всех попыток!", "error")
            return

        self.total_pages = self.get_total_pages(soup)
        effective_end = self._get_effective_end_page()
        end_str = str(effective_end) if effective_end else '?'
        self.on_log(f"Всего страниц: {self.total_pages or '?'}, парсинг: {self.current_page_num} - {end_str}", "info")

        yield soup, cur, self.current_page_num
        self.pages_parsed = 1
        self.consecutive_failures = 0

        # Цикл по остальным страницам
        while not self.stop_flag.is_set():
            while self.pause_flag.is_set() and not self.stop_flag.is_set():
                time.sleep(0.5)
            if self.stop_flag.is_set():
                break

            if self.end_page > 0 and self.current_page_num >= self.end_page:
                self.on_log(f">> Достигнута конечная страница: {self.end_page}", "success")
                break

            nxt = self.get_next_url(soup, cur)
            if not nxt or nxt.rstrip('/') == cur.rstrip('/'):
                # Если ссылки next нет, формируем вручную
                if self.total_pages and self.current_page_num < self.total_pages:
                    next_page = self.current_page_num + 1
                    if self.end_page > 0 and next_page > self.end_page:
                        break
                    base_url = self.start_url.split('?')[0].rstrip('/')
                    base_url = re.sub(r'/page/\d+/?', '', base_url)
                    nxt = base_url + f'/page/{next_page}/'
                else:
                    break

            cur = nxt
            next_page_num = self.extract_page_number(cur)
            self.on_log(f">> Переход к странице {next_page_num}...", "info")
            self.current_page_num = next_page_num
            soup = None

            # Если много ошибок подряд – увеличиваем паузу
            if self.consecutive_failures >= Config.MAX_CONSECUTIVE_FAILURES:
                severe_wait = min(
                    15,
                    Config.PAGE_RETRY_BASE_DELAY * (self.consecutive_failures + 1)
                )
                self.on_log(f"  !! Серия ошибок ({self.consecutive_failures}), пауза {int(severe_wait)}с", "warning")
                time.sleep(severe_wait)
                self._reset_connections()

            # Повторные попытки для текущей страницы
            for attempt in range(Config.PAGE_RETRY_COUNT):
                if self.stop_flag.is_set():
                    break
                soup = self.get_soup(cur)
                if soup:
                    blocks = self.get_post_blocks(soup)
                    if blocks:
                        break
                    else:
                        self.on_log(f"  !! Стр. {self.current_page_num}: загружена, но посты не найдены", "warning")
                        soup = None
                        wait = 2 * (attempt + 1)
                        self.on_log(f"  .. Повтор через {wait}с [{attempt + 1}/{Config.PAGE_RETRY_COUNT}]", "warning")
                        time.sleep(wait)
                        self._reset_connections()
                        continue
                else:
                    wait = 1.5 * (attempt + 1) + random.uniform(0.3, 1)
                    self.on_log(
                        f"  !! Стр. {self.current_page_num}: ошибка загрузки, "
                        f"повтор через {int(wait)}с [{attempt + 1}/{Config.PAGE_RETRY_COUNT}]",
                        "warning"
                    )
                    time.sleep(wait)
                    self._reset_connections()

            if not soup:
                self.on_log(f"  XX Стр. {self.current_page_num}: ПРОПУЩЕНА после {Config.PAGE_RETRY_COUNT} попыток", "error")
                self.consecutive_failures += 1
                continue

            self.consecutive_failures = 0
            yield soup, cur, self.current_page_num
            self.pages_parsed += 1

            delay = random.uniform(Config.MIN_DELAY, Config.MAX_DELAY)
            self.on_log(f"  .. Задержка {delay:.1f}с перед следующей страницей", "debug")
            time.sleep(delay)

    def extract_topic_title(self, soup):
        """Извлекает заголовок темы из супа (первый вызов)."""
        if self.topic_title:
            return self.topic_title
        for sel in Config.SELECTORS.get('topic_title', []):
            el = soup.select_one(sel)
            if el:
                title = el.get_text(strip=True)
                if title:
                    self.topic_title = title
                    return title
        title_tag = soup.find('title')
        if title_tag:
            title = title_tag.get_text(strip=True)
            title = re.sub(r'\s*[—\-–]\s*Page\s+\d+.*$', '', title, flags=re.I)
            title = re.sub(r'\s*[—\-–]\s*Страница\s+\d+.*$', '', title, flags=re.I)
            title = re.sub(r'\s*[—\-–]\s*Таверна.*$', '', title, flags=re.I)
            title = re.sub(r'\s*[—\-–]\s*prodota\.ru.*$', '', title, flags=re.I)
            title = title.strip(' —-–')
            if title:
                self.topic_title = title
                return title
        self.topic_title = 'Без названия'
        return self.topic_title


# ----------------------------------------------------------------------
# Текстовый парсер (извлекает текст постов и сохраняет в TXT/JSON/CSV)
# ----------------------------------------------------------------------

class TextParser(BaseParser):
    """Парсер для извлечения текста сообщений."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.posts = []          # список словарей с информацией о постах
        self.seen = set()        # множество ID уже обработанных постов (для избежания дублей)
        self.first_page = None   # номер первой обработанной страницы
        self.last_page = None    # номер последней обработанной страницы

    def _get_text_content_element(self, post_element):
        """Находит элемент, содержащий текст поста."""
        direct = post_element.select_one('[data-role="commentContent"]')
        if direct:
            return direct
        for sel in Config.SELECTORS['content']:
            content_el = post_element.select_one(sel)
            if content_el:
                inner = content_el.select_one('[data-role="commentContent"]')
                if inner:
                    return inner
                return content_el
        return None

    def extract_post_text_html(self, content_element):
        """Рекурсивно обходит HTML-дерево и извлекает текст, обрабатывая цитаты, спойлеры и т.д.
        
        Bug 2 fix: added handlers for li, ul, ol, blockquote (generic), strong, b, em, i,
        code, pre, h1-h6, hr elements."""
        if not content_element:
            return ''

        parts = []  # накапливаем фрагменты текста

        # Track ordered list numbering
        _ol_counters = []

        def _walk(node):
            if not isinstance(node, Tag):
                text = str(node)
                if text:
                    parts.append(text)
                return

            name = node.name.lower() if node.name else ''

            # Пропускаем скрипты, стили
            if name in ('script', 'style', 'noscript'):
                return

            classes = node.get('class', [])
            if isinstance(classes, str):
                classes = classes.split()
            class_str = ' '.join(classes).lower()

            # Обработка спойлеров (details, ipsSpoiler)
            if name == 'details' or 'ipsspoiler' in class_str or (
                'data-ipsspoiler' in (node.get('data-ipsspoiler', '') or '').lower()
            ):
                if name == 'details':
                    for child in node.children:
                        if isinstance(child, Tag) and child.name and child.name.lower() == 'summary':
                            continue
                        _walk(child)
                else:
                    for child in node.children:
                        _walk(child)
                return

            # Обработка скрытого текста (ipsHide)
            if 'ipshide' in class_str:
                for child in node.children:
                    _walk(child)
                return

            # Обработка цитат (ipsQuote) — improved quote formatting
            if 'ipsquote' in class_str:
                quote_author = ''
                quote_citation_text = ''
                quote_username = node.get('data-ipsquote-username', '')
                if quote_username:
                    quote_author = f' ({quote_username})'
                elif node.get('data-ipsquote', ''):
                    try:
                        data = json.loads(node.get('data-ipsquote', ''))
                        if isinstance(data, dict):
                            quote_author = f" ({data.get('username', '')})"
                    except Exception:
                        pass

                # Also extract citation text (e.g., "написал 2 часа назад:")
                citation_el = node.select_one('.ipsQuote_citation')
                if citation_el:
                    quote_citation_text = citation_el.get_text(strip=True)

                quote_contents = node.select_one('.ipsQuote_contents')
                if quote_contents:
                    quote_text = self._extract_quote_body(quote_contents)
                else:
                    quote_text = node.get_text(separator=' ', strip=True)

                if quote_text:
                    parts.append(f'\n{"─" * 40}')
                    parts.append(f'Цитата{quote_author}')
                    if quote_citation_text and quote_citation_text != quote_username:
                        parts.append(f'  {quote_citation_text}')
                    parts.append(f'{"─" * 40}')
                    for qline in quote_text.split('\n'):
                        qline = qline.strip()
                        if qline:
                            parts.append(f'│ {qline}')
                    parts.append(f'{"─" * 40}')
                    parts.append('')
                return

            def _walk_quoted(nd, qparts):
                """Walk node for blockquote content, appending to qparts."""
                if not isinstance(nd, Tag):
                    qparts.append(str(nd))
                    return
                nd_name = nd.name.lower() if nd.name else ''
                if nd_name in ('script', 'style', 'noscript'):
                    return
                for child in nd.children:
                    _walk_quoted(child, qparts)

            # Bug 2 fix: handle generic <blockquote> elements
            if name == 'blockquote':
                parts.append('\n')
                # Collect quote text
                quote_parts = []
                for child in node.children:
                    # Walk child but capture into quote_parts
                    _walk_quoted(child, quote_parts)
                quote_text = ''.join(quote_parts).strip()
                if quote_text:
                    parts.append(f'{"─" * 40}')
                    parts.append(f'│ {quote_text.replace(chr(10), chr(10) + "│ ")}')
                    parts.append(f'{"─" * 40}')
                    parts.append('')
                return

            # Bug 2 fix: handle <ul> and <ol> lists
            if name == 'ul':
                parts.append('\n')
                for child in node.children:
                    if isinstance(child, Tag) and child.name and child.name.lower() == 'li':
                        parts.append('• ')
                        for li_child in child.children:
                            _walk(li_child)
                        parts.append('\n')
                    elif isinstance(child, Tag):
                        _walk(child)
                    else:
                        t = str(child).strip()
                        if t:
                            parts.append(f'• {t}\n')
                parts.append('\n')
                return

            if name == 'ol':
                parts.append('\n')
                counter = 1
                for child in node.children:
                    if isinstance(child, Tag) and child.name and child.name.lower() == 'li':
                        start = int(child.get('value', counter)) if child.get('value') else counter
                        parts.append(f'{start}. ')
                        for li_child in child.children:
                            _walk(li_child)
                        parts.append('\n')
                        counter = start + 1
                    elif isinstance(child, Tag):
                        _walk(child)
                    else:
                        t = str(child).strip()
                        if t:
                            parts.append(f'{counter}. {t}\n')
                            counter += 1
                parts.append('\n')
                return

            # Bug 2 fix: handle <li> outside of explicit ul/ol
            if name == 'li':
                parts.append('• ')
                for child in node.children:
                    _walk(child)
                parts.append('\n')
                return

            # Bug 2 fix: handle headings
            if name in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
                parts.append('\n')
                for child in node.children:
                    _walk(child)
                parts.append('\n')
                return

            # Bug 2 fix: handle <hr>
            if name == 'hr':
                parts.append('\n─' * 20 + '\n')
                return

            # Bug 2 fix: handle <strong>/<b> with **markers**
            if name in ('strong', 'b'):
                parts.append('**')
                for child in node.children:
                    _walk(child)
                parts.append('**')
                return

            # Bug 2 fix: handle <em>/<i> with *markers*
            if name in ('em', 'i'):
                parts.append('*')
                for child in node.children:
                    _walk(child)
                parts.append('*')
                return

            # Bug 2 fix: handle <code> and <pre>
            if name == 'code':
                parts.append('`')
                for child in node.children:
                    _walk(child)
                parts.append('`')
                return

            if name == 'pre':
                parts.append('\n```\n')
                for child in node.children:
                    _walk(child)
                parts.append('\n```\n')
                return

            # Пропускаем изображения (они обрабатываются отдельно)
            if name == 'img':
                return

            # Пропускаем служебные блоки (кнопки, реакции)
            if any(kw in class_str for kw in
                   ['ipsitemcontrols', 'ipsreact', 'cpost_share',
                    'cpost_moderation', 'cpost_controls',
                    'ipscomment_controls', 'ipscomment_edit',
                    'ipscomment_meta', 'ipscomment_reaction']):
                return

            # Пропускаем подписи
            if 'membersignature' in (node.get('data-role', '') or '').lower():
                return

            if name == 'time':
                return

            # Ссылки: берём только текст, не URL
            if name == 'a':
                link_text = node.get_text(strip=True)
                if link_text:
                    parts.append(link_text)
                return

            if name == 'br':
                parts.append('\n')
                return

            if name == 'p':
                parts.append('\n')
                for child in node.children:
                    _walk(child)
                parts.append('\n')
                return

            # Пропускаем удалённые посты
            if 'ipscomment_deleted' in class_str or 'ipsfaded_more' in class_str:
                return

            # Рекурсивный обход дочерних элементов
            for child in node.children:
                _walk(child)

        _walk(content_element)

        raw_text = ''.join(parts)
        raw_text = re.sub(r'\n{3,}', '\n\n', raw_text)
        raw_text = re.sub(r'\n +', '\n', raw_text)
        return raw_text.strip()

    def _extract_quote_body(self, quote_contents_el):
        """Извлекает текст из элемента цитаты, удаляя заголовок цитаты.
        Handle video URLs, images, and attachments inside quotes."""
        citation_selectors = [
            '.ipsQuote_citation', 'cite', 'p > strong > a',
            'p.ipsQuote_citation',
        ]
        temp = BeautifulSoup(str(quote_contents_el), 'html.parser')
        for s in citation_selectors:
            c = temp.select_one(s)
            if c:
                c.decompose()
                break

        # Remove video elements but add a note
        for vid in temp.select('video'):
            note = '[(видео)]'
            vid.replace_with(temp.new_string(note))

        # Remove images but add a note
        for img in temp.select('img'):
            note = '[(изображение)]'
            img.replace_with(temp.new_string(note))

        # Remove attachment links but keep filename
        for a in temp.select('a.ipsAttachLink'):
            text = a.get_text(strip=True) or '[(вложение)]'
            a.replace_with(temp.new_string(text))

        full_text = temp.get_text(separator='\n', strip=True)
        lines = full_text.split('\n')
        cleaned_lines = []
        for line in lines:
            stripped = line.strip()
            # Удаляем строки типа "User said On ..."
            if re.match(r'^.*?said\s+On\s+\d{2}/\d{2}/\d{4}\s+at\s+\d{2}:\d{2}\s*(?:AM|PM)?\s*:?\s*$', stripped, re.I):
                continue
            if re.match(r'^(?:написал|писал|написала)\s+.*?(?:назад|вчера|сегодня)', stripped, re.I):
                continue
            if re.match(r'^(?:\d+\s*(?:час|часа|часов|минут[ауы]?|секунд[ауы]?|день|дня|дней|неделю|недели|месяц|месяца|месяцев|год|года|лет)\s*)+назад$', stripped, re.I):
                continue
            if re.match(r'^Опубликовано:\s*', stripped, re.I):
                continue
            if stripped:
                cleaned_lines.append(stripped)
        # Сохраняем переносы строк между абзацами цитаты (а не склеиваем через пробел)
        return '\n'.join(cleaned_lines)

    def parse_post(self, el, page_num=1):
        """Извлекает из HTML-элемента поста данные (ID, автор, текст). Возвращает словарь или None."""
        pid = self.extract_post_id(el)
        if not pid or pid in self.seen:
            return None
        author = self.extract_author(el)
        if not self.is_author_allowed(author):
            return None

        content_el = self._get_text_content_element(el)
        if not content_el:
            return None

        # Bug 9: log post parsing start
        logger.debug("Парсинг поста #%s автор=%s страница=%d", pid, author, page_num)

        raw_text = self.extract_post_text_html(content_el)
        if not raw_text:
            return None

        cleaned_text = clean_post_content(raw_text)
        if not cleaned_text:
            return None

        self.seen.add(pid)
        logger.debug("Пост #%s успешно распарсен (%d символов)", pid, len(cleaned_text))
        return {
            'author': author or 'Anonymous',
            'post_number': pid,
            'page_number': page_num,
            'post_text': cleaned_text,
        }

    def run(self, base_path):
        """Запускает парсинг текста. Возвращает список постов."""
        self.on_log("== Режим ТЕКСТ запущен ==", "success")
        for soup, url, page_num in self.iterate_pages():
            if self.first_page is None:
                self.first_page = page_num
                self.extract_topic_title(soup)
                if self.topic_title:
                    self.on_log(f"Тема: {self.topic_title}", "info")
                self._init_topic_dir(base_path, 'text')

            for block in self.get_post_blocks(soup):
                if self.stop_flag.is_set():
                    break
                p = self.parse_post(block, page_num)
                if p:
                    self.posts.append(p)
            self.last_page = page_num

            effective_end = self._get_effective_end_page()
            end_str = str(effective_end) if effective_end else '?'
            prog = f"Страница {page_num}/{end_str} | постов: {len(self.posts)}"
            self.on_log(prog, "progress")
            self.on_progress(page_num, effective_end)
        return self.posts

    def _save_txt(self, filepath=None):
        """Сохраняет все посты в текстовый файл."""
        if not self.topic_dir:
            return None
        p = Path(filepath) if filepath else self.topic_dir / 'posts.txt'

        sep = '=' * 55
        thin_sep = '-' * 55
        topic = self.topic_title or 'Без названия'
        total_pages = self.total_pages or (self.last_page or 1)
        first = self.first_page or 1
        last = self.last_page or first

        with open(p, 'w', encoding='utf-8') as f:
            f.write(f"Название темы: {topic}\n")
            f.write(f"Общее количество страниц: {total_pages}\n")
            f.write(f"Общее количество постов: {len(self.posts)}\n")
            f.write(f"Парсинг со страницы {first} по страницу {last}\n")
            f.write(f"{sep}\n")

            if self.allowed_authors is not None:
                # Группировка по авторам
                author_posts = OrderedDict()
                for post in self.posts:
                    author = post.get('author', 'Anonymous')
                    if author not in author_posts:
                        author_posts[author] = []
                    author_posts[author].append(post)

                for i, (author, author_post_list) in enumerate(author_posts.items()):
                    if i > 0:
                        f.write(f"\n{sep}\n")
                    f.write(f"\nАВТОР: {author}\n")
                    f.write(f"Количество постов: {len(author_post_list)}\n")
                    f.write(f"{thin_sep}\n\n")
                    for post in author_post_list:
                        f.write(f"Пост #{post.get('post_number', '?')}\n")
                        f.write(f"Страница: {post.get('page_number', '?')}\n")
                        f.write(f"{thin_sep}\n")
                        f.write(f"{post.get('post_text', '')}\n")
                        f.write(f"{thin_sep}\n\n")
            else:
                for post in self.posts:
                    f.write(f"\n{sep}\n")
                    f.write(f"Пост #{post.get('post_number', '?')}\n")
                    f.write(f"Автор: {post.get('author', 'Anonymous')}\n")
                    f.write(f"Страница: {post.get('page_number', '?')}\n")
                    f.write(f"{thin_sep}\n")
                    f.write(f"{post.get('post_text', '')}\n")
        return p

    def _save_json(self, filepath=None):
        """Сохраняет все посты в JSON."""
        if not self.topic_dir:
            return None
        p = Path(filepath) if filepath else self.topic_dir / 'posts.json'

        topic = self.topic_title or 'Без названия'
        first = self.first_page or 1
        last = self.last_page or first
        total_pages = self.total_pages or (last - first + 1)

        posts_data = []
        for post in self.posts:
            posts_data.append({
                "post_number": post.get('post_number', ''),
                "author": post.get('author', ''),
                "page_number": post.get('page_number', ''),
                "post_text": post.get('post_text', ''),
            })

        data = {
            "metadata": {
                "topic": topic,
                "total_pages": total_pages,
                "total_posts": len(self.posts),
                "parsed_from_page": first,
                "parsed_to_page": last,
                "authors_filter": sorted(self.allowed_authors) if self.allowed_authors else None,
            },
            "posts": posts_data
        }

        with open(p, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        return p

    def _save_csv(self, filepath=None):
        """Сохраняет все посты в CSV."""
        if not self.topic_dir:
            return None
        p = Path(filepath) if filepath else self.topic_dir / 'posts.csv'

        with open(p, 'w', newline='', encoding='utf-8-sig') as f:
            w = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
            w.writerow([
                'Номер поста',
                'Страница',
                'Автор',
                'Текст поста'
            ])
            for post in self.posts:
                w.writerow([
                    post.get('post_number', ''),
                    post.get('page_number', ''),
                    post.get('author', ''),
                    post.get('post_text', '')
                ])
        return p

    def save_all(self):
        """Сохраняет результаты во всех форматах (TXT, JSON, CSV).
        
        Bug 1 fix: when allowed_authors is set, only create per-author files.
        Skip combined save (_save_txt, _save_json, _save_csv at topic level).
        Do NOT save into text/ subfolder when filtering.
        After saving, clean up any empty text/ folder that may have been created.
        
        Always create folders for ALL specified authors, even those with no posts.
        """
        saved = []

        # Группируем посты по авторам
        author_posts = OrderedDict()
        for post in self.posts:
            author = post.get('author', 'Anonymous')
            if author not in author_posts:
                author_posts[author] = []
            author_posts[author].append(post)

        if self.allowed_authors:
            # Bug 1 fix: фильтр авторов задан — ТОЛЬКО отдельные папки по каждому автору
            # НЕ создаём общие файлы в text/ папке

            # Determine topic base (parent of topic_dir, which may or may not have 'text' appended)
            # When allowed_authors is set, _init_topic_dir already doesn't add subfolder,
            # so self.topic_dir points directly to topic folder
            topic_base = self.topic_dir

            # Create folders for ALL specified authors first
            for author in self.allowed_authors:
                safe_author = sanitize_filename(author, 60)
                author_dir = topic_base / safe_author
                try:
                    author_dir.mkdir(parents=True, exist_ok=True)
                    logger.debug("Создана папка автора: %s", author_dir)
                except Exception:
                    pass

            # Save per-author files (only for authors who have posts)
            for author, author_post_list in author_posts.items():
                safe_author = sanitize_filename(author, 60)
                # Bug 1 fix: per-author folders at topic level (not inside text/)
                author_dir = topic_base / safe_author
                try:
                    author_dir.mkdir(parents=True, exist_ok=True)
                    logger.debug("Создана папка автора: %s", author_dir)
                except Exception as e:
                    self.on_log(f"Ошибка создания папки автора {author}: {e}", "error")
                    continue

                txt_path = author_dir / f"{safe_author} posts.txt"
                json_path = author_dir / f"{safe_author} posts.json"
                csv_path = author_dir / f"{safe_author} posts.csv"

                sep = '=' * 55
                thin_sep = '-' * 55
                topic = self.topic_title or 'Без названия'
                total_pages = self.total_pages or (self.last_page or 1)
                first = self.first_page or 1
                last = self.last_page or first

                with open(txt_path, 'w', encoding='utf-8') as f:
                    f.write(f"Название темы: {topic}\n")
                    f.write(f"Общее количество страниц: {total_pages}\n")
                    f.write(f"Количество постов автора: {len(author_post_list)}\n")
                    f.write(f"Парсинг со страницы {first} по страницу {last}\n")
                    f.write(f"{sep}\n\n")
                    f.write(f"АВТОР: {author}\n")
                    f.write(f"Количество постов: {len(author_post_list)}\n")
                    f.write(f"{thin_sep}\n\n")
                    for post in author_post_list:
                        f.write(f"Пост #{post.get('post_number', '?')}\n")
                        f.write(f"Страница: {post.get('page_number', '?')}\n")
                        f.write(f"{thin_sep}\n")
                        f.write(f"{post.get('post_text', '')}\n")
                        f.write(f"{thin_sep}\n\n")
                saved.append(txt_path)

                posts_data = [{
                    "post_number": post.get('post_number', ''),
                    "author": post.get('author', ''),
                    "page_number": post.get('page_number', ''),
                    "post_text": post.get('post_text', ''),
                } for post in author_post_list]

                json_data = {
                    "metadata": {
                        "topic": topic,
                        "total_pages": total_pages,
                        "total_posts": len(author_post_list),
                        "parsed_from_page": first,
                        "parsed_to_page": last,
                        "authors_filter": [author],
                    },
                    "posts": posts_data
                }
                with open(json_path, 'w', encoding='utf-8') as f:
                    json.dump(json_data, f, ensure_ascii=False, indent=2)
                saved.append(json_path)

                with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
                    w = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
                    w.writerow(['Номер поста', 'Страница', 'Автор', 'Текст поста'])
                    for post in author_post_list:
                        w.writerow([
                            post.get('post_number', ''),
                            post.get('page_number', ''),
                            post.get('author', ''),
                            post.get('post_text', '')
                        ])
                saved.append(csv_path)
                self.on_log(f"  Папка автора: {author_dir}", "info")

            # Bug 1 fix: clean up empty 'text' folder if it was created
            text_folder = topic_base / 'text'
            _try_remove_empty_dir(text_folder)
        else:
            # Нет фильтра авторов – только общие файлы
            if self.posts:
                t = self._save_txt()
                j = self._save_json()
                c = self._save_csv()
                if t:
                    saved.append(t)
                if j:
                    saved.append(j)
                if c:
                    saved.append(c)
        return saved


# ----------------------------------------------------------------------
# Медиапарсер (извлекает изображения и видео)
# ----------------------------------------------------------------------

class MediaParser(BaseParser):
    """Парсер для скачивания изображений и видео из постов."""

    def __init__(self, *args, media_type='both', **kwargs):
        super().__init__(*args, **kwargs)
        self.media_type = media_type        # 'images', 'videos' или 'both'
        self.downloaded = 0                 # количество успешно скачанных файлов
        self.filtered_out = 0               # количество отфильтрованных URL
        self.metadata = []                  # список словарей с информацией о скачанных файлах
        self.seen_video_urls = set()        # нормализованные URL видео для избежания дублей
        self.seen_image_urls = set()        # нормализованные URL изображений
        self.duplicates_skipped = 0         # количество пропущенных дублей видео
        self.image_duplicates_skipped = 0   # количество пропущенных дублей изображений

    def _get_content_element(self, post_element):
        """Находит элемент с содержимым поста (аналогично текстовому парсеру)."""
        for sel in Config.SELECTORS['content']:
            content_el = post_element.select_one(sel)
            if content_el:
                inner = content_el.select_one('[data-role="commentContent"]')
                if inner:
                    return inner
                return content_el
        return None

    def _is_inside_quote(self, elem):
        """Проверяет, находится ли элемент внутри цитаты."""
        if not isinstance(elem, Tag):
            return False
        parent = elem.parent
        depth = 0
        while parent and isinstance(parent, Tag) and depth < 15:
            classes = parent.get('class', [])
            if isinstance(classes, str):
                classes = classes.split()
            class_str = ' '.join(classes).lower()
            # Если достигли основного контента – не в цитате
            if 'cpost_contentwrap' in class_str:
                return False
            role = parent.get('data-role', '')
            if isinstance(role, str) and role.lower() == 'commentcontent':
                return False
            if 'ipsquote' in class_str:
                return True
            depth += 1
            parent = parent.parent
        return False

    def _is_inside_signature(self, elem):
        """Проверяет, находится ли элемент внутри подписи пользователя."""
        if not isinstance(elem, Tag):
            return False
        parent = elem.parent
        depth = 0
        while parent and isinstance(parent, Tag) and depth < 10:
            role = parent.get('data-role', '')
            if isinstance(role, str) and role.lower() == 'commentcontent':
                return False
            classes = parent.get('class', [])
            if isinstance(classes, str):
                classes = classes.split()
            class_str = ' '.join(classes).lower()
            if 'cpost_contentwrap' in class_str:
                return False
            if isinstance(role, str) and role.lower() == 'membersignature':
                return True
            depth += 1
            parent = parent.parent
        return False

    @staticmethod
    def _normalize_video_url(url):
        """Нормализует URL видео (только путь, без параметров)."""
        if not url:
            return ''
        parsed = urlparse(url)
        return parsed.path.lower()

    @staticmethod
    def _normalize_image_url(url):
        """Нормализует URL изображения (только путь, без параметров)."""
        if not url:
            return ''
        parsed = urlparse(url)
        return parsed.path.lower()

    def _is_junk_element(self, elem):
        """Проверяет, является ли элемент мусорным (аватар, эмодзи, кнопка реакции и т.п.)."""
        if not isinstance(elem, Tag):
            return True
        classes = elem.get('class', [])
        if isinstance(classes, str):
            classes = classes.split()
        class_str = ' '.join(classes).lower()

        junk_classes = [
            'cauthorpane', 'ipscomment_author', 'author', 'avatar',
            'ipsuserphoto', 'ipsbadge', 'ipsrank', 'ipsribbon', 'ipsgroup',
            'ipsreact', 'ipsrep', 'ipscomment_controls', 'ipscomment_header',
            'ipscomment_meta', 'ipscomment_reaction', 'ipsquote',
            'ipstooltip', 'ipsfaded', 'ipsmenu', 'ipsdialog',
            'emoji', 'emoticon', 'smiley', 'smilie',
            'reaction', 'like', 'rep',
            'cpost_share', 'cpost_moderation', 'cpost_controls',
            'ipscomment_edit', 'ipscomment_deleted', 'ipscomment_wrap',
            'ipsquote_header', 'ipsquote_contents',
        ]
        for jc in junk_classes:
            if jc in class_str:
                return True

        for attr in elem.attrs:
            attr_lower = attr.lower()
            if any(kw in attr_lower for kw in ['avatar', 'emoji', 'emoticon', 'reaction', 'badge', 'rank']):
                return True

        if elem.has_attr('data-emoticon'):
            return True

        alt = elem.get('alt', '')
        if alt and re.match(r'^:[\w]+:$', alt.strip()):
            return True

        # Проверка родителей
        parent = elem.parent
        depth = 0
        while parent and isinstance(parent, Tag) and depth < 8:
            parent_classes = parent.get('class', [])
            if isinstance(parent_classes, str):
                parent_classes = parent_classes.split()
            parent_class_str = ' '.join(parent_classes).lower()

            if 'cpost_contentwrap' in parent_class_str:
                break
            parent_role = parent.get('data-role', '')
            if isinstance(parent_role, str) and parent_role.lower() == 'commentcontent':
                break

            for jc in ['ipsreact', 'ipscomment_meta', 'ipscomment_controls',
                        'cpost_share', 'cpost_moderation', 'cpost_controls',
                        'ipscomment_header', 'ipsmenu', 'ipsdialog',
                        'ipsitemcontrols']:
                if jc in parent_class_str:
                    return True
            if isinstance(parent_role, str) and parent_role.lower() == 'membersignature':
                return True
            depth += 1
            parent = parent.parent

        return False

    def extract_media(self, post_element, pid, author):
        """
        Извлекает из поста URL изображений и видео.
        Возвращает список кортежей (type, url, post_id, author).
        """
        items = []
        seen = set()

        content_el = self._get_content_element(post_element)
        if not content_el:
            return items

        # Селекторы для изображений
        image_selectors = [
            'img[src]', 'img[data-src]', 'img[data-original]',
            'img[data-lazy-src]', 'img[data-cfsrc]',
            'a[href*=".jpg"]', 'a[href*=".jpeg"]', 'a[href*=".png"]',
            'a[href*=".gif"]', 'a[href*=".webp"]', 'a[href*=".bmp"]',
            '.ipsAttachments a[href*="."]', 'figure img', 'figure a[href*="."]',
        ]

        # Селекторы для видео
        video_selectors = [
            'video source[data-video-src]', 'video source[src]',
            'video[data-video-src]', 'video[src]',
            'a[href*=".mp4"]', 'a[href*=".webm"]', 'a[href*=".mov"]',
            'a[href*=".mkv"]', 'a[href*=".avi"]',
            'a[data-fileext="mp4"]', 'a[data-fileext="webm"]',
            '.ipsAttachments a[href*=".mp4"]', '.ipsAttachments a[href*=".webm"]',
        ]

        # Обработка изображений
        if self.media_type in ('images', 'both'):
            for sel in image_selectors:
                for elem in content_el.select(sel):
                    if self._is_junk_element(elem):
                        continue
                    if self._is_inside_quote(elem):
                        continue
                    if self._is_inside_signature(elem):
                        continue

                    if elem.name == 'img':
                        url = (elem.get('data-src') or elem.get('data-original')
                               or elem.get('data-lazy-src') or elem.get('data-cfsrc')
                               or elem.get('src'))
                    else:
                        url = (elem.get('src') or elem.get('data-src')
                               or elem.get('data-original') or elem.get('data-lazy-src')
                               or elem.get('href'))
                    if not url:
                        continue

                    full = urljoin(self.start_url, url)

                    # Для ссылок, которые ведут на страницу с изображением, пытаемся взять реальный URL из вложенного img
                    if elem.name == 'a':
                        inner_img = elem.select_one('img')
                        if inner_img:
                            url_from_a = elem.get('href')
                            if url_from_a:
                                full = urljoin(self.start_url, url_from_a)

                    if is_url_junk(full):
                        self.filtered_out += 1
                        continue

                    original_url = unthumb_url(full)
                    normalized = self._normalize_image_url(original_url)
                    if normalized and normalized in self.seen_image_urls:
                        self.image_duplicates_skipped += 1
                        continue

                    if original_url not in seen:
                        seen.add(original_url)
                        if original_url != full:
                            seen.add(full)
                        if normalized:
                            self.seen_image_urls.add(normalized)
                        items.append(('image', original_url, pid, author))

            # Дополнительный поиск по атрибутам data-src и т.п. через regex
            content_html = str(content_el)
            for pat in [
                r'data-src\s*=\s*["\'](https?://[^"\'\s>]+\.(?:jpg|jpeg|png|gif|webp|bmp))',
                r'data-original\s*=\s*["\'](https?://[^"\'\s>]+\.(?:jpg|jpeg|png|gif|webp|bmp))',
                r'data-cfsrc\s*=\s*["\'](https?://[^"\'\s>]+\.(?:jpg|jpeg|png|gif|webp|bmp))',
            ]:
                for m in re.finditer(pat, content_html, re.I):
                    found_url = m.group(1).strip()
                    if any(kw in found_url.lower() for kw in ['spacer', 'blank', 'pixel', '1x1', 'transparent', 'clear']):
                        continue
                    full = urljoin(self.start_url, found_url)
                    if is_url_junk(full):
                        continue
                    original_url = unthumb_url(full)
                    normalized = self._normalize_image_url(original_url)
                    if normalized and normalized in self.seen_image_urls:
                        continue
                    if original_url not in seen:
                        seen.add(original_url)
                        if normalized:
                            self.seen_image_urls.add(normalized)
                        items.append(('image', original_url, pid, author))

        # Обработка видео
        if self.media_type in ('videos', 'both'):
            video_urls_found = []

            # Элементы <video>
            video_elements = content_el.select('video')
            for vid_elem in video_elements:
                if self._is_inside_quote(vid_elem):
                    continue
                if self._is_inside_signature(vid_elem):
                    continue
                vid_url = (vid_elem.get('src') or vid_elem.get('data-video-src')
                           or vid_elem.get('data-embedurl') or vid_elem.get('data-video-url'))
                if vid_url:
                    video_urls_found.append((vid_url, vid_elem))
                for source in vid_elem.find_all('source', recursive=False):
                    src_url = source.get('src') or source.get('data-video-src') or source.get('data-src')
                    if src_url:
                        video_urls_found.append((src_url, source))

            # Ссылки на видеофайлы
            for sel in video_selectors:
                for elem in content_el.select(sel):
                    if self._is_junk_element(elem):
                        continue
                    if self._is_inside_quote(elem):
                        continue
                    if self._is_inside_signature(elem):
                        continue
                    url = elem.get('href') or elem.get('data-video-src')
                    if url:
                        video_urls_found.append((url, elem))

            # Регулярные выражения для прямых ссылок (handle hashed video URLs)
            content_html = str(content_el)
            for pat in [
                r'data-video-src\s*=\s*["\'](https?://[^"\'\s>]+\.(?:mp4|webm|mov|mkv|avi)[^"\'\s>]*)',
                r'(?:src|href|data-video-src)\s*=\s*["\'](https?://[^"\'\s>]+?\.(?:mp4|webm|mov|mkv|avi)(?:\.[^"\'\s>]*)?)["\'\s>]',
            ]:
                for m in re.finditer(pat, content_html, re.I):
                    if re.search(r'\.(?:mp4|webm|mov|mkv|avi)\s*["\'\s>]', m.group(0), re.I):
                        video_urls_found.append((m.group(1).strip(), None))

            for raw_url, elem in video_urls_found:
                if not raw_url:
                    continue
                full = urljoin(self.start_url, raw_url)
                # Пропускаем ссылки на видеохостинги (YouTube и т.п.)
                if any(h in full.lower() for h in ['youtube', 'youtu.be', 'vimeo', 'twitch', 'dailymotion']):
                    continue
                if is_url_junk(full):
                    self.filtered_out += 1
                    continue
                if elem is not None:
                    if self._is_inside_quote(elem):
                        continue
                    if self._is_junk_element(elem):
                        continue

                normalized = self._normalize_video_url(full)
                if not normalized:
                    continue
                if normalized in self.seen_video_urls:
                    self.duplicates_skipped += 1
                    continue
                if full not in seen:
                    seen.add(full)
                    self.seen_video_urls.add(normalized)
                    items.append(('video', full, pid, author))

        return items

    def _get_author_media_dir(self, topic_base, author, mtype):
        """Возвращает путь к папке медиа для конкретного автора.
        При фильтре авторов создаёт структуру: topic_base/Имя автора/images/ (или video/)
        Без фильтра — topic_base/images/ (или video/)"""
        safe_author = sanitize_filename(author, 60)
        if self.allowed_authors:
            # Фильтр авторов задан — создаём подпапку по автору
            author_base = topic_base / safe_author
            author_base.mkdir(parents=True, exist_ok=True)
            if mtype == 'image':
                return author_base / 'images'
            elif mtype == 'video':
                return author_base / 'video'
        # Без фильтра — в общие папки
        if mtype == 'image':
            return topic_base / 'images'
        elif mtype == 'video':
            return topic_base / 'video'
        return topic_base

    def run(self, base_path):
        """Запускает парсинг медиа. Возвращает количество скачанных файлов."""
        if self.media_type in ('images', 'both'):
            img_sub = 'images'
        else:
            img_sub = None
        if self.media_type in ('videos', 'both'):
            vid_sub = 'video'
        else:
            vid_sub = None

        self.on_log(f"== Режим МЕДИА ({self.media_type}) запущен ==", "success")

        total_media_found = 0
        topic_dir_initialized = False
        # Кэш папок авторов для избежания повторного создания
        author_dirs_cache = {}

        for soup, url, page_num in self.iterate_pages():
            if not soup:
                break

            # Создаём базовую папку для темы при первом проходе
            if not topic_dir_initialized:
                self.extract_topic_title(soup)
                if self.topic_title:
                    self.on_log(f"Тема: {self.topic_title}", "info")
                topic_folder = sanitize_filename(self.topic_title or 'Без названия')
                self.topic_base = Path(base_path) / 'Prodota Parser' / topic_folder
                try:
                    self.topic_base.mkdir(parents=True, exist_ok=True)
                    logger.debug("Создана папка: %s", self.topic_base)
                except Exception:
                    import tempfile
                    self.topic_base = Path(tempfile.gettempdir()) / topic_folder
                    self.topic_base.mkdir(parents=True, exist_ok=True)

                # Bug 1 fix: only create images/video at topic root when NOT filtering by author
                if not self.allowed_authors:
                    if img_sub:
                        self.img_dir = self.topic_base / img_sub
                        self.img_dir.mkdir(parents=True, exist_ok=True)
                        logger.debug("Создана папка: %s", self.img_dir)
                    if vid_sub:
                        self.vid_dir = self.topic_base / vid_sub
                        self.vid_dir.mkdir(parents=True, exist_ok=True)
                        logger.debug("Создана папка: %s", self.vid_dir)

                self.on_log(f"Папка: {self.topic_base}", "info")
                topic_dir_initialized = True

                # Create folders for all specified authors (even if no media yet)
                if self.allowed_authors:
                    for author in self.allowed_authors:
                        safe_author = sanitize_filename(author, 60)
                        author_base = self.topic_base / safe_author
                        author_base.mkdir(parents=True, exist_ok=True)
                        logger.debug("Создана папка автора: %s", author_base)
                        if img_sub:
                            (author_base / 'images').mkdir(parents=True, exist_ok=True)
                        if vid_sub:
                            (author_base / 'video').mkdir(parents=True, exist_ok=True)

            post_blocks = self.get_post_blocks(soup)
            if not post_blocks:
                self.on_log(f"Страница {page_num}: посты не найдены", "warning")

            for block in post_blocks:
                if self.stop_flag.is_set():
                    break
                pid = self.extract_post_id(block) or 'unknown'
                author = self.extract_author(block) or 'unknown'
                if not self.is_author_allowed(author):
                    continue
                media_items = self.extract_media(block, pid, author)
                total_media_found += len(media_items)

                for mtype, media_url, p_id, auth in media_items:
                    if self.stop_flag.is_set():
                        break

                    # Определяем папку назначения
                    if self.allowed_authors:
                        # С фильтром авторов — папки по авторам
                        cache_key = (auth, mtype)
                        if cache_key not in author_dirs_cache:
                            dest_dir = self._get_author_media_dir(
                                self.topic_base, auth, mtype)
                            dest_dir.mkdir(parents=True, exist_ok=True)
                            author_dirs_cache[cache_key] = dest_dir
                        dest_dir = author_dirs_cache[cache_key]
                    else:
                        # Без фильтра — общие папки
                        if mtype == 'image' and img_sub:
                            dest_dir = self.img_dir
                        elif mtype == 'video' and vid_sub:
                            dest_dir = self.vid_dir
                        else:
                            continue

                    # Bug 7 fix: new media file naming format:
                    # {topic_name}; {author}; post {pid}; {orig}
                    orig = (os.path.basename(urlparse(media_url).path)
                            .split('?')[0].split('#')[0])
                    if not orig or '.' not in orig:
                        ext = '.jpg' if mtype == 'image' else '.mp4'
                        orig = f"{uuid.uuid4().hex[:8]}{ext}"

                    topic_name_part = sanitize_filename(self.topic_title or 'topic', 40)
                    author_part = sanitize_filename(auth, 30)
                    fname = f"{topic_name_part}; {author_part}; post {p_id}; {orig}"
                    fname = sanitize_filename(fname, 150)

                    dest = dest_dir / fname

                    if dest.exists():
                        continue
                    if download_file(media_url, dest, self.session, on_log=self.on_log):
                        self.downloaded += 1
                        self.metadata.append({
                            'post_id': p_id,
                            'author': auth,
                            'type': mtype,
                            'original_url': media_url,
                            'local_path': str(dest)
                        })

            effective_end = self._get_effective_end_page()
            end_str = str(effective_end) if effective_end else '?'
            prog = (f"Страница {page_num}/{end_str} | "
                    f"найдено: {total_media_found}, скачано: {self.downloaded}")
            self.on_log(prog, "progress")
            self.on_progress(page_num, effective_end)

        # Сохраняем метаданные
        if topic_dir_initialized and self.metadata:
            meta_path = self.topic_base / 'media_metadata.json'
            # Bug 6 fix: when allowed_authors is set, delete metadata after saving
            if self.allowed_authors:
                # Still save metadata temporarily for internal use, then delete
                with open(meta_path, 'w', encoding='utf-8') as f:
                    json.dump(self.metadata, f, ensure_ascii=False, indent=2)
                # Delete immediately — user doesn't need it when filtering
                try:
                    meta_path.unlink()
                    logger.debug("Удалён media_metadata.json (режим фильтра авторов)")
                except Exception:
                    pass
            else:
                with open(meta_path, 'w', encoding='utf-8') as f:
                    json.dump(self.metadata, f, ensure_ascii=False, indent=2)

        # Bug 1 fix: clean up empty images/video folders at topic root when filtering
        if self.allowed_authors and topic_dir_initialized:
            _try_remove_empty_dir(self.topic_base / 'images')
            _try_remove_empty_dir(self.topic_base / 'video')
            # Also clean up any empty media_metadata.json if it exists
            meta_path = self.topic_base / 'media_metadata.json'
            try:
                if meta_path.exists():
                    meta_path.unlink()
                    logger.debug("Удалён media_metadata.json (очистка)")
            except Exception:
                pass

        return self.downloaded


# ----------------------------------------------------------------------
# Графический интерфейс (Tkinter)
# ----------------------------------------------------------------------
MODE_OPTIONS = [
    "Текст",
    "Картинки",
    "Видео",
    "Картинки и видео",
    "Всё вместе",
]


class ParserApp:
    """Основное окно приложения."""

    def __init__(self, root):
        self.root = root
        self.root.title("PRODOTA PARSER")
        self.root.geometry("360x720")
        self.root.resizable(False, False)
        self.root.protocol("WM_DELETE_WINDOW", self.confirm_exit)

        # Переменные для хранения состояния GUI
        self.mode_var = tk.StringVar(value=MODE_OPTIONS[0])
        self.start_page_var = tk.StringVar(value="0")
        self.end_page_var = tk.StringVar(value="0")

        self.is_running = False          # выполняется ли парсинг
        self.pause_flag = None           # Event для паузы
        self.stop_flag = None            # Event для остановки
        self.parser_thread = None        # поток парсера
        self._build_ui()

    def _build_ui(self):
        """Строит все элементы интерфейса с прокруткой."""
        # Создаём холст для прокрутки
        self._canvas = tk.Canvas(self.root, highlightthickness=0)
        scrollbar = ttk.Scrollbar(self.root, orient="vertical", command=self._canvas.yview)
        self.settings_frame = ttk.Frame(self._canvas)

        self._canvas_window = self._canvas.create_window(
            (0, 0), window=self.settings_frame, anchor="nw"
        )
        self._canvas.configure(yscrollcommand=scrollbar.set)

        def _on_canvas_configure(event):
            self._canvas.itemconfig(self._canvas_window, width=event.width)

        self._canvas.bind("<Configure>", _on_canvas_configure)

        self.settings_frame.bind(
            "<Configure>",
            lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all"))
        )

        scrollbar.pack(side="right", fill="y")
        self._canvas.pack(side="left", fill="both", expand=True)

        # Прокрутка колёсиком мыши
        def _on_mousewheel(event):
            self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

        def _on_touch_scroll(event):
            if event.num == 4:
                self._canvas.yview_scroll(-1, "units")
            elif event.num == 5:
                self._canvas.yview_scroll(1, "units")

        self._canvas.bind_all("<MouseWheel>", _on_mousewheel)
        self._canvas.bind_all("<Button-4>", _on_touch_scroll)
        self._canvas.bind_all("<Button-5>", _on_touch_scroll)

        sf = self.settings_frame
        PAD = 6

        # Заголовок
        ttk.Label(sf, text="PRODOTA PARSER",
                  font=('Arial', 11, 'bold'), anchor='center'
                  ).pack(fill='x', pady=(6, 2), padx=PAD)

        # 1. Ссылка на тему
        ttk.Label(sf, text="Ссылка на тему:",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))
        self.url_text = tk.Text(sf, height=2, wrap=tk.WORD,
                                font=('Arial', 8), bd=1, relief='solid')
        self.url_text.pack(fill='x', padx=PAD, pady=1)
        self.url_text.insert('1.0', Config.START_URL)

        # 2-3. Начальная и конечная страницы
        pages_frame = ttk.Frame(sf)
        pages_frame.pack(fill='x', padx=PAD, pady=2)
        pages_frame.columnconfigure(1, weight=1)
        pages_frame.columnconfigure(4, weight=1)

        ttk.Label(pages_frame, text="Стр.:",
                  font=('Arial', 9)).grid(row=0, column=0, sticky='w')
        self.start_page_entry = ttk.Entry(pages_frame, textvariable=self.start_page_var,
                                           width=6, font=('Arial', 9))
        self.start_page_entry.grid(row=0, column=1, sticky='ew', padx=(2, 4))
        ttk.Label(pages_frame, text="по",
                  font=('Arial', 9)).grid(row=0, column=2, sticky='w')
        self.end_page_entry = ttk.Entry(pages_frame, textvariable=self.end_page_var,
                                         width=6, font=('Arial', 9))
        self.end_page_entry.grid(row=0, column=3, sticky='e', padx=(4, 2))
        ttk.Label(pages_frame, text="(0=all)",
                  font=('Arial', 7)).grid(row=0, column=4, sticky='w')

        # 4. Путь сохранения
        ttk.Label(sf, text="Путь сохранения:",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))

        self.path_var = tk.StringVar(value=Config.DEFAULT_OUTPUT)
        self.path_entry = ttk.Entry(sf, textvariable=self.path_var,
                                    font=('Arial', 8))
        self.path_entry.pack(fill='x', padx=PAD, pady=(0, 2))

        ttk.Button(sf, text="📁 Выбрать папку",
                   command=self.choose_dir).pack(fill='x', padx=PAD, pady=(0, 4))

        # 5. Фильтр авторов
        ttk.Label(sf, text="Фильтр авторов (';'):",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))
        self.authors_text = tk.Text(sf, height=1, wrap=tk.WORD,
                                     font=('Arial', 8), bd=1, relief='solid')
        self.authors_text.pack(fill='x', padx=PAD, pady=1)

        # 6. Режим парсинга
        ttk.Label(sf, text="Режим:",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))
        self.mode_combobox = ttk.Combobox(
            sf, textvariable=self.mode_var,
            values=MODE_OPTIONS, state='readonly',
            font=('Arial', 9), height=5
        )
        self.mode_combobox.pack(fill='x', padx=PAD, pady=1)

        # Кнопки управления
        btn_grid = ttk.Frame(sf)
        btn_grid.pack(fill='x', padx=PAD, pady=(6, 2))
        btn_grid.columnconfigure(0, weight=1)
        btn_grid.columnconfigure(1, weight=1)

        self.start_btn = ttk.Button(btn_grid, text="СТАРТ",
                                    command=self.start_parsing)
        self.start_btn.grid(row=0, column=0, columnspan=2, sticky='ew', pady=1)

        self.pause_btn = ttk.Button(btn_grid, text="ПАУЗА",
                                    command=self.pause_parsing, state='disabled')
        self.pause_btn.grid(row=1, column=0, sticky='ew', padx=(0, 2), pady=1)

        self.stop_btn = ttk.Button(btn_grid, text="СТОП",
                                   command=self.stop_parsing, state='disabled')
        self.stop_btn.grid(row=1, column=1, sticky='ew', padx=(2, 0), pady=1)

        ttk.Button(btn_grid, text="Очистить",
                   command=self.clear_log).grid(row=2, column=0, sticky='ew', padx=(0, 2), pady=1)
        ttk.Button(btn_grid, text="Выход",
                   command=self.confirm_exit).grid(row=2, column=1, sticky='ew', padx=(2, 0), pady=1)

        # Прогресс
        prog_frame = ttk.LabelFrame(sf, text="Прогресс")
        prog_frame.pack(fill='x', padx=PAD, pady=(4, 2))
        self.progress = ttk.Progressbar(prog_frame, orient='horizontal', mode='determinate')
        self.progress.pack(fill='x', padx=3, pady=(3, 1))
        self.status_label = ttk.Label(prog_frame, text="Готов", font=('Arial', 9))
        self.status_label.pack(anchor='w', padx=3)
        self.progress_line = ttk.Label(prog_frame, text="", font=('Arial', 8),
                                       foreground='#009999')
        self.progress_line.pack(anchor='w', padx=3, pady=(0, 3))

        # Лог
        log_frame = ttk.LabelFrame(sf, text="Лог")
        log_frame.pack(fill='both', expand=True, padx=PAD, pady=(2, 6))
        self.log_text = scrolledtext.ScrolledText(
            log_frame, wrap=tk.WORD, font=('Arial', 8),
            height=10
        )
        self.log_text.pack(fill='both', expand=True)
        # Теги для цветного вывода
        self.log_text.tag_config('info', foreground='#0066CC')
        self.log_text.tag_config('success', foreground='#008800')
        self.log_text.tag_config('error', foreground='#CC0000')
        self.log_text.tag_config('warning', foreground='#CC6600')
        self.log_text.tag_config('debug', foreground='#666666')
        self.log_text.tag_config('progress', foreground='#6600CC')

    def choose_dir(self):
        """Открывает диалог выбора папки."""
        initial = self.path_var.get().strip()
        if not initial or not os.path.isdir(initial):
            initial = Config.DEFAULT_OUTPUT
        d = filedialog.askdirectory(
            title="Выберите папку для сохранения",
            initialdir=initial
        )
        if d:
            self.path_var.set(d)

    def _get_url(self):
        return self.url_text.get('1.0', tk.END).strip()

    def _get_path(self):
        return self.path_var.get().strip()

    def _get_authors(self):
        return self.authors_text.get('1.0', tk.END).strip()

    def _get_selected_mode_index(self):
        val = self.mode_var.get()
        for i, opt in enumerate(MODE_OPTIONS):
            if opt == val:
                return i
        return 0

    def clear_log(self):
        """Очищает текстовое поле лога."""
        self.log_text.delete(1.0, tk.END)

    def log(self, msg, level='info'):
        """Добавляет сообщение в лог с временной меткой и цветом."""
        tag = level.lower()
        if tag not in ('info', 'success', 'error', 'warning', 'debug', 'progress'):
            tag = 'info'
        ts = time.strftime('%H:%M:%S')
        icons = {'info': '[i]', 'success': '[+]', 'error': '[!]', 'warning': '[~]', 'debug': '[.]', 'progress': '[>]'}
        icon = icons.get(tag, '[i]')
        formatted = f"{ts} {icon} {msg}\n"
        self.root.after(0, lambda: self._safe_log_insert(formatted, tag))

    def _safe_log_insert(self, msg, tag):
        """Безопасная вставка в лог (вызывается из главного потока)."""
        try:
            self.log_text.insert(tk.END, msg, tag)
            self.log_text.see(tk.END)
        except tk.TclError:
            pass

    def update_progress_line(self, text):
        """Обновляет текстовую строку прогресса."""
        self.root.after(0, lambda: self._safe_progress_line(text))

    def _safe_progress_line(self, text):
        try:
            self.progress_line.config(text=text)
        except tk.TclError:
            pass

    def update_progress_bar(self, current, total):
        """Обновляет индикатор прогресса (текущая страница / всего)."""
        self.root.after(0, lambda: self._safe_progress_bar(current, total))

    def _safe_progress_bar(self, current, total):
        try:
            if total and total > 0:
                self.progress['value'] = (current / total) * 100
                self.status_label.config(text=f"Страница {current}/{total}")
            else:
                self.progress['value'] = 0
                self.status_label.config(text=f"Страница {current}")
        except tk.TclError:
            pass

    def start_parsing(self):
        """Обработчик кнопки СТАРТ. Запускает парсинг в отдельном потоке."""
        if self.is_running:
            self.log("Парсинг уже запущен", "warning")
            return

        url = self._get_url()
        if not url.startswith('http'):
            self.log("Неверная ссылка", "error")
            return

        path = self._get_path()
        if not path:
            self.log("Укажите путь сохранения", "error")
            return

        base_path = Path(path)
        if not base_path.exists():
            try:
                base_path.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                self.log(f"Не удалось создать папку: {e}", "error")
                return

        try:
            start_page = int(self.start_page_var.get().strip() or "0")
        except ValueError:
            start_page = 0
        if start_page < 0:
            start_page = 0

        try:
            end_page = int(self.end_page_var.get().strip() or "0")
        except ValueError:
            end_page = 0
        if end_page < 0:
            end_page = 0

        authors_str = self._get_authors()
        allowed = None
        if authors_str:
            allowed = {n.strip() for n in authors_str.split(';') if n.strip()}
            self.log(f"Фильтр авторов: {', '.join(sorted(allowed))}", "info")

        self.stop_flag = threading.Event()
        self.pause_flag = threading.Event()
        self.is_running = True
        self.start_btn.config(state='disabled')
        self.pause_btn.config(state='normal')
        self.stop_btn.config(state='normal')
        self.progress['value'] = 0
        self.status_label.config(text="Запуск...")
        self.progress_line.config(text="")
        self.log("Парсинг запущен", "info")

        if start_page <= 1:
            self.log("Начальная страница: автоматически первая", "info")
        else:
            self.log(f"Начальная страница: {start_page}", "info")
        if end_page > 0:
            self.log(f"Конечная страница: {end_page}", "info")
        else:
            self.log("Конечная страница: до последней", "info")

        mode_idx = self._get_selected_mode_index()

        if mode_idx == 0:
            self._run_text_parser(url, str(base_path), start_page, end_page, allowed)
        elif mode_idx == 1:
            self._run_media_parser(url, str(base_path), start_page, end_page, 'images', allowed)
        elif mode_idx == 2:
            self._run_media_parser(url, str(base_path), start_page, end_page, 'videos', allowed)
        elif mode_idx == 3:
            self._run_media_parser(url, str(base_path), start_page, end_page, 'both', allowed)
        elif mode_idx == 4:
            self._run_combined_parser(url, str(base_path), start_page, end_page, allowed)

    def _run_text_parser(self, url, base_path, start_page, end_page, allowed):
        """Запускает текстовый парсер в отдельном потоке."""
        def target():
            try:
                parser = TextParser(url, self.log, self.update_progress_bar,
                                    self.stop_flag, self.pause_flag, allowed,
                                    start_page=start_page, end_page=end_page)
                # Перехватываем сообщения прогресса, чтобы выводить их в отдельную строку
                orig_log = parser.on_log

                def progress_log(msg, level):
                    if level == 'progress':
                        self.update_progress_line(msg)
                    else:
                        orig_log(msg, level)

                parser.on_log = progress_log
                posts = parser.run(base_path)
                if posts and not self.stop_flag.is_set():
                    saved = parser.save_all()
                    self.log(f"Сохранено {len(posts)} постов в {len(saved)} файл(ов)", "success")
                    for s in saved:
                        self.log(f"  -> {s}", "info")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                else:
                    self.log("Посты не найдены", "warning")
            except Exception as e:
                self.log(f"Ошибка: {type(e).__name__}: {e}", "error")
                logger.error("Текст-парсер: %s\n%s", e, traceback.format_exc())
            finally:
                self.root.after(0, self._finish)

        self.parser_thread = threading.Thread(target=target, daemon=True)
        self.parser_thread.start()

    def _run_media_parser(self, url, base_path, start_page, end_page, media_type, allowed):
        """Запускает медиапарсер в отдельном потоке."""
        def target():
            try:
                parser = MediaParser(url, self.log, self.update_progress_bar,
                                     self.stop_flag, self.pause_flag, allowed,
                                     start_page=start_page, end_page=end_page,
                                     media_type=media_type)
                orig_log = parser.on_log

                def progress_log(msg, level):
                    if level == 'progress':
                        self.update_progress_line(msg)
                    else:
                        orig_log(msg, level)

                parser.on_log = progress_log
                count = parser.run(base_path)
                if count:
                    self.log(f"Скачано {count} файлов (отфильтровано: {parser.filtered_out})", "success")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                else:
                    self.log(f"Медиа не найдено (отфильтровано: {parser.filtered_out})", "warning")
            except Exception as e:
                self.log(f"Ошибка: {type(e).__name__}: {e}", "error")
                logger.error("Медиа-парсер: %s\n%s", e, traceback.format_exc())
            finally:
                self.root.after(0, self._finish)

        self.parser_thread = threading.Thread(target=target, daemon=True)
        self.parser_thread.start()

    def _run_combined_parser(self, url, base_path, start_page, end_page, allowed):
        """Запускает комбинированный парсинг (текст + медиа) в отдельном потоке."""
        def target():
            try:
                self.log("=== ЭТАП 1: Извлечение текста ===", "info")
                text_parser = TextParser(url, self.log, self.update_progress_bar,
                                         self.stop_flag, self.pause_flag, allowed,
                                         start_page=start_page, end_page=end_page)
                orig_log_text = text_parser.on_log

                def progress_log_text(msg, level):
                    if level == 'progress':
                        self.update_progress_line(f"[Текст] {msg}")
                    else:
                        orig_log_text(msg, level)

                text_parser.on_log = progress_log_text
                posts = text_parser.run(base_path)
                if posts and not self.stop_flag.is_set():
                    saved = text_parser.save_all()
                    self.log(f"[Текст] Сохранено {len(posts)} постов в {len(saved)} файл(ов)", "success")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                    return
                else:
                    self.log("[Текст] Посты не найдены", "warning")

                if self.stop_flag.is_set():
                    return

                self.update_progress_bar(0, 0)
                self.update_progress_line("")

                self.log("=== ЭТАП 2: Извлечение медиа ===", "info")
                media_parser = MediaParser(url, self.log, self.update_progress_bar,
                                           self.stop_flag, self.pause_flag, allowed,
                                           start_page=start_page, end_page=end_page,
                                           media_type='both')
                orig_log_media = media_parser.on_log

                def progress_log_media(msg, level):
                    if level == 'progress':
                        self.update_progress_line(f"[Медиа] {msg}")
                    else:
                        orig_log_media(msg, level)

                media_parser.on_log = progress_log_media
                count = media_parser.run(base_path)
                if count:
                    self.log(f"[Медиа] Скачано {count} файлов", "success")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                else:
                    self.log("[Медиа] Медиа не найдено", "warning")

                self.log("=== КОМБИНИРОВАННЫЙ ПАРСИНГ ЗАВЕРШЁН ===", "success")
                self.log(f"Итого: {len(posts)} постов + {count} медиафайлов", "success")

            except Exception as e:
                self.log(f"Ошибка: {type(e).__name__}: {e}", "error")
                logger.error("Комбинированный парсер: %s\n%s", e, traceback.format_exc())
            finally:
                self.root.after(0, self._finish)

        self.parser_thread = threading.Thread(target=target, daemon=True)
        self.parser_thread.start()

    def _finish(self):
        """Завершает парсинг, восстанавливает кнопки."""
        if not self.is_running:
            return
        self.is_running = False
        self.start_btn.config(state='normal')
        self.pause_btn.config(state='disabled', text="ПАУЗА")
        self.stop_btn.config(state='disabled')
        self.progress['value'] = 0
        self.status_label.config(text="Готов")
        self.progress_line.config(text="")
        self.log("Парсинг завершён", "info")

    def pause_parsing(self):
        """Ставит парсинг на паузу."""
        if self.is_running and self.pause_flag:
            self.pause_flag.set()
            self.pause_btn.config(text="ВОЗОБНОВИТЬ", command=self.resume_parsing)
            self.log("Пауза", "warning")
            self.update_progress_line("|| Пауза")

    def resume_parsing(self):
        """Возобновляет парсинг после паузы."""
        if self.is_running and self.pause_flag:
            self.pause_flag.clear()
            self.pause_btn.config(text="ПАУЗА", command=self.pause_parsing)
            self.log("Возобновление", "info")
            self.update_progress_line("> Возобновление")

    def stop_parsing(self):
        """Останавливает парсинг."""
        if self.is_running and self.stop_flag:
            self.stop_flag.set()
            if self.pause_flag:
                self.pause_flag.clear()
            self.log("Остановка...", "warning")
            self.update_progress_line("X Остановка...")
            self.root.after(100, self._check_stop)

    def _check_stop(self):
        """Периодически проверяет, завершился ли поток парсера."""
        if self.parser_thread and self.parser_thread.is_alive():
            self.root.after(100, self._check_stop)
        else:
            self._finish()

    def confirm_exit(self):
        """Подтверждение выхода, если парсинг активен."""
        if self.is_running:
            if messagebox.askokcancel("Выход", "Парсинг ещё выполняется. Прервать и выйти?"):
                self.stop_parsing()
                self.root.after(1500, self.root.destroy)
        else:
            self.root.destroy()


# ----------------------------------------------------------------------
# Точка входа
# ----------------------------------------------------------------------
if __name__ == '__main__':
    def main():
        try:
            root = tk.Tk()
            app = ParserApp(root)
            root.mainloop()
        except Exception as e:
            crash_msg = traceback.format_exc()
            try:
                with open(_CRASH_LOG, 'a', encoding='utf-8') as f:
                    f.write(f"\n{'='*70}\n{time.strftime('%Y-%m-%d %H:%M:%S')}\n{crash_msg}")
            except Exception:
                pass
            try:
                root = tk.Tk()
                root.withdraw()
                messagebox.showerror("Критическая ошибка", f"Программа завершилась с ошибкой:\n\n{e}\n\nПодробности в файле:\n{_CRASH_LOG}")
                root.destroy()
            except Exception:
                print(f"CRITICAL ERROR: {e}\n{crash_msg}", file=sys.stderr)

    main()

 

 

PD21.py

PD19.py


Изменено пользователем yellyex
Веточка- и ural1qqq понравилось это

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

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

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


Ссылка на сообщение
(изменено)

Вот обновленный код. Последний. Больше править не буду. Со смартфона это жутко неудобно, плюс приходится ждать кулдауна работы агента. Есть что улучшить:

1. Парсинг картинок - он обращается по URL, а там не всегда картинка или видео скачивается. Надо, чтобы он как-то открывал картинку (правой кнопкой мыши) "открыть картинку в новой вкладке" и скачивал её оттуда.

2. Ошибки подключения и переподключения.

3. Различные мелкие правки структуры файлов на выходе.

 

На этом откланяюсь. И пожелаю удачи в доработке, тем кто осмелится. Попрошу разве что указать моё имя в титрах, если будет полноценное APK приложение.

Всем спасибо! Пользуйтесь на здоровье! Скачивайте картинки и посты юзеров из любого топика.

♥️♥️♥️

👍👍👍

🫡🫡🫡

 

Исходный код:

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

# -*- coding: utf-8 -*-

import os
import re
import sys
import json
import csv
import time
import uuid
import random
import threading
import traceback
import logging
import logging.handlers
import tkinter as tk
from tkinter import ttk, filedialog, messagebox, scrolledtext
from pathlib import Path
from collections import OrderedDict
from urllib.parse import urljoin, urlparse

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


# ----------------------------------------------------------------------
# Настройка логирования (обширная система)
# ----------------------------------------------------------------------

# Папка, в которой находится скрипт
_SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Отдельная папка для логов в корне скрипта
_LOG_DIR = os.path.join(_SCRIPT_DIR, 'parser_logs')
os.makedirs(_LOG_DIR, exist_ok=True)
# Файл для записи ошибок парсера
ERROR_LOG_FILE = os.path.join(_LOG_DIR, 'parser_errors.log')
# Файл для записи критических сбоев (неперехваченных исключений)
_CRASH_LOG = os.path.join(_LOG_DIR, 'parser_crash.log')
# Файл для записи всех действий (отдельный детальный лог)
_ACTIONS_LOG_FILE = os.path.join(_LOG_DIR, 'parser_actions.log')
# Файл для записи информации о скачивании медиа
_MEDIA_LOG_FILE = os.path.join(_LOG_DIR, 'parser_media.log')
# Файл для записи информации о страницах и постах
_PAGES_LOG_FILE = os.path.join(_LOG_DIR, 'parser_pages.log')

# Формат для всех логов (с номером строки и millisecond)
_DETAILED_FORMAT = logging.Formatter(
    '%(asctime)s.%(msecs)03d | %(levelname)-8s | %(funcName)s:%(lineno)d | %(message)s',
    datefmt='%Y-%m-%d %H:%M:%S'
)

# Создаём обработчик для ротации логов: максимальный размер 5 МБ, 5 бэкапов, кодировка utf-8
_file_handler = logging.handlers.RotatingFileHandler(
    ERROR_LOG_FILE, maxBytes=5_000_000, backupCount=5,
    encoding='utf-8', errors='replace'
)
_file_handler.setLevel(logging.DEBUG)
_file_handler.setFormatter(_DETAILED_FORMAT)

# Обработчик для лога действий (все действия парсера)
_actions_handler = logging.handlers.RotatingFileHandler(
    _ACTIONS_LOG_FILE, maxBytes=5_000_000, backupCount=5,
    encoding='utf-8', errors='replace'
)
_actions_handler.setLevel(logging.DEBUG)
_actions_handler.setFormatter(_DETAILED_FORMAT)

# Обработчик для лога медиа (скачивания изображений и видео)
_media_handler = logging.handlers.RotatingFileHandler(
    _MEDIA_LOG_FILE, maxBytes=5_000_000, backupCount=5,
    encoding='utf-8', errors='replace'
)
_media_handler.setLevel(logging.DEBUG)
_media_handler.setFormatter(_DETAILED_FORMAT)

# Обработчик для лога страниц (загрузка страниц, посты)
_pages_handler = logging.handlers.RotatingFileHandler(
    _PAGES_LOG_FILE, maxBytes=5_000_000, backupCount=5,
    encoding='utf-8', errors='replace'
)
_pages_handler.setLevel(logging.DEBUG)
_pages_handler.setFormatter(_DETAILED_FORMAT)

# Создаём логгер с именем 'prodota_parser'
logger = logging.getLogger('prodota_parser')
logger.setLevel(logging.DEBUG)
logger.addHandler(_file_handler)
logger.addHandler(_actions_handler)
logger.addHandler(_media_handler)
logger.addHandler(_pages_handler)
logger.propagate = False  # не передавать события выше

# Консольный обработчик для отладки
_console_handler = logging.StreamHandler(sys.stdout)
_console_handler.setLevel(logging.DEBUG)
_console_handler.setFormatter(logging.Formatter(
    '%(asctime)s | %(levelname)-8s | %(message)s', datefmt='%Y-%m-%d %H:%M:%S'
))
logger.addHandler(_console_handler)


def _crash_handler(exc_type, exc_value, exc_tb):
    """Обработчик неперехваченных исключений: записывает в отдельный файл и в лог."""
    crash_msg = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
    try:
        with open(_CRASH_LOG, 'a', encoding='utf-8') as f:
            f.write(f"\n{'='*70}\n{time.strftime('%Y-%m-%d %H:%M:%S')}\n{crash_msg}")
    except Exception:
        pass
    logger.critical("НЕПЕРЕХВАЧЕННОЕ ИСКЛЮЧЕНИЕ:\n%s", crash_msg)
    try:
        sys.__excepthook__(exc_type, exc_value, exc_tb)
    except Exception:
        pass


# Устанавливаем глобальный обработчик исключений
sys.excepthook = _crash_handler
logger.info("=" * 60)
logger.info("Парсер запущен")
logger.info("Папка логов: %s", _LOG_DIR)
logger.info("Ошибки: %s", ERROR_LOG_FILE)
logger.info("Действия: %s", _ACTIONS_LOG_FILE)
logger.info("Медиа: %s", _MEDIA_LOG_FILE)
logger.info("Страницы: %s", _PAGES_LOG_FILE)
logger.info("Сбой: %s", _CRASH_LOG)
logger.info("=" * 60)


# ----------------------------------------------------------------------
# Конфигурационные настройки
# ----------------------------------------------------------------------

class Config:
    """Статический класс, содержащий все настройки парсера."""
    # Стартовый URL темы форума (по умолчанию)
    START_URL = 'https://prodota.ru/forum/topic/150153/#comments'
    # Папка для сохранения по умолчанию (если существует /storage/emulated/0/Download – для Android, иначе ~/Downloads)
    DEFAULT_OUTPUT = (
        '/storage/emulated/0/Download'
        if os.path.exists('/storage/emulated/0/Download')
        else os.path.expanduser('~/Downloads')
    )
    # Задержки между запросами страниц (секунды) — Bug 3 fix: reduced
    MIN_DELAY, MAX_DELAY = 1.0, 2.5
    # Таймаут HTTP-запроса
    REQUEST_TIMEOUT = 30
    # Максимальное количество повторных попыток при ошибках соединения/HTTP
    MAX_RETRIES = 2
    # Базовая задержка между попытками загрузки страницы
    PAGE_RETRY_BASE_DELAY = 3
    # Количество попыток загрузить страницу при ошибках
    PAGE_RETRY_COUNT = 3
    # Максимальное количество последовательных ошибок, после чего увеличивается пауза
    MAX_CONSECUTIVE_FAILURES = 5
    # Максимальная пауза при серии ошибок (секунды)
    SEVERE_DELAY_CAP = 30
    # Настройки повторных попыток для скачивания файлов
    DOWNLOAD_MAX_RETRIES = 3
    DOWNLOAD_RETRY_DELAY = 2
    # Список User-Agent для ротации
    USER_AGENTS = [
        'Mozilla/5.0 (Linux; Android 10; SM-G973F) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.5735.196 Mobile Safari/537.36',
        'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.6099.230 Mobile Safari/537.36',
        'Mozilla/5.0 (Linux; Android 12; Samsung Galaxy S21) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/121.0.6167.178 Mobile Safari/537.36',
        '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; rv:109.0) Gecko/20100101 Firefox/119.0',
        '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',
    ]
    # Селекторы 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', 'a[rel="last"]'],
        'topic_title': ['h1.ipsType_pageTitle'],
    }
    # Критические URL‑фильтры: если подходит – сразу отбрасываем
    URL_FILTER_CRITICAL = [
        r'/uploads/emoticons/',
        r'/emoticons?/',
        r'/spacer\.(gif|png)',
        r'1x1\.(gif|png|jpg)',
        r'transparent\.(gif|png)',
        r'clear\.gif',
        r'/pixel\.',
        r'/blank\.',
        r'^data:',
    ]
    # Фильтры для URL, которые нужно исключить (например, аватары, иконки)
    URL_FILTER_OUT = [
        r'/uploads/profile/',
        r'/uploads/monthly_\d+/\d{4}/',
        r'_avatar\.',
        r'avatar_\d+',
        r'photo-\d+-\d+\.(jpg|jpeg|png|gif)',
        r'/emoji/',
        r'/smil(?:ey|ie)?/',
        r'/react/',
        r'/reactions/',
        r'reaction-',
        r'/icon/',
        r'/icon\.',
        r'/badge',
        r'/rank',
        r'/ribbon',
        r'/group',
        r'/sprite',
        r'/theme/',
        r'/core/',
        r'/applications/',
        r'/uploads/set_resources_',
        r'/uploads/monthly_.*?(?:icon|logo|banner|bg|background)',
        r'/loading',
        r'/ajax',
        r'/sharedmedia/',
        r'/javascript/',
        r'/css/',
        r'/global/',
    ]
    # Белый список URL, которые разрешены, даже если попали под FILTER_OUT
    URL_ALWAYS_ALLOW = [
        r'/uploads/monthly_.*?\.(jpg|jpeg|png|gif|webp|bmp)',
        r'/uploads/.*?\.(jpg|jpeg|png|gif|webp|bmp|mp4|webm)',
        r'\.attachments/',
        r'/attached/',
    ]
    # Минимальный размер изображения в байтах, чтобы не скачивать пустышки
    MIN_IMAGE_SIZE = 1024


def _compile_filter_patterns(patterns):
    """Компилирует список строк в список регулярных выражений (регистронезависимых)."""
    return [re.compile(p, re.I) for p in patterns]


# Предварительная компиляция фильтров URL для ускорения работы
_FILTER_CRITICAL_COMPILED = _compile_filter_patterns(Config.URL_FILTER_CRITICAL)
_FILTER_OUT_COMPILED = _compile_filter_patterns(Config.URL_FILTER_OUT)
_ALWAYS_ALLOW_COMPILED = _compile_filter_patterns(Config.URL_ALWAYS_ALLOW)


# ----------------------------------------------------------------------
# Вспомогательные функции
# ----------------------------------------------------------------------

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


def _try_remove_empty_dir(dirpath):
    """Пытается удалить пустую директорию, логирует результат."""
    try:
        if dirpath.exists() and dirpath.is_dir():
            # Check if empty (no files, only possibly empty subdirs)
            try:
                next(dirpath.iterdir())
                return  # Not empty
            except StopIteration:
                dirpath.rmdir()
                logger.debug("Удалена пустая папка: %s", dirpath)
    except Exception as e:
        logger.debug("Не удалось удалить папку %s: %s", dirpath, e)


# Регулярное выражение для удаления эмодзи и прочих не‑BMP символов
_EMOJI_PATTERN = re.compile(
    "["
    "\U0001F600-\U0001F64F"
    "\U0001F300-\U0001F5FF"
    "\U0001F680-\U0001F6FF"
    "\U0001F700-\U0001F77F"
    "\U0001F780-\U0001F7FF"
    "\U0001F800-\U0001F8FF"
    "\U0001F900-\U0001F9FF"
    "\U0001FA00-\U0001FA6F"
    "\U0001FA70-\U0001FAFF"
    "\U00002702-\U000027B0"
    "\u24C2-\u24FF"
    "\U0001f926-\U0001f937"
    "\u2640-\u2642"
    "\u2600-\u2B55"
    "\u200d"
    "\u23cf"
    "\u23e9"
    "\u231a"
    "\ufe0f"
    "\u3030"
    "\u00a9"
    "\u00ae"
    "\u2122"
    "\u2b50"
    "\u2b55"
    "\u231b"
    "\u2318"
    "\u23ea"
    "\u23eb"
    "\u23ec"
    "\u23ed"
    "\u23ee"
    "\u23ef"
    "\u23f0"
    "\u23f1"
    "\u23f2"
    "\u23f3"
    "\u25fb"
    "\u25fc"
    "\u25fd"
    "\u25fe"
    "\u2614"
    "\u2615"
    "\u2648-\u2653"
    "\u267f"
    "\u2693"
    "\u26a1"
    "\u26aa"
    "\u26ab"
    "\u26bd"
    "\u26be"
    "\u26c4"
    "\u26c5"
    "\u26ce"
    "\u26d4"
    "\u26ea"
    "\u26f2"
    "\u26f3"
    "\u26f5"
    "\u26fa"
    "\u26fd"
    "\u2702"
    "\u2705"
    "\u2708-\u270d"
    "\u270f"
    "\u2712"
    "\u2714"
    "\u2716"
    "\u271d"
    "\u2721"
    "\u2728"
    "\u2733"
    "\u2734"
    "\u2744"
    "\u2747"
    "\u274c"
    "\u274e"
    "\u2753-\u2755"
    "\u2757"
    "\u2763"
    "\u2764"
    "\u2795-\u2797"
    "\u27a1"
    "\u27b0"
    "\u27bf"
    "\u2934"
    "\u2935"
    "\u2b05-\u2b07"
    "\u2b1b"
    "\u2b1c"
    "\u303d"
    "\u3297"
    "\u3299"
    "\U0001f004"
    "\U0001f0cf"
    "\U0001f170-\U0001f171"
    "\U0001f17e-\U0001f17f"
    "\U0001f18e"
    "\U0001f191-\U0001f19a"
    "\U0001f1ad-\U0001f1e5"
    "\U0001f201-\U0001f20f"
    "\U0001f21a"
    "\U0001f22f"
    "\U0001f232-\U0001f23a"
    "\U0001f23c-\U0001f23f"
    "\U0001f249-\U0001f3fa"
    "\U0001f400-\U0001f53d"
    "\U0001f546-\U0001f64f"
    "]+",
    flags=re.UNICODE
)


def clean_post_content(raw):
    """Очищает текст поста от HTML‑сущностей, эмодзи, служебных строк (дата, цитаты, подписи)."""
    if not raw:
        return ''
    text = raw.strip()

    # Заменяем неразрывные пробелы (\u00a0) на обычные — BeautifulSoup
    # конвертирует &nbsp; в \u00a0, и на Android они отображаются как точки ·
    text = text.replace('\u00a0', ' ')
    # Также заменяем другие Unicode-пробелы
    # Bug 2 fix: do NOT remove \u200d (Zero Width Joiner) — needed for Unicode rendering
    for ws_char in ['\u200b', '\u200c', '\ufeff', '\u202f', '\u205f', '\u3000']:
        text = text.replace(ws_char, ' ')

    # Декодирование HTML-сущностей
    text = text.replace('&nbsp;', ' ')
    text = text.replace('&middot;', '·')
    text = text.replace('&lt;', '<')
    text = text.replace('&gt;', '>')
    text = text.replace('&quot;', '"')
    text = text.replace('&#39;', "'")
    text = text.replace('&#039;', "'")
    text = text.replace('&apos;', "'")
    text = text.replace('&mdash;', '—')
    text = text.replace('&ndash;', '–')
    text = text.replace('&laquo;', '«')
    text = text.replace('&raquo;', '»')
    text = text.replace('&hellip;', '...')
    text = text.replace('&bull;', '•')
    # Числовые сущности вида &#NNN; (валидный Unicode: 0–0x10FFFF)
    def _decode_numeric_entity(m):
        try:
            codepoint = int(m.group(1))
            if 0 <= codepoint <= 0x10FFFF:
                return chr(codepoint)
        except (ValueError, OverflowError):
            pass
        return ''
    text = re.sub(r'&#(\d+);', _decode_numeric_entity, text)
    text = text.replace('&amp;', '&')  # после числовых, чтобы не сломать их

    # Удаляем эмодзи
    text = _EMOJI_PATTERN.sub('', text)

    # Удаляем строки с "said On MM/DD/YYYY at HH:MM AM/PM:"
    text = re.sub(
        r'(>)?\s*\S+\s+said\s+On\s+\d{2}/\d{2}/\d{4}\s+at\s+\d{2}:\d{2}\s*(?:AM|PM)?\s*:?\s*\n?',
        '', text, flags=re.I
    )

    # Удаляем различные отметки времени и фразы "Опубликовано:"
    for pat in [r'Опубликовано:\s*\S+',
                r'(?:вчера|сегодня|\d+\s*(?:часов?|минут?|секунд?|дней?|месяцев?|лет?)\s*назад)',
                r'\d{2}\.\d{2}\.\d{4}\s+в\s+\d{2}:\d{2}',
                r'Posted\s+\w+\s+\d{1,2}',
                r'Posted\s+\d{1,2}\s+\w+',
                r'Posted\s+\w+',
                r'(?:написал|писал|написала|сказал|сказала)\s+.*?(?:назад|вчера|сегодня)',
                r'(?:\d+\s*(?:час|часа|часов|минут[ауы]?|секунд[ауы]?|день|дня|дней|неделю|недели|месяц|месяца|месяцев|год|года|лет)\s*)+назад']:
        text = re.sub(pat, '', text, flags=re.I)

    # Удаляем "#123" в начале каждой строки
    text = re.sub(r'^#\d+\s*', '', text, flags=re.M).strip()

    # Удаляем паттерны "X, Y and Z like this"
    text = re.sub(
        r'^[\w\s,]+(?:and\s+\d+\s+others?\s+)?like\s+this\s*$',
        '', text, flags=re.I | re.M
    )
    text = re.sub(
        r'^[\w\s]+and\s+[\w\s]+like\s+this\s*$',
        '', text, flags=re.I | re.M
    )

    # Разбиваем на строки и фильтруем служебные строки
    lines = text.split('\n')
    cleaned = []
    for line in lines:
        stripped = line.strip()

        # Don't touch quote formatting lines (─, │, Цитата)
        if stripped.startswith(("\u2500", "\u2502", "\u0426\u0438\u0442\u0430\u0442\u0430")):
            cleaned.append(stripped)
            continue

        # Сохраняем цитаты (начинаются с '>')
        if stripped.startswith('>'):
            cleaned.append(stripped)
            continue

        # Пропускаем строки "понравилось это" вместе с предыдущей строкой, если она была просто именем
        if 'понравилось это' in stripped.lower():
            if cleaned and re.match(r'^[\s\w,]+$', cleaned[-1]):
                cleaned.pop()
            continue

        # Пропускаем строки, состоящие только из имён (короткие, запятые)
        if re.match(r'^[\w\s]+(,\s*[\w\s]+)+$', stripped, re.UNICODE) and len(stripped) < 100:
            continue

        # Список паттернов для полного игнорирования строки
        skip_patterns = [
            r'^Показать содержимое$',
            r'^Ссылка на сообщение$',
            r'^Поделиться сообщением$',
            r'^Поделиться$',
            r'^Редактировать$',
            r'^Цитата$',
            r'^Quote$',
            r'^said\s+On\s+',
            r'^\d+\s+members?\s+like\s+this$',
            r'^\d+\s+чел\.\s+понравилось$',
            r'^Нравится$',
            r'^Ответить$',
            r'\s+reacted to this$',
            r'^Жалоба$',
            r'^Пожаловаться$',
            r'^Отредактировано\s*$',
            r'^Изменено\s*$',
            r'^Модератор\s*$',
            r'^Предупреждение',
            r'^Награда',
            r'^Изменено пользователем',
            r'^Отредактировано пользователем',
            r'^Получить ссылку$',
            r'^Скопировать ссылку$',
            r'^Вставить в цитату$',
            r'^_multiquote',
            r'^ipsQuote',
            r'^Posted\s+\w+',
            r'^Опубликовано',
            r'^\d+\s+others?\s+like\s+this$',
            r'^\d+\s+людям\s+понравилось$',
        ]
        skip = False
        for sp in skip_patterns:
            if re.match(sp, stripped, re.I):
                skip = True
                break
        if skip:
            continue

        # Пропускаем строки, похожие на атрибуты HTML
        if re.match(r'^(data-[a-z]+|class=|id=|style=|href=|src=)', stripped, re.I):
            continue

        # Пропускаем строки, состоящие только из знаков пунктуации
        if re.match(r'^[\s\.\,\;\:\!\?\-\_\=\+\*\#\@\$\%\^\&\(\)\[\]\{\}\/\\]+$', stripped):
            continue

        cleaned.append(stripped)

    text = '\n'.join(cleaned)

    # Обрезаем текст до появления маркеров окончания (кнопки форума)
    # Только если маркер является ЕДИНСТВЕННЫМ текстом на строке (Bug 5 fix)
    end_markers = [
        'ссылка на сообщение', 'поделиться сообщением',
        'редактировать', 'поделиться', 'жалоба', 'пожаловаться',
    ]
    for marker in end_markers:
        for i, line in enumerate(text.split('\n')):
            if line.strip().lower() == marker:
                text = '\n'.join(text.split('\n')[:i]).strip()
                return _final_cleanup(text)

    # Финальная чистка: лишние пробелы и переносы
    return _final_cleanup(text)


def _final_cleanup(text):
    """Финальная чистка текста: лишние пробелы и переносы."""
    text = re.sub(r'  +', ' ', text)
    text = re.sub(r'\n{3,}', '\n\n', text).strip()
    return text


def _validate_url(url):
    """Проверяет URL на валидность перед запросом."""
    if not url or not isinstance(url, str):
        return False
    url = url.strip()
    if len(url) < 10:
        logger.warning("URL слишком короткий (%d символов): %s", len(url), url)
        return False
    if not url.startswith(('http://', 'https://')):
        logger.warning("URL не начинается с http/https: %s", url[:80])
        return False
    return True


def download_file(url, path, session, timeout=30, on_log=None):
    """Скачивает файл по URL, проверяет минимальный размер для изображений, удаляет недокачанные файлы.
    Включает механизм повторных попыток."""
    max_retries = getattr(Config, 'DOWNLOAD_MAX_RETRIES', 3)
    retry_delay = getattr(Config, 'DOWNLOAD_RETRY_DELAY', 2)
    last_error = None
    
    # FIX: Валидация URL перед скачиванием
    if not _validate_url(url):
        logger.error("Скачивание отменено — невалидный URL: %s", url)
        return False
    
    logger.info("[MEDIA] Попытка скачивания: %s -> %s", url, path)
    for attempt in range(max_retries + 1):
        try:
            # FIX: Set Referer — для prodota.ru ставим prodota.ru, для внешних — тоже
            # многие хостинги картинок проверяют Referer
            old_referer = session.headers.get('Referer', '')
            parsed_url = urlparse(url)
            if 'prodota.ru' in (parsed_url.netloc or ''):
                session.headers['Referer'] = 'https://prodota.ru/'
            elif parsed_url.netloc:
                # FIX: Для внешних ресурсов ставим Referer с prodota.ru
                # (т.к. картинки встроены в страницу prodota.ru)
                session.headers['Referer'] = 'https://prodota.ru/'
            r = None
            try:
                r = session.get(url, stream=True, timeout=timeout)
            finally:
                # Restore original Referer
                if old_referer:
                    session.headers['Referer'] = old_referer
                else:
                    session.headers.pop('Referer', None)
            r.raise_for_status()
            # Если сервер вернул HTML вместо файла – отказываемся
            content_type = r.headers.get('Content-Type', '')
            if 'text/html' in content_type:
                logger.debug("[MEDIA] Сервер вернул HTML вместо файла: %s (Content-Type: %s)", url, content_type)
                r.close()
                return False
            path.parent.mkdir(parents=True, exist_ok=True)
            content_length = int(r.headers.get('Content-Length', 0))
            ext = path.suffix.lower()
            # Для изображений проверяем размер
            if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'):
                if content_length > 0 and content_length < Config.MIN_IMAGE_SIZE:
                    logger.debug("[MEDIA] Пропущено (мало байт %d): %s", content_length, url)
                    if on_log:
                        on_log(f"  .. Пропущен (мало байт {content_length}): {os.path.basename(str(path))}", "debug")
                    r.close()
                    return False
            # Запись файла
            with open(path, 'wb') as f:
                for chunk in r.iter_content(8192):
                    f.write(chunk)
            # FIX: Закрываем соединение после записи
            r.close()
            # После записи проверяем фактический размер изображения
            if ext in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'):
                actual_size = path.stat().st_size
                if actual_size < Config.MIN_IMAGE_SIZE:
                    path.unlink()
                    logger.debug("[MEDIA] Удалено (файл %d байт < %d): %s", actual_size, Config.MIN_IMAGE_SIZE, url)
                    if on_log:
                        on_log(f"  .. Удалён маленький файл ({actual_size} байт): {path.name}", "debug")
                    return False
            logger.info("[MEDIA] Скачан успешно: %s (%d байт) из %s", path.name, path.stat().st_size, url)
            if on_log:
                on_log(f"  + Скачан: {path.name}", "debug")
            return True
        except requests.exceptions.HTTPError as e:
            last_error = e
            # FIX: Корректная обработка HTTP ошибок при скачивании
            if r:
                try:
                    r.close()
                except Exception:
                    pass
            try:
                if path.exists():
                    path.unlink()
            except Exception:
                pass
            status_code = e.response.status_code if e.response is not None else '?'
            # FIX: При 403 (Forbidden) не делаем повторов — сервер не изменит решение
            if status_code == 403:
                logger.debug("[MEDIA] HTTP 403 Forbidden (без повторов): %s", url)
                return False
            logger.warning("[MEDIA] HTTP ошибка %s при скачивании %s (попытка %d/%d)", status_code, url, attempt + 1, max_retries + 1)
            if attempt < max_retries:
                time.sleep(retry_delay * (attempt + 1))
                if on_log:
                    on_log(f"  .. Повтор скачивания {attempt + 2}/{max_retries + 1}: {os.path.basename(str(path))}", "debug")
                continue
            logger.error("[MEDIA] Ошибка скачивания (HTTP %s, все попытки) %s: %s", status_code, url, e)
            if on_log:
                on_log(f"  !! Ошибка HTTP {status_code}: {os.path.basename(str(path))} | {url[:80]}", "debug")
            return False
        except Exception as e:
            last_error = e
            # Удаляем частично скачанный файл
            if r:
                try:
                    r.close()
                except Exception:
                    pass
            try:
                if path.exists():
                    path.unlink()
            except Exception:
                pass
            if attempt < max_retries:
                time.sleep(retry_delay * (attempt + 1))
                if on_log:
                    on_log(f"  .. Повтор скачивания {attempt + 2}/{max_retries + 1}: {os.path.basename(str(path))}", "debug")
                continue
            logger.error("[MEDIA] Ошибка скачивания (все попытки) %s: %s: %s", url, type(e).__name__, e)
            if on_log:
                on_log(f"  !! Ошибка скачивания: {os.path.basename(str(path))} | {url[:80]}", "debug")
            return False


def is_url_junk(url):
    """Проверяет URL на принадлежность к мусорным (эмодзи, аватары, иконки).
    
    Порядок проверки:
    1. Критический фильтр (data:, spacer и т.п.) → сразу мусор
    2. Белый список (контентные изображения/видео) → НЕ мусор (приоритет!)
    3. Фильтр исключений (аватары, иконки) → мусор
    """
    if not url:
        return True
    # Критический фильтр – сразу отбрасываем
    for pat in _FILTER_CRITICAL_COMPILED:
        if pat.search(url):
            logger.debug("URL отфильтрован (critical): %s -> %s", pat.pattern, url)
            return True
    # Белый список проверяем ДО фильтра исключений!
    # Это исправляет баг: /uploads/monthly_*/2024/*.jpg попадал под FILTER_OUT
    # и блокировался до того, как ALWAYS_ALLOW мог его разрешить
    for pat in _ALWAYS_ALLOW_COMPILED:
        if pat.search(url):
            return False
    # Фильтр исключений – если подходит, отбрасываем
    for pat in _FILTER_OUT_COMPILED:
        if pat.search(url):
            logger.debug("URL отфильтрован (filter_out): %s -> %s", pat.pattern, url)
            return True
    return False


def unthumb_url(url):
    """Преобразует URL с миниатюрой в URL полного изображения."""
    if not url:
        return url
    # Handle prodota.ru: filename.thumb.ext.hash.ext → filename.ext.hash.ext (Bug 2 fix)
    url = re.sub(r'\.thumb(\.(jpg|jpeg|png|gif|webp))\.', r'\1.', url, flags=re.I)
    # Удаляем /thumb-... или _thumb. расширение
    url = re.sub(r'/thumb(-[^/]*\.(jpg|jpeg|png|gif|webp))$', r'\1', url, flags=re.I)
    url = re.sub(r'_thumb(\.(jpg|jpeg|png|gif|webp))$', r'\1', url, flags=re.I)
    url = re.sub(r'[?&]thumb=[^&]*', '', url)
    url = re.sub(r'\?$', '', url)
    return url


# ----------------------------------------------------------------------
# Базовый класс парсера (общая логика загрузки страниц, навигации)
# ----------------------------------------------------------------------

class BaseParser:
    """Базовый класс, реализующий обход страниц, получение супа, определение количества страниц, извлечение авторов и т.д."""

    def __init__(self, start_url, on_log, on_progress, stop_flag, pause_flag,
                 allowed_authors, start_page=0, end_page=0):
        """
        :param start_url: URL темы форума
        :param on_log: функция обратного вызова для логирования (msg, level)
        :param on_progress: функция обратного вызова для обновления прогресса (current, total)
        :param stop_flag: threading.Event для сигнала остановки
        :param pause_flag: threading.Event для паузы
        :param allowed_authors: set имён авторов, если None – все авторы
        :param start_page: номер страницы, с которой начинать (0 – первая)
        :param end_page: номер последней страницы (0 – до конца)
        """
        self.start_url = self._normalize_url(start_url, start_page)
        self.on_log = on_log
        self.on_progress = on_progress
        self.stop_flag = stop_flag
        self.pause_flag = pause_flag
        self.allowed_authors = allowed_authors
        self.end_page = end_page
        self.session = self._create_session()
        self.total_pages = None           # общее количество страниц в теме
        self.current_page_num = start_page if start_page > 1 else 1
        self.pages_parsed = 0             # сколько страниц уже обработано
        self.topic_title = None           # заголовок темы
        self.topic_dir = None             # папка для сохранения результатов
        self.consecutive_failures = 0     # счётчик последовательных ошибок

    def _normalize_url(self, url, start_page):
        """Приводит URL к каноническому виду, удаляет /page/X/ и добавляет /page/start_page/ если нужно."""
        url = url.split('#')[0]
        url = re.sub(r'/page/\d+/?#?', '', url)
        url = re.sub(r'\?page=\d+', '', url)
        url = url.rstrip('/')
        if start_page <= 1:
            return url + '/'
        else:
            return url + f'/page/{start_page}/'

    def _create_session(self):
        """Создаёт requests.Session с настройками повторных попыток и стандартными заголовками."""
        s = requests.Session()
        # Bug 3 fix: backoff_factor=0 — we handle retries ourselves
        retry = Retry(total=0, backoff_factor=0,
                      status_forcelist=[500, 502, 503, 504, 429],
                      redirect=5)
        s.mount('https://', HTTPAdapter(max_retries=retry))
        s.mount('http://', HTTPAdapter(max_retries=retry))
        # Bug 3 fix: removed 'br' from Accept-Encoding (brotli not supported by requests without extra package)
        s.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-US;q=0.8,en;q=0.7',
            'Accept-Encoding': 'gzip, deflate',
            'Connection': 'keep-alive',
            'Referer': 'https://prodota.ru/',
        })
        return s

    def _mount_adapters(self):
        """Переустанавливает адаптеры без пересоздания сессии."""
        retry = Retry(total=0, backoff_factor=0,
                      status_forcelist=[500, 502, 503, 504, 429],
                      redirect=5)
        self.session.mount('https://', HTTPAdapter(max_retries=retry))
        self.session.mount('http://', HTTPAdapter(max_retries=retry))

    def _reset_connections(self):
        """Сбрасывает соединения без полного пересоздания сессии."""
        self._rotate_ua()
        self.session.adapters.clear()
        self._mount_adapters()

    def _get_ua(self):
        """Возвращает случайный User-Agent из списка."""
        return random.choice(Config.USER_AGENTS)

    def _rotate_ua(self):
        """Обновляет User-Agent в текущей сессии."""
        self.session.headers.update({'User-Agent': self._get_ua()})

    def _get_effective_end_page(self):
        """Возвращает фактическую последнюю страницу с учётом self.total_pages и self.end_page."""
        if self.end_page > 0:
            if self.total_pages and self.end_page > self.total_pages:
                return self.total_pages
            return self.end_page
        return self.total_pages

    def _init_topic_dir(self, base_path, subfolder):
        """Создаёт папку для сохранения результатов (тема + подпапка).
        
        Bug 1 fix: when allowed_authors is set, ignore subfolder parameter
        and don't add it to the path. This prevents creating 'text/' inside
        the topic folder when filtering by author.
        """
        if not self.topic_title:
            self.topic_title = 'Без названия'
        topic_folder = sanitize_filename(self.topic_title)
        if self.allowed_authors:
            # Bug 1 fix: no subfolder when filtering by author
            self.topic_dir = Path(base_path) / 'Prodota Parser' / topic_folder
        else:
            self.topic_dir = Path(base_path) / 'Prodota Parser' / topic_folder / subfolder
        try:
            self.topic_dir.mkdir(parents=True, exist_ok=True)
            self.on_log(f"Папка: {self.topic_dir}", "info")
            logger.debug("Создана папка: %s", self.topic_dir)
        except Exception as e:
            self.on_log(f"Ошибка создания папки: {e}", "error")
            import tempfile
            if self.allowed_authors:
                self.topic_dir = Path(tempfile.gettempdir()) / topic_folder
            else:
                self.topic_dir = Path(tempfile.gettempdir()) / topic_folder / subfolder
            self.topic_dir.mkdir(parents=True, exist_ok=True)
            self.on_log(f"Запасная папка: {self.topic_dir}", "warning")
        return self.topic_dir

    def get_soup(self, url, retry=0):
        """
        Загружает страницу, обрабатывает ошибки, блокировки, делает повторные попытки.
        Возвращает BeautifulSoup объект или None.
        """
        # Цикл вместо рекурсии
        while retry <= Config.MAX_RETRIES:
            # Проверка остановки
            if self.stop_flag.is_set():
                return None
            # Ожидание при паузе
            while self.pause_flag.is_set() and not self.stop_flag.is_set():
                time.sleep(0.5)
            if self.stop_flag.is_set():
                return None

            t0 = time.time()
            try:
                self.session.headers.update({'User-Agent': self._get_ua()})
                self.on_log(f"  -> Запрос: {url[:80]}{'...' if len(url) > 80 else ''}", "debug")
                resp = self.session.get(url, timeout=Config.REQUEST_TIMEOUT)
                elapsed = time.time() - t0

                # Bug 3 fix: reduced wait times for HTTP errors
                if resp.status_code in (403, 429) or resp.status_code >= 500:
                    if retry < Config.MAX_RETRIES:
                        wait = 1.5 * (retry + 1) + random.uniform(0.3, 1)
                        self.on_log(f"  !! HTTP {resp.status_code} ({elapsed:.1f}с), повтор через {int(wait)}с [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                        time.sleep(wait)
                        self._rotate_ua()
                        retry += 1
                        continue
                    self.on_log(f"  XX HTTP {resp.status_code} - все попытки исчерпаны", "error")
                    return None

                resp.raise_for_status()
                soup = BeautifulSoup(resp.content, 'html.parser')

                # Проверка на блокировку (captcha, cloudflare)
                if self._is_blocked(soup):
                    if retry < Config.MAX_RETRIES:
                        wait = 5 * (retry + 1) + random.uniform(1, 3)
                        self.on_log(f"  !! Блокировка обнаружена ({elapsed:.1f}с), пауза {int(wait)}с [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                        time.sleep(wait)
                        self._rotate_ua()
                        retry += 1
                        continue
                    self.on_log(f"  XX Блокировка - все попытки исчерпаны", "error")
                    return None

                size_kb = len(resp.content) / 1024
                self.on_log(f"  OK Загружена за {elapsed:.1f}с ({size_kb:.0f} KB)", "debug")
                return soup

            except (requests.exceptions.Timeout, requests.exceptions.ConnectionError) as e:
                elapsed = time.time() - t0
                # Bug 3 fix: reduced wait times for connection errors
                if retry < Config.MAX_RETRIES:
                    wait = 1.5 * (retry + 1) + random.uniform(0.3, 1)
                    self.on_log(f"  !! {type(e).__name__} ({elapsed:.1f}с), повтор через {int(wait)}с [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                    time.sleep(wait)
                    self._rotate_ua()
                    retry += 1
                    continue
                self.on_log(f"  XX {type(e).__name__} - все попытки исчерпаны", "error")
                return None
            except requests.exceptions.HTTPError as e:
                elapsed = time.time() - t0
                # FIX: Обработка HTTPError (404, 403 и др.) отдельно
                status_code = e.response.status_code if e.response is not None else '?'
                if retry < Config.MAX_RETRIES:
                    wait = 1.5 * (retry + 1) + random.uniform(0.3, 1)
                    self.on_log(f"  !! HTTP {status_code} {type(e).__name__} ({elapsed:.1f}с), повтор [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                    time.sleep(wait)
                    self._rotate_ua()
                    retry += 1
                    continue
                self.on_log(f"  XX HTTP {status_code} {type(e).__name__} - все попытки исчерпаны", "error")
                logger.error("[PAGES] HTTP ошибка %s для %s: %s", status_code, url, e)
                return None
            except Exception as e:
                elapsed = time.time() - t0
                if retry < Config.MAX_RETRIES:
                    wait = 1.5 * (retry + 1)
                    self.on_log(f"  !! Ошибка {type(e).__name__} ({elapsed:.1f}с), повтор [{retry + 1}/{Config.MAX_RETRIES}]", "warning")
                    time.sleep(wait)
                    self._rotate_ua()
                    retry += 1
                    continue
                self.on_log(f"  XX Неизвестная ошибка: {type(e).__name__}: {e}", "error")
                logger.error("[PAGES] Ошибка загрузки %s: %s: %s", url, type(e).__name__, e)
                return None
        return None

    def _is_blocked(self, soup):
        """Проверяет, не заблокирован ли доступ (captcha, cloudflare и т.п.).
        FIX: Усиленная проверка — только при совпадении >=4 индикаторов ИЛИ
        точное совпадение заголовка блокировки."""
        # Only check beginning of page (block pages show message at top)
        text = soup.get_text()[:3000].lower()
        # Check title specifically
        title = soup.find('title')
        title_text = title.get_text().lower().strip() if title else ''
        block_indicators = ['captcha', 'cloudflare', 'checking your browser',
                            'ddos-guard', 'доступ запрещен', 'access denied',
                            'you have been blocked', 'ip has been blocked']
        # FIX: Требуем >=4 индикаторов (было 3) для избежания ложных срабатываний
        matches = sum(1 for w in block_indicators if w in text)
        # FIX: Заголовок блокировки — только если он КОРОТКИЙ и содержит специфичные фразы
        # (темы форума могут содержать 'cloudflare' в заголовке)
        strict_title_matches = [
            'just a moment', 'attention required', 'cloudflare - security',
            'access denied', 'доступ запрещен', 'error 403', 'error 503',
            'service unavailable', 'captcha', 'ddos-guard',
        ]
        title_match = any(tm in title_text for tm in strict_title_matches) and len(title_text) < 80
        return matches >= 4 or title_match

    def get_total_pages(self, soup):
        """Извлекает общее количество страниц из супа (через ссылку "последняя" или текст)."""
        for sel in Config.SELECTORS['last_page']:
            el = soup.select_one(sel)
            if el and el.get('href'):
                m = re.search(r'page[/=_-]?(\d+)', el['href'])
                if m:
                    return int(m.group(1))
        link_last = soup.find('link', rel='last')
        if link_last and link_last.get('href'):
            m = re.search(r'page[/=_-]?(\d+)', link_last['href'])
            if m:
                return int(m.group(1))
        m = re.search(r'Страница\s+\d+\s+из\s+(\d+)', soup.get_text(), re.I)
        return int(m.group(1)) if m else None

    def get_post_blocks(self, soup):
        """Находит все блоки постов на странице по заданным селекторам."""
        for sel in Config.SELECTORS['post_blocks']:
            blocks = soup.select(sel)
            if blocks:
                self.on_log(f"По селектору {sel} найдено {len(blocks)} блоков", "debug")
                return blocks
        self.on_log("Не найдено блоков постов ни по одному селектору", "warning")
        return []

    def get_next_url(self, soup, cur):
        """Извлекает URL следующей страницы из супа."""
        for sel in Config.SELECTORS['next_page']:
            el = soup.select_one(sel)
            if el and el.get('href'):
                return urljoin(cur, el['href'])
        link_next = soup.find('link', rel='next')
        if link_next and link_next.get('href'):
            return urljoin(cur, link_next['href'])
        return None

    def extract_post_id(self, el):
        """Извлекает ID поста из HTML-элемента."""
        if el.get('id'):
            m = re.search(r'elComment[_-](\d+)|Comment-(\d+)|Post-(\d+)|comment_(\d+)', el['id'], re.I)
            if m:
                return next((g for g in m.groups() if g), None)
        if el.get('data-commentid'):
            return str(el['data-commentid'])
        link = el.select_one('a[href*="#comment-"], a[href*="#post-"]')
        if link and link.get('href'):
            m = re.search(r'#(?:comment|post)-?(\d+)', link['href'], re.I)
            if m:
                return m.group(1)
        return None

    def extract_author(self, el):
        """Извлекает имя автора из элемента поста."""
        for sel in Config.SELECTORS['author']:
            a = el.select_one(sel)
            if a:
                # Prefer nested <a> tag which contains only the username
                inner_a = a.find('a')
                if inner_a:
                    t = inner_a.get_text(strip=True)
                    if t:
                        return t
                # Fallback: get direct text of the element
                t = a.get_text(strip=True)
                if t:
                    return t
        return None

    def is_author_allowed(self, author):
        """Проверяет, разрешён ли автор (сравнение без учёта регистра)."""
        if self.allowed_authors is None:
            return True
        if not author:
            return False
        author_lower = author.lower()
        for allowed in self.allowed_authors:
            if allowed.lower() == author_lower:
                return True
        return False

    def extract_page_number(self, url):
        """Извлекает номер страницы из URL."""
        m = re.search(r'[?&]page=(\d+)', url)
        if m:
            return int(m.group(1))
        m = re.search(r'/page/(\d+)', url)
        if m:
            return int(m.group(1))
        return 1

    def iterate_pages(self):
        """Генератор, который последовательно выдаёт (soup, url, page_num) для всех страниц темы."""
        cur = self.start_url
        self.current_page_num = self.extract_page_number(cur)

        # Загрузка первой страницы с повторными попытками
        soup = None
        self.on_log(f">> Начало: {cur[:70]}{'...' if len(cur) > 70 else ''}", "info")
        for attempt in range(Config.PAGE_RETRY_COUNT):
            if self.stop_flag.is_set():
                return
            soup = self.get_soup(cur)
            if soup:
                break
            # Bug 8: use Config.PAGE_RETRY_BASE_DELAY
            wait = Config.PAGE_RETRY_BASE_DELAY * (attempt + 1) // 2 + 1
            self.on_log(f"  !! Первая страница не загружена, попытка {attempt + 1}/{Config.PAGE_RETRY_COUNT}, пауза {wait}с", "warning")
            time.sleep(wait)
            self._reset_connections()

        if not soup:
            self.on_log("  XX Не удалось загрузить первую страницу после всех попыток!", "error")
            return

        self.total_pages = self.get_total_pages(soup)
        effective_end = self._get_effective_end_page()
        end_str = str(effective_end) if effective_end else '?'
        self.on_log(f"Всего страниц: {self.total_pages or '?'}, парсинг: {self.current_page_num} - {end_str}", "info")

        yield soup, cur, self.current_page_num
        self.pages_parsed = 1
        self.consecutive_failures = 0

        # Цикл по остальным страницам
        while not self.stop_flag.is_set():
            while self.pause_flag.is_set() and not self.stop_flag.is_set():
                time.sleep(0.5)
            if self.stop_flag.is_set():
                break

            if self.end_page > 0 and self.current_page_num >= self.end_page:
                self.on_log(f">> Достигнута конечная страница: {self.end_page}", "success")
                break

            nxt = self.get_next_url(soup, cur)
            if not nxt or nxt.rstrip('/') == cur.rstrip('/'):
                # Если ссылки next нет, формируем вручную
                if self.total_pages and self.current_page_num < self.total_pages:
                    next_page = self.current_page_num + 1
                    if self.end_page > 0 and next_page > self.end_page:
                        break
                    base_url = self.start_url.split('?')[0].rstrip('/')
                    base_url = re.sub(r'/page/\d+/?', '', base_url)
                    nxt = base_url + f'/page/{next_page}/'
                else:
                    break

            cur = nxt
            next_page_num = self.extract_page_number(cur)
            self.on_log(f">> Переход к странице {next_page_num}...", "info")
            self.current_page_num = next_page_num
            soup = None

            # Если много ошибок подряд – увеличиваем паузу
            if self.consecutive_failures >= Config.MAX_CONSECUTIVE_FAILURES:
                severe_wait = min(
                    15,
                    Config.PAGE_RETRY_BASE_DELAY * (self.consecutive_failures + 1)
                )
                self.on_log(f"  !! Серия ошибок ({self.consecutive_failures}), пауза {int(severe_wait)}с", "warning")
                time.sleep(severe_wait)
                self._reset_connections()

            # Повторные попытки для текущей страницы
            for attempt in range(Config.PAGE_RETRY_COUNT):
                if self.stop_flag.is_set():
                    break
                soup = self.get_soup(cur)
                if soup:
                    blocks = self.get_post_blocks(soup)
                    if blocks:
                        break
                    else:
                        self.on_log(f"  !! Стр. {self.current_page_num}: загружена, но посты не найдены", "warning")
                        soup = None
                        wait = 2 * (attempt + 1)
                        self.on_log(f"  .. Повтор через {wait}с [{attempt + 1}/{Config.PAGE_RETRY_COUNT}]", "warning")
                        time.sleep(wait)
                        self._reset_connections()
                        continue
                else:
                    wait = 1.5 * (attempt + 1) + random.uniform(0.3, 1)
                    self.on_log(
                        f"  !! Стр. {self.current_page_num}: ошибка загрузки, "
                        f"повтор через {int(wait)}с [{attempt + 1}/{Config.PAGE_RETRY_COUNT}]",
                        "warning"
                    )
                    time.sleep(wait)
                    self._reset_connections()

            if not soup:
                self.on_log(f"  XX Стр. {self.current_page_num}: ПРОПУЩЕНА после {Config.PAGE_RETRY_COUNT} попыток", "error")
                self.consecutive_failures += 1
                continue

            self.consecutive_failures = 0
            yield soup, cur, self.current_page_num
            self.pages_parsed += 1

            delay = random.uniform(Config.MIN_DELAY, Config.MAX_DELAY)
            self.on_log(f"  .. Задержка {delay:.1f}с перед следующей страницей", "debug")
            time.sleep(delay)

    def extract_topic_title(self, soup):
        """Извлекает заголовок темы из супа (первый вызов)."""
        if self.topic_title:
            return self.topic_title
        for sel in Config.SELECTORS.get('topic_title', []):
            el = soup.select_one(sel)
            if el:
                title = el.get_text(strip=True)
                if title:
                    self.topic_title = title
                    return title
        title_tag = soup.find('title')
        if title_tag:
            title = title_tag.get_text(strip=True)
            title = re.sub(r'\s*[—\-–]\s*Page\s+\d+.*$', '', title, flags=re.I)
            title = re.sub(r'\s*[—\-–]\s*Страница\s+\d+.*$', '', title, flags=re.I)
            title = re.sub(r'\s*[—\-–]\s*Таверна.*$', '', title, flags=re.I)
            title = re.sub(r'\s*[—\-–]\s*prodota\.ru.*$', '', title, flags=re.I)
            title = title.strip(' —-–')
            if title:
                self.topic_title = title
                return title
        self.topic_title = 'Без названия'
        return self.topic_title


# ----------------------------------------------------------------------
# Текстовый парсер (извлекает текст постов и сохраняет в TXT/JSON/CSV)
# ----------------------------------------------------------------------

class TextParser(BaseParser):
    """Парсер для извлечения текста сообщений."""

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.posts = []          # список словарей с информацией о постах
        self.seen = set()        # множество ID уже обработанных постов (для избежания дублей)
        self.first_page = None   # номер первой обработанной страницы
        self.last_page = None    # номер последней обработанной страницы

    def _get_text_content_element(self, post_element):
        """Находит элемент, содержащий текст поста."""
        direct = post_element.select_one('[data-role="commentContent"]')
        if direct:
            return direct
        for sel in Config.SELECTORS['content']:
            content_el = post_element.select_one(sel)
            if content_el:
                inner = content_el.select_one('[data-role="commentContent"]')
                if inner:
                    return inner
                return content_el
        return None

    def extract_post_text_html(self, content_element):
        """Рекурсивно обходит HTML-дерево и извлекает текст, обрабатывая цитаты, спойлеры и т.д.
        
        Bug 2 fix: added handlers for li, ul, ol, blockquote (generic), strong, b, em, i,
        code, pre, h1-h6, hr elements."""
        if not content_element:
            return ''

        parts = []  # накапливаем фрагменты текста

        def _walk(node):
            if not isinstance(node, Tag):
                text = str(node)
                if text:
                    parts.append(text)
                return

            name = node.name.lower() if node.name else ''

            # Пропускаем скрипты, стили
            if name in ('script', 'style', 'noscript'):
                return

            classes = node.get('class', [])
            if isinstance(classes, str):
                classes = classes.split()
            class_str = ' '.join(classes).lower()

            # Обработка спойлеров (details, ipsSpoiler)
            if name == 'details' or 'ipsspoiler' in class_str or (
                'data-ipsspoiler' in (node.get('data-ipsspoiler', '') or '').lower()
            ):
                if name == 'details':
                    for child in node.children:
                        if isinstance(child, Tag) and child.name and child.name.lower() == 'summary':
                            continue
                        _walk(child)
                else:
                    for child in node.children:
                        _walk(child)
                return

            # Обработка скрытого текста (ipsHide)
            if 'ipshide' in class_str:
                for child in node.children:
                    _walk(child)
                return

            # Обработка цитат (ipsQuote) — improved quote formatting
            if 'ipsquote' in class_str:
                quote_author = ''
                quote_citation_text = ''
                quote_username = node.get('data-ipsquote-username', '')
                if quote_username:
                    quote_author = f' ({quote_username})'
                elif node.get('data-ipsquote', ''):
                    try:
                        data = json.loads(node.get('data-ipsquote', ''))
                        if isinstance(data, dict):
                            quote_author = f" ({data.get('username', '')})"
                    except Exception:
                        pass

                # Also extract citation text (e.g., "написал 2 часа назад:")
                citation_el = node.select_one('.ipsQuote_citation')
                if citation_el:
                    quote_citation_text = citation_el.get_text(strip=True)

                quote_contents = node.select_one('.ipsQuote_contents')
                if quote_contents:
                    quote_text = self._extract_quote_body(quote_contents)
                else:
                    quote_text = node.get_text(separator=' ', strip=True)

                if quote_text:
                    parts.append(f'\n{"─" * 40}')
                    parts.append(f'Цитата{quote_author}')
                    if quote_citation_text and quote_citation_text != quote_username:
                        parts.append(f'  {quote_citation_text}')
                    parts.append(f'{"─" * 40}')
                    for qline in quote_text.split('\n'):
                        qline = qline.strip()
                        if qline:
                            parts.append(f'│ {qline}')
                    parts.append(f'{"─" * 40}')
                    parts.append('')
                return

            def _walk_quoted(nd, qparts):
                """Walk node for blockquote content, appending to qparts."""
                if not isinstance(nd, Tag):
                    qparts.append(str(nd))
                    return
                nd_name = nd.name.lower() if nd.name else ''
                if nd_name in ('script', 'style', 'noscript'):
                    return
                for child in nd.children:
                    _walk_quoted(child, qparts)

            # Bug 2 fix: handle generic <blockquote> elements
            if name == 'blockquote':
                parts.append('\n')
                # Collect quote text
                quote_parts = []
                for child in node.children:
                    # Walk child but capture into quote_parts
                    _walk_quoted(child, quote_parts)
                quote_text = ''.join(quote_parts).strip()
                if quote_text:
                    parts.append(f'{"─" * 40}')
                    parts.append(f'│ {quote_text.replace(chr(10), chr(10) + "│ ")}')
                    parts.append(f'{"─" * 40}')
                    parts.append('')
                return

            # Bug 2 fix: handle <ul> and <ol> lists
            if name == 'ul':
                parts.append('\n')
                for child in node.children:
                    if isinstance(child, Tag) and child.name and child.name.lower() == 'li':
                        parts.append('• ')
                        for li_child in child.children:
                            _walk(li_child)
                        parts.append('\n')
                    elif isinstance(child, Tag):
                        _walk(child)
                    else:
                        t = str(child).strip()
                        if t:
                            parts.append(f'• {t}\n')
                parts.append('\n')
                return

            if name == 'ol':
                parts.append('\n')
                counter = 1
                for child in node.children:
                    if isinstance(child, Tag) and child.name and child.name.lower() == 'li':
                        start = int(child.get('value', counter)) if child.get('value') else counter
                        parts.append(f'{start}. ')
                        for li_child in child.children:
                            _walk(li_child)
                        parts.append('\n')
                        counter = start + 1
                    elif isinstance(child, Tag):
                        _walk(child)
                    else:
                        t = str(child).strip()
                        if t:
                            parts.append(f'{counter}. {t}\n')
                            counter += 1
                parts.append('\n')
                return

            # Bug 2 fix: handle <li> outside of explicit ul/ol
            if name == 'li':
                parts.append('• ')
                for child in node.children:
                    _walk(child)
                parts.append('\n')
                return

            # Bug 2 fix: handle headings
            if name in ('h1', 'h2', 'h3', 'h4', 'h5', 'h6'):
                parts.append('\n')
                for child in node.children:
                    _walk(child)
                parts.append('\n')
                return

            # Bug 2 fix: handle <hr>
            if name == 'hr':
                parts.append('\n─' * 20 + '\n')
                return

            # Bug 2 fix: handle <strong>/<b> with **markers**
            if name in ('strong', 'b'):
                parts.append('**')
                for child in node.children:
                    _walk(child)
                parts.append('**')
                return

            # Bug 2 fix: handle <em>/<i> with *markers*
            if name in ('em', 'i'):
                parts.append('*')
                for child in node.children:
                    _walk(child)
                parts.append('*')
                return

            # Bug 2 fix: handle <code> and <pre>
            if name == 'code':
                parts.append('`')
                for child in node.children:
                    _walk(child)
                parts.append('`')
                return

            if name == 'pre':
                parts.append('\n```\n')
                for child in node.children:
                    _walk(child)
                parts.append('\n```\n')
                return

            # Пропускаем изображения (они обрабатываются отдельно)
            if name == 'img':
                return

            # Пропускаем служебные блоки (кнопки, реакции)
            if any(kw in class_str for kw in
                   ['ipsitemcontrols', 'ipsreact', 'cpost_share',
                    'cpost_moderation', 'cpost_controls',
                    'ipscomment_controls', 'ipscomment_edit',
                    'ipscomment_meta', 'ipscomment_reaction']):
                return

            # Пропускаем подписи
            if 'membersignature' in (node.get('data-role', '') or '').lower():
                return

            if name == 'time':
                return

            # Ссылки: берём только текст, не URL
            if name == 'a':
                link_text = node.get_text(strip=True)
                if link_text:
                    parts.append(link_text)
                return

            if name == 'br':
                parts.append('\n')
                return

            if name == 'p':
                parts.append('\n')
                for child in node.children:
                    _walk(child)
                parts.append('\n')
                return

            # Пропускаем удалённые посты
            if 'ipscomment_deleted' in class_str or 'ipsfaded_more' in class_str:
                return

            # Рекурсивный обход дочерних элементов
            for child in node.children:
                _walk(child)

        _walk(content_element)

        raw_text = ''.join(parts)
        raw_text = re.sub(r'\n{3,}', '\n\n', raw_text)
        raw_text = re.sub(r'\n +', '\n', raw_text)
        return raw_text.strip()

    def _extract_quote_body(self, quote_contents_el):
        """Извлекает текст из элемента цитаты, удаляя заголовок цитаты.
        Handle video URLs, images, and attachments inside quotes."""
        citation_selectors = [
            '.ipsQuote_citation', 'cite', 'p > strong > a',
            'p.ipsQuote_citation',
        ]
        temp = BeautifulSoup(str(quote_contents_el), 'html.parser')
        for s in citation_selectors:
            c = temp.select_one(s)
            if c:
                c.decompose()
                break

        # Remove video elements but add a note
        for vid in temp.select('video'):
            note = '[(видео)]'
            vid.replace_with(temp.new_string(note))

        # Remove images but add a note
        for img in temp.select('img'):
            note = '[(изображение)]'
            img.replace_with(temp.new_string(note))

        # Remove attachment links but keep filename
        for a in temp.select('a.ipsAttachLink'):
            text = a.get_text(strip=True) or '[(вложение)]'
            a.replace_with(temp.new_string(text))

        full_text = temp.get_text(separator='\n', strip=True)
        lines = full_text.split('\n')
        cleaned_lines = []
        for line in lines:
            stripped = line.strip()
            # Удаляем строки типа "User said On ..."
            if re.match(r'^.*?said\s+On\s+\d{2}/\d{2}/\d{4}\s+at\s+\d{2}:\d{2}\s*(?:AM|PM)?\s*:?\s*$', stripped, re.I):
                continue
            if re.match(r'^(?:написал|писал|написала)\s+.*?(?:назад|вчера|сегодня)', stripped, re.I):
                continue
            if re.match(r'^(?:\d+\s*(?:час|часа|часов|минут[ауы]?|секунд[ауы]?|день|дня|дней|неделю|недели|месяц|месяца|месяцев|год|года|лет)\s*)+назад$', stripped, re.I):
                continue
            if re.match(r'^Опубликовано:\s*', stripped, re.I):
                continue
            if stripped:
                cleaned_lines.append(stripped)
        # Сохраняем переносы строк между абзацами цитаты (а не склеиваем через пробел)
        return '\n'.join(cleaned_lines)

    def parse_post(self, el, page_num=1):
        """Извлекает из HTML-элемента поста данные (ID, автор, текст). Возвращает словарь или None."""
        pid = self.extract_post_id(el)
        if not pid or pid in self.seen:
            return None
        author = self.extract_author(el)
        if not self.is_author_allowed(author):
            return None

        content_el = self._get_text_content_element(el)
        if not content_el:
            return None

        # Bug 9: log post parsing start
        logger.debug("Парсинг поста #%s автор=%s страница=%d", pid, author, page_num)

        raw_text = self.extract_post_text_html(content_el)
        if not raw_text:
            return None

        cleaned_text = clean_post_content(raw_text)
        if not cleaned_text:
            return None

        self.seen.add(pid)
        logger.debug("Пост #%s успешно распарсен (%d символов)", pid, len(cleaned_text))
        return {
            'author': author or 'Anonymous',
            'post_number': pid,
            'page_number': page_num,
            'post_text': cleaned_text,
        }

    def run(self, base_path):
        """Запускает парсинг текста. Возвращает список постов."""
        self.on_log("== Режим ТЕКСТ запущен ==", "success")
        for soup, url, page_num in self.iterate_pages():
            if self.first_page is None:
                self.first_page = page_num
                self.extract_topic_title(soup)
                if self.topic_title:
                    self.on_log(f"Тема: {self.topic_title}", "info")
                self._init_topic_dir(base_path, 'text')

            for block in self.get_post_blocks(soup):
                if self.stop_flag.is_set():
                    break
                p = self.parse_post(block, page_num)
                if p:
                    self.posts.append(p)
            self.last_page = page_num

            effective_end = self._get_effective_end_page()
            end_str = str(effective_end) if effective_end else '?'
            prog = f"Страница {page_num}/{end_str} | постов: {len(self.posts)}"
            self.on_log(prog, "progress")
            self.on_progress(page_num, effective_end)
        return self.posts

    def _save_txt(self, filepath=None):
        """Сохраняет все посты в текстовый файл."""
        if not self.topic_dir:
            return None
        p = Path(filepath) if filepath else self.topic_dir / 'posts.txt'

        sep = '=' * 55
        thin_sep = '-' * 55
        topic = self.topic_title or 'Без названия'
        total_pages = self.total_pages or (self.last_page or 1)
        first = self.first_page or 1
        last = self.last_page or first

        with open(p, 'w', encoding='utf-8') as f:
            f.write(f"Название темы: {topic}\n")
            f.write(f"Общее количество страниц: {total_pages}\n")
            f.write(f"Общее количество постов: {len(self.posts)}\n")
            f.write(f"Парсинг со страницы {first} по страницу {last}\n")
            f.write(f"{sep}\n")

            if self.allowed_authors is not None:
                # Группировка по авторам
                author_posts = OrderedDict()
                for post in self.posts:
                    author = post.get('author', 'Anonymous')
                    if author not in author_posts:
                        author_posts[author] = []
                    author_posts[author].append(post)

                for i, (author, author_post_list) in enumerate(author_posts.items()):
                    if i > 0:
                        f.write(f"\n{sep}\n")
                    f.write(f"\nАВТОР: {author}\n")
                    f.write(f"Количество постов: {len(author_post_list)}\n")
                    f.write(f"{thin_sep}\n\n")
                    for post in author_post_list:
                        f.write(f"Пост #{post.get('post_number', '?')}\n")
                        f.write(f"Страница: {post.get('page_number', '?')}\n")
                        f.write(f"{thin_sep}\n")
                        f.write(f"{post.get('post_text', '')}\n")
                        f.write(f"{thin_sep}\n\n")
            else:
                for post in self.posts:
                    f.write(f"\n{sep}\n")
                    f.write(f"Пост #{post.get('post_number', '?')}\n")
                    f.write(f"Автор: {post.get('author', 'Anonymous')}\n")
                    f.write(f"Страница: {post.get('page_number', '?')}\n")
                    f.write(f"{thin_sep}\n")
                    f.write(f"{post.get('post_text', '')}\n")
        return p

    def _save_json(self, filepath=None):
        """Сохраняет все посты в JSON."""
        if not self.topic_dir:
            return None
        p = Path(filepath) if filepath else self.topic_dir / 'posts.json'

        topic = self.topic_title or 'Без названия'
        first = self.first_page or 1
        last = self.last_page or first
        total_pages = self.total_pages or (last - first + 1)

        posts_data = []
        for post in self.posts:
            posts_data.append({
                "post_number": post.get('post_number', ''),
                "author": post.get('author', ''),
                "page_number": post.get('page_number', ''),
                "post_text": post.get('post_text', ''),
            })

        data = {
            "metadata": {
                "topic": topic,
                "total_pages": total_pages,
                "total_posts": len(self.posts),
                "parsed_from_page": first,
                "parsed_to_page": last,
                "authors_filter": sorted(self.allowed_authors) if self.allowed_authors else None,
            },
            "posts": posts_data
        }

        with open(p, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        return p

    def _save_csv(self, filepath=None):
        """Сохраняет все посты в CSV."""
        if not self.topic_dir:
            return None
        p = Path(filepath) if filepath else self.topic_dir / 'posts.csv'

        with open(p, 'w', newline='', encoding='utf-8-sig') as f:
            w = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
            w.writerow([
                'Номер поста',
                'Страница',
                'Автор',
                'Текст поста'
            ])
            for post in self.posts:
                w.writerow([
                    post.get('post_number', ''),
                    post.get('page_number', ''),
                    post.get('author', ''),
                    post.get('post_text', '')
                ])
        return p

    def save_all(self):
        """Сохраняет результаты во всех форматах (TXT, JSON, CSV).
        
        Bug 1 fix: when allowed_authors is set, only create per-author files.
        Skip combined save (_save_txt, _save_json, _save_csv at topic level).
        Do NOT save into text/ subfolder when filtering.
        After saving, clean up any empty text/ folder that may have been created.
        
        Always create folders for ALL specified authors, even those with no posts.
        """
        saved = []

        # Группируем посты по авторам
        author_posts = OrderedDict()
        for post in self.posts:
            author = post.get('author', 'Anonymous')
            if author not in author_posts:
                author_posts[author] = []
            author_posts[author].append(post)

        if self.allowed_authors:
            # Bug 1 fix: фильтр авторов задан — ТОЛЬКО отдельные папки по каждому автору
            # НЕ создаём общие файлы в text/ папке

            # Determine topic base (parent of topic_dir, which may or may not have 'text' appended)
            # When allowed_authors is set, _init_topic_dir already doesn't add subfolder,
            # so self.topic_dir points directly to topic folder
            topic_base = self.topic_dir

            # Create folders for ALL specified authors first
            for author in self.allowed_authors:
                safe_author = sanitize_filename(author, 60)
                author_dir = topic_base / safe_author
                try:
                    author_dir.mkdir(parents=True, exist_ok=True)
                    logger.debug("Создана папка автора: %s", author_dir)
                except Exception:
                    pass

            # Save per-author files (only for authors who have posts)
            for author, author_post_list in author_posts.items():
                safe_author = sanitize_filename(author, 60)
                # Bug 1 fix: per-author folders at topic level (not inside text/)
                author_dir = topic_base / safe_author
                try:
                    author_dir.mkdir(parents=True, exist_ok=True)
                    logger.debug("Создана папка автора: %s", author_dir)
                except Exception as e:
                    self.on_log(f"Ошибка создания папки автора {author}: {e}", "error")
                    continue

                txt_path = author_dir / f"{safe_author} posts.txt"
                json_path = author_dir / f"{safe_author} posts.json"
                csv_path = author_dir / f"{safe_author} posts.csv"

                sep = '=' * 55
                thin_sep = '-' * 55
                topic = self.topic_title or 'Без названия'
                total_pages = self.total_pages or (self.last_page or 1)
                first = self.first_page or 1
                last = self.last_page or first

                with open(txt_path, 'w', encoding='utf-8') as f:
                    f.write(f"Название темы: {topic}\n")
                    f.write(f"Общее количество страниц: {total_pages}\n")
                    f.write(f"Количество постов автора: {len(author_post_list)}\n")
                    f.write(f"Парсинг со страницы {first} по страницу {last}\n")
                    f.write(f"{sep}\n\n")
                    f.write(f"АВТОР: {author}\n")
                    f.write(f"Количество постов: {len(author_post_list)}\n")
                    f.write(f"{thin_sep}\n\n")
                    for post in author_post_list:
                        f.write(f"Пост #{post.get('post_number', '?')}\n")
                        f.write(f"Страница: {post.get('page_number', '?')}\n")
                        f.write(f"{thin_sep}\n")
                        f.write(f"{post.get('post_text', '')}\n")
                        f.write(f"{thin_sep}\n\n")
                saved.append(txt_path)

                posts_data = [{
                    "post_number": post.get('post_number', ''),
                    "author": post.get('author', ''),
                    "page_number": post.get('page_number', ''),
                    "post_text": post.get('post_text', ''),
                } for post in author_post_list]

                json_data = {
                    "metadata": {
                        "topic": topic,
                        "total_pages": total_pages,
                        "total_posts": len(author_post_list),
                        "parsed_from_page": first,
                        "parsed_to_page": last,
                        "authors_filter": [author],
                    },
                    "posts": posts_data
                }
                with open(json_path, 'w', encoding='utf-8') as f:
                    json.dump(json_data, f, ensure_ascii=False, indent=2)
                saved.append(json_path)

                with open(csv_path, 'w', newline='', encoding='utf-8-sig') as f:
                    w = csv.writer(f, delimiter=';', quoting=csv.QUOTE_ALL)
                    w.writerow(['Номер поста', 'Страница', 'Автор', 'Текст поста'])
                    for post in author_post_list:
                        w.writerow([
                            post.get('post_number', ''),
                            post.get('page_number', ''),
                            post.get('author', ''),
                            post.get('post_text', '')
                        ])
                saved.append(csv_path)
                self.on_log(f"  Папка автора: {author_dir}", "info")

            # Bug 1 fix: clean up empty 'text' folder if it was created
            text_folder = topic_base / 'text'
            _try_remove_empty_dir(text_folder)
        else:
            # Нет фильтра авторов – только общие файлы
            if self.posts:
                t = self._save_txt()
                j = self._save_json()
                c = self._save_csv()
                if t:
                    saved.append(t)
                if j:
                    saved.append(j)
                if c:
                    saved.append(c)
        return saved


# ----------------------------------------------------------------------
# Медиапарсер (извлекает изображения и видео)
# ----------------------------------------------------------------------

class MediaParser(BaseParser):
    """Парсер для скачивания изображений и видео из постов."""

    def __init__(self, *args, media_type='both', **kwargs):
        super().__init__(*args, **kwargs)
        self.media_type = media_type        # 'images', 'videos' или 'both'
        self.downloaded = 0                 # количество успешно скачанных файлов
        self.filtered_out = 0               # количество отфильтрованных URL
        self.metadata = []                  # список словарей с информацией о скачанных файлах
        self.seen_video_urls = set()        # нормализованные URL видео для избежания дублей
        self.seen_image_urls = set()        # нормализованные URL изображений
        self.duplicates_skipped = 0         # количество пропущенных дублей видео
        self.image_duplicates_skipped = 0   # количество пропущенных дублей изображений
        # FIX: Счётчики для переименования изображений и видео
        self.image_counter = 0              # счётчик для «картинка N»
        self.video_counter = 0              # счётчик для «видео N»

    def _get_content_element(self, post_element):
        """Находит элемент с содержимым поста (аналогично текстовому парсеру)."""
        for sel in Config.SELECTORS['content']:
            content_el = post_element.select_one(sel)
            if content_el:
                inner = content_el.select_one('[data-role="commentContent"]')
                if inner:
                    return inner
                return content_el
        return None

    def _is_inside_quote(self, elem):
        """Проверяет, находится ли элемент внутри цитаты."""
        if not isinstance(elem, Tag):
            return False
        parent = elem.parent
        depth = 0
        while parent and isinstance(parent, Tag) and depth < 15:
            classes = parent.get('class', [])
            if isinstance(classes, str):
                classes = classes.split()
            class_str = ' '.join(classes).lower()
            # Если достигли основного контента – не в цитате
            if 'cpost_contentwrap' in class_str:
                return False
            role = parent.get('data-role', '')
            if isinstance(role, str) and role.lower() == 'commentcontent':
                return False
            if 'ipsquote' in class_str:
                return True
            depth += 1
            parent = parent.parent
        return False

    def _is_inside_signature(self, elem):
        """Проверяет, находится ли элемент внутри подписи пользователя."""
        if not isinstance(elem, Tag):
            return False
        parent = elem.parent
        depth = 0
        while parent and isinstance(parent, Tag) and depth < 10:
            role = parent.get('data-role', '')
            if isinstance(role, str) and role.lower() == 'commentcontent':
                return False
            classes = parent.get('class', [])
            if isinstance(classes, str):
                classes = classes.split()
            class_str = ' '.join(classes).lower()
            if 'cpost_contentwrap' in class_str:
                return False
            if isinstance(role, str) and role.lower() == 'membersignature':
                return True
            depth += 1
            parent = parent.parent
        return False

    @staticmethod
    def _normalize_video_url(url):
        """Нормализует URL видео (только путь, без параметров)."""
        if not url:
            return ''
        parsed = urlparse(url)
        return parsed.path.lower()

    @staticmethod
    def _normalize_image_url(url):
        """Нормализует URL изображения (только путь, без параметров)."""
        if not url:
            return ''
        parsed = urlparse(url)
        return parsed.path.lower()

    def _is_junk_element(self, elem):
        """Проверяет, является ли элемент мусорным (аватар, эмодзи, кнопка реакции и т.п.)."""
        if not isinstance(elem, Tag):
            return True
        classes = elem.get('class', [])
        if isinstance(classes, str):
            classes = classes.split()
        class_str = ' '.join(classes).lower()

        junk_classes = [
            'cauthorpane', 'ipscomment_author', 'author', 'avatar',
            'ipsuserphoto', 'ipsbadge', 'ipsrank', 'ipsribbon', 'ipsgroup',
            'ipsreact', 'ipsrep', 'ipscomment_controls', 'ipscomment_header',
            'ipscomment_meta', 'ipscomment_reaction', 'ipsquote',
            'ipstooltip', 'ipsfaded', 'ipsmenu', 'ipsdialog',
            'emoji', 'emoticon', 'smiley', 'smilie',
            'reaction', 'like', 'rep',
            'cpost_share', 'cpost_moderation', 'cpost_controls',
            'ipscomment_edit', 'ipscomment_deleted', 'ipscomment_wrap',
            'ipsquote_header', 'ipsquote_contents',
        ]
        for jc in junk_classes:
            if jc in class_str:
                return True

        for attr in elem.attrs:
            attr_lower = attr.lower()
            if any(kw in attr_lower for kw in ['avatar', 'emoji', 'emoticon', 'reaction', 'badge', 'rank']):
                return True

        if elem.has_attr('data-emoticon'):
            return True

        alt = elem.get('alt', '')
        if alt and re.match(r'^:[\w]+:$', alt.strip()):
            return True

        # Проверка родителей
        parent = elem.parent
        depth = 0
        while parent and isinstance(parent, Tag) and depth < 8:
            parent_classes = parent.get('class', [])
            if isinstance(parent_classes, str):
                parent_classes = parent_classes.split()
            parent_class_str = ' '.join(parent_classes).lower()

            if 'cpost_contentwrap' in parent_class_str:
                break
            parent_role = parent.get('data-role', '')
            if isinstance(parent_role, str) and parent_role.lower() == 'commentcontent':
                break

            for jc in ['ipsreact', 'ipscomment_meta', 'ipscomment_controls',
                        'cpost_share', 'cpost_moderation', 'cpost_controls',
                        'ipscomment_header', 'ipsmenu', 'ipsdialog',
                        'ipsitemcontrols']:
                if jc in parent_class_str:
                    return True
            if isinstance(parent_role, str) and parent_role.lower() == 'membersignature':
                return True
            depth += 1
            parent = parent.parent

        return False

    def extract_media(self, post_element, pid, author):
        """
        Извлекает из поста URL изображений и видео.
        Возвращает список кортежей (type, url, post_id, author).
        """
        items = []
        seen = set()

        content_el = self._get_content_element(post_element)
        if not content_el:
            return items

        # Селекторы для изображений
        image_selectors = [
            'img[src]', 'img[data-src]', 'img[data-original]',
            'img[data-lazy-src]', 'img[data-cfsrc]',
            'a[href*=".jpg"]', 'a[href*=".jpeg"]', 'a[href*=".png"]',
            'a[href*=".gif"]', 'a[href*=".webp"]', 'a[href*=".bmp"]',
            '.ipsAttachments a[href*="."]', 'figure img', 'figure a[href*="."]',
        ]

        # Селекторы для видео
        video_selectors = [
            'video source[data-video-src]', 'video source[src]',
            'video[data-video-src]', 'video[src]',
            'a[href*=".mp4"]', 'a[href*=".webm"]', 'a[href*=".mov"]',
            'a[href*=".mkv"]', 'a[href*=".avi"]',
            'a[data-fileext="mp4"]', 'a[data-fileext="webm"]',
            '.ipsAttachments a[href*=".mp4"]', '.ipsAttachments a[href*=".webm"]',
        ]

        # Обработка изображений
        if self.media_type in ('images', 'both'):
            for sel in image_selectors:
                for elem in content_el.select(sel):
                    if self._is_junk_element(elem):
                        continue
                    if self._is_inside_quote(elem):
                        continue
                    if self._is_inside_signature(elem):
                        continue

                    if elem.name == 'img':
                        url = (elem.get('data-src') or elem.get('data-original')
                               or elem.get('data-lazy-src') or elem.get('data-cfsrc')
                               or elem.get('src'))
                    else:
                        url = (elem.get('src') or elem.get('data-src')
                               or elem.get('data-original') or elem.get('data-lazy-src')
                               or elem.get('href'))
                    if not url:
                        continue

                    full = urljoin(self.start_url, url)

                    # Для ссылок, которые ведут на страницу с изображением, пытаемся взять реальный URL из вложенного img
                    if elem.name == 'a':
                        inner_img = elem.select_one('img')
                        if inner_img:
                            url_from_a = elem.get('href')
                            if url_from_a:
                                full = urljoin(self.start_url, url_from_a)

                    if is_url_junk(full):
                        self.filtered_out += 1
                        continue

                    original_url = unthumb_url(full)
                    normalized = self._normalize_image_url(original_url)
                    if normalized and normalized in self.seen_image_urls:
                        self.image_duplicates_skipped += 1
                        continue

                    if original_url not in seen:
                        seen.add(original_url)
                        if original_url != full:
                            seen.add(full)
                        if normalized:
                            self.seen_image_urls.add(normalized)
                        items.append(('image', original_url, pid, author))

            # Дополнительный поиск по атрибутам data-src и т.п. через regex
            content_html = str(content_el)
            for pat in [
                r'data-src\s*=\s*["\'](https?://[^"\'\s>]+\.(?:jpg|jpeg|png|gif|webp|bmp))',
                r'data-original\s*=\s*["\'](https?://[^"\'\s>]+\.(?:jpg|jpeg|png|gif|webp|bmp))',
                r'data-cfsrc\s*=\s*["\'](https?://[^"\'\s>]+\.(?:jpg|jpeg|png|gif|webp|bmp))',
            ]:
                for m in re.finditer(pat, content_html, re.I):
                    found_url = m.group(1).strip()
                    if any(kw in found_url.lower() for kw in ['spacer', 'blank', 'pixel', '1x1', 'transparent', 'clear']):
                        continue
                    full = urljoin(self.start_url, found_url)
                    if is_url_junk(full):
                        continue
                    original_url = unthumb_url(full)
                    normalized = self._normalize_image_url(original_url)
                    if normalized and normalized in self.seen_image_urls:
                        continue
                    if original_url not in seen:
                        seen.add(original_url)
                        if normalized:
                            self.seen_image_urls.add(normalized)
                        items.append(('image', original_url, pid, author))

        # Обработка видео
        if self.media_type in ('videos', 'both'):
            video_urls_found = []

            # Элементы <video>
            video_elements = content_el.select('video')
            for vid_elem in video_elements:
                if self._is_inside_quote(vid_elem):
                    continue
                if self._is_inside_signature(vid_elem):
                    continue
                vid_url = (vid_elem.get('src') or vid_elem.get('data-video-src')
                           or vid_elem.get('data-embedurl') or vid_elem.get('data-video-url'))
                if vid_url:
                    video_urls_found.append((vid_url, vid_elem))
                for source in vid_elem.find_all('source', recursive=False):
                    src_url = source.get('src') or source.get('data-video-src') or source.get('data-src')
                    if src_url:
                        video_urls_found.append((src_url, source))

            # Ссылки на видеофайлы
            for sel in video_selectors:
                for elem in content_el.select(sel):
                    if self._is_junk_element(elem):
                        continue
                    if self._is_inside_quote(elem):
                        continue
                    if self._is_inside_signature(elem):
                        continue
                    url = elem.get('href') or elem.get('data-video-src')
                    if url:
                        video_urls_found.append((url, elem))

            # Регулярные выражения для прямых ссылок (handle hashed video URLs)
            content_html = str(content_el)
            for pat in [
                r'data-video-src\s*=\s*["\'](https?://[^"\'\s>]+\.(?:mp4|webm|mov|mkv|avi)[^"\'\s>]*)',
                r'(?:src|href|data-video-src)\s*=\s*["\'](https?://[^"\'\s>]+?\.(?:mp4|webm|mov|mkv|avi)(?:\.[^"\'\s>]*)?)["\'\s>]',
            ]:
                for m in re.finditer(pat, content_html, re.I):
                    if re.search(r'\.(?:mp4|webm|mov|mkv|avi)\s*["\'\s>]', m.group(0), re.I):
                        video_urls_found.append((m.group(1).strip(), None))

            for raw_url, elem in video_urls_found:
                if not raw_url:
                    continue
                full = urljoin(self.start_url, raw_url)
                # Пропускаем ссылки на видеохостинги (YouTube и т.п.)
                if any(h in full.lower() for h in ['youtube', 'youtu.be', 'vimeo', 'twitch', 'dailymotion']):
                    continue
                if is_url_junk(full):
                    self.filtered_out += 1
                    continue
                if elem is not None:
                    if self._is_inside_quote(elem):
                        continue
                    if self._is_junk_element(elem):
                        continue

                normalized = self._normalize_video_url(full)
                if not normalized:
                    continue
                if normalized in self.seen_video_urls:
                    self.duplicates_skipped += 1
                    continue
                if full not in seen:
                    seen.add(full)
                    self.seen_video_urls.add(normalized)
                    items.append(('video', full, pid, author))

        return items

    def _get_author_media_dir(self, topic_base, author, mtype):
        """Возвращает путь к папке медиа для конкретного автора.
        При фильтре авторов создаёт структуру: topic_base/Имя автора/images/ (или video/)
        Без фильтра — topic_base/images/ (или video/)"""
        safe_author = sanitize_filename(author, 60)
        if self.allowed_authors:
            # Фильтр авторов задан — создаём подпапку по автору
            author_base = topic_base / safe_author
            author_base.mkdir(parents=True, exist_ok=True)
            if mtype == 'image':
                return author_base / 'images'
            elif mtype == 'video':
                return author_base / 'video'
        # Без фильтра — в общие папки
        if mtype == 'image':
            return topic_base / 'images'
        elif mtype == 'video':
            return topic_base / 'video'
        return topic_base

    def run(self, base_path):
        """Запускает парсинг медиа. Возвращает количество скачанных файлов."""
        if self.media_type in ('images', 'both'):
            img_sub = 'images'
        else:
            img_sub = None
        if self.media_type in ('videos', 'both'):
            vid_sub = 'video'
        else:
            vid_sub = None

        self.on_log(f"== Режим МЕДИА ({self.media_type}) запущен ==", "success")

        total_media_found = 0
        topic_dir_initialized = False
        # Кэш папок авторов для избежания повторного создания
        author_dirs_cache = {}

        for soup, url, page_num in self.iterate_pages():
            if not soup:
                break

            # Создаём базовую папку для темы при первом проходе
            if not topic_dir_initialized:
                self.extract_topic_title(soup)
                if self.topic_title:
                    self.on_log(f"Тема: {self.topic_title}", "info")
                topic_folder = sanitize_filename(self.topic_title or 'Без названия')
                self.topic_base = Path(base_path) / 'Prodota Parser' / topic_folder
                try:
                    self.topic_base.mkdir(parents=True, exist_ok=True)
                    logger.debug("Создана папка: %s", self.topic_base)
                except Exception:
                    import tempfile
                    self.topic_base = Path(tempfile.gettempdir()) / topic_folder
                    self.topic_base.mkdir(parents=True, exist_ok=True)

                # Bug 1 fix: only create images/video at topic root when NOT filtering by author
                if not self.allowed_authors:
                    if img_sub:
                        self.img_dir = self.topic_base / img_sub
                        self.img_dir.mkdir(parents=True, exist_ok=True)
                        logger.debug("Создана папка: %s", self.img_dir)
                    if vid_sub:
                        self.vid_dir = self.topic_base / vid_sub
                        self.vid_dir.mkdir(parents=True, exist_ok=True)
                        logger.debug("Создана папка: %s", self.vid_dir)

                self.on_log(f"Папка: {self.topic_base}", "info")
                topic_dir_initialized = True

                # Create folders for all specified authors (even if no media yet)
                if self.allowed_authors:
                    for author in self.allowed_authors:
                        safe_author = sanitize_filename(author, 60)
                        author_base = self.topic_base / safe_author
                        author_base.mkdir(parents=True, exist_ok=True)
                        logger.debug("Создана папка автора: %s", author_base)
                        if img_sub:
                            (author_base / 'images').mkdir(parents=True, exist_ok=True)
                        if vid_sub:
                            (author_base / 'video').mkdir(parents=True, exist_ok=True)

            post_blocks = self.get_post_blocks(soup)
            if not post_blocks:
                self.on_log(f"Страница {page_num}: посты не найдены", "warning")

            for block in post_blocks:
                if self.stop_flag.is_set():
                    break
                pid = self.extract_post_id(block) or 'unknown'
                author = self.extract_author(block) or 'unknown'
                if not self.is_author_allowed(author):
                    continue
                media_items = self.extract_media(block, pid, author)
                total_media_found += len(media_items)

                for mtype, media_url, p_id, auth in media_items:
                    if self.stop_flag.is_set():
                        break

                    # Определяем папку назначения
                    if self.allowed_authors:
                        # С фильтром авторов — папки по авторам
                        cache_key = (auth, mtype)
                        if cache_key not in author_dirs_cache:
                            dest_dir = self._get_author_media_dir(
                                self.topic_base, auth, mtype)
                            dest_dir.mkdir(parents=True, exist_ok=True)
                            author_dirs_cache[cache_key] = dest_dir
                        dest_dir = author_dirs_cache[cache_key]
                    else:
                        # Без фильтра — общие папки
                        if mtype == 'image' and img_sub:
                            dest_dir = self.img_dir
                        elif mtype == 'video' and vid_sub:
                            dest_dir = self.vid_dir
                        else:
                            continue

                    # FIX: Новое именование файлов:
                    # Изображения: {topic}; {author}; post {pid}; картинка {N}.{ext}
                    # Видео: {topic}; {author}; post {pid}; видео {N}.{ext}
                    orig_name = (os.path.basename(urlparse(media_url).path)
                                 .split('?')[0].split('#')[0])
                    # Определяем расширение файла
                    orig_ext = os.path.splitext(orig_name)[1].lower() if orig_name else ''
                    # Для видео: URL может быть вида f.mp4.hash.mp4 — берём реальное расширение
                    if mtype == 'video':
                        # Убираем хеш из URL вида filename.mp4.HASH.mp4
                        clean_url_path = urlparse(media_url).path.split('?')[0]
                        # Проверяем паттерн с двойным расширением (prodota uploads)
                        hash_match = re.search(r'\.(mp4|webm|mov|mkv|avi)\.[a-f0-9]{8,32}\.(mp4|webm|mov|mkv|avi)$', clean_url_path, re.I)
                        if hash_match:
                            orig_ext = f'.{hash_match.group(1)}'
                        elif not orig_ext or orig_ext not in ('.mp4', '.webm', '.mov', '.mkv', '.avi'):
                            orig_ext = '.mp4'
                    else:
                        # Для изображений: тоже обрабатываем двойные расширения
                        clean_url_path = urlparse(media_url).path.split('?')[0]
                        hash_match = re.search(r'\.(jpg|jpeg|png|gif|webp|bmp)\.[a-f0-9]{8,32}\.(jpg|jpeg|png|gif|webp|bmp)$', clean_url_path, re.I)
                        if hash_match:
                            orig_ext = f'.{hash_match.group(1)}'
                        elif not orig_ext or orig_ext not in ('.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp'):
                            orig_ext = '.jpg'

                    topic_name_part = sanitize_filename(self.topic_title or 'topic', 40)
                    author_part = sanitize_filename(auth, 30)
                    
                    if mtype == 'image':
                        self.image_counter += 1
                        media_label = f"картинка {self.image_counter}"
                    else:
                        self.video_counter += 1
                        media_label = f"видео {self.video_counter}"
                    
                    fname = f"{topic_name_part}; {author_part}; post {p_id}; {media_label}{orig_ext}"
                    fname = sanitize_filename(fname, 150)
                    # FIX: sanitize_filename может убрать точки из расширения — восстанавливаем
                    fname_base, fname_ext = os.path.splitext(fname)
                    if fname_ext.lower() != orig_ext.lower():
                        fname = fname_base + orig_ext

                    dest = dest_dir / fname

                    if dest.exists():
                        continue
                    if download_file(media_url, dest, self.session, on_log=self.on_log):
                        self.downloaded += 1
                        self.metadata.append({
                            'post_id': p_id,
                            'author': auth,
                            'type': mtype,
                            'original_url': media_url,
                            'local_path': str(dest)
                        })

            effective_end = self._get_effective_end_page()
            end_str = str(effective_end) if effective_end else '?'
            prog = (f"Страница {page_num}/{end_str} | "
                    f"найдено: {total_media_found}, скачано: {self.downloaded}")
            self.on_log(prog, "progress")
            self.on_progress(page_num, effective_end)

        # Сохраняем метаданные
        if topic_dir_initialized and self.metadata:
            meta_path = self.topic_base / 'media_metadata.json'
            # Bug 6 fix: when allowed_authors is set, delete metadata after saving
            if self.allowed_authors:
                # Still save metadata temporarily for internal use, then delete
                with open(meta_path, 'w', encoding='utf-8') as f:
                    json.dump(self.metadata, f, ensure_ascii=False, indent=2)
                # Delete immediately — user doesn't need it when filtering
                try:
                    meta_path.unlink()
                    logger.debug("Удалён media_metadata.json (режим фильтра авторов)")
                except Exception:
                    pass
            else:
                with open(meta_path, 'w', encoding='utf-8') as f:
                    json.dump(self.metadata, f, ensure_ascii=False, indent=2)

        # Bug 1 fix: clean up empty images/video folders at topic root when filtering
        if self.allowed_authors and topic_dir_initialized:
            _try_remove_empty_dir(self.topic_base / 'images')
            _try_remove_empty_dir(self.topic_base / 'video')
            # Also clean up any empty media_metadata.json if it exists
            meta_path = self.topic_base / 'media_metadata.json'
            try:
                if meta_path.exists():
                    meta_path.unlink()
                    logger.debug("Удалён media_metadata.json (очистка)")
            except Exception:
                pass

        return self.downloaded


# ----------------------------------------------------------------------
# Графический интерфейс (Tkinter)
# ----------------------------------------------------------------------
MODE_OPTIONS = [
    "Текст",
    "Картинки",
    "Видео",
    "Картинки и видео",
    "Всё вместе",
]


class ParserApp:
    """Основное окно приложения."""

    def __init__(self, root):
        self.root = root
        self.root.title("PRODOTA PARSER")
        self.root.geometry("360x720")
        self.root.resizable(False, False)
        self.root.protocol("WM_DELETE_WINDOW", self.confirm_exit)

        # Переменные для хранения состояния GUI
        self.mode_var = tk.StringVar(value=MODE_OPTIONS[0])
        self.start_page_var = tk.StringVar(value="0")
        self.end_page_var = tk.StringVar(value="0")

        self.is_running = False          # выполняется ли парсинг
        self.pause_flag = None           # Event для паузы
        self.stop_flag = None            # Event для остановки
        self.parser_thread = None        # поток парсера
        self._build_ui()

    def _build_ui(self):
        """Строит все элементы интерфейса с прокруткой."""
        # Создаём холст для прокрутки
        self._canvas = tk.Canvas(self.root, highlightthickness=0)
        scrollbar = ttk.Scrollbar(self.root, orient="vertical", command=self._canvas.yview)
        self.settings_frame = ttk.Frame(self._canvas)

        self._canvas_window = self._canvas.create_window(
            (0, 0), window=self.settings_frame, anchor="nw"
        )
        self._canvas.configure(yscrollcommand=scrollbar.set)

        def _on_canvas_configure(event):
            self._canvas.itemconfig(self._canvas_window, width=event.width)

        self._canvas.bind("<Configure>", _on_canvas_configure)

        self.settings_frame.bind(
            "<Configure>",
            lambda e: self._canvas.configure(scrollregion=self._canvas.bbox("all"))
        )

        scrollbar.pack(side="right", fill="y")
        self._canvas.pack(side="left", fill="both", expand=True)

        # Прокрутка колёсиком мыши
        def _on_mousewheel(event):
            self._canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")

        def _on_touch_scroll(event):
            if event.num == 4:
                self._canvas.yview_scroll(-1, "units")
            elif event.num == 5:
                self._canvas.yview_scroll(1, "units")

        self._canvas.bind_all("<MouseWheel>", _on_mousewheel)
        self._canvas.bind_all("<Button-4>", _on_touch_scroll)
        self._canvas.bind_all("<Button-5>", _on_touch_scroll)

        sf = self.settings_frame
        PAD = 6

        # Заголовок
        ttk.Label(sf, text="PRODOTA PARSER",
                  font=('Arial', 11, 'bold'), anchor='center'
                  ).pack(fill='x', pady=(6, 2), padx=PAD)

        # 1. Ссылка на тему
        ttk.Label(sf, text="Ссылка на тему:",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))
        self.url_text = tk.Text(sf, height=2, wrap=tk.WORD,
                                font=('Arial', 8), bd=1, relief='solid')
        self.url_text.pack(fill='x', padx=PAD, pady=1)
        self.url_text.insert('1.0', Config.START_URL)

        # 2-3. Начальная и конечная страницы
        pages_frame = ttk.Frame(sf)
        pages_frame.pack(fill='x', padx=PAD, pady=2)
        pages_frame.columnconfigure(1, weight=1)
        pages_frame.columnconfigure(4, weight=1)

        ttk.Label(pages_frame, text="Стр.:",
                  font=('Arial', 9)).grid(row=0, column=0, sticky='w')
        self.start_page_entry = ttk.Entry(pages_frame, textvariable=self.start_page_var,
                                           width=6, font=('Arial', 9))
        self.start_page_entry.grid(row=0, column=1, sticky='ew', padx=(2, 4))
        ttk.Label(pages_frame, text="по",
                  font=('Arial', 9)).grid(row=0, column=2, sticky='w')
        self.end_page_entry = ttk.Entry(pages_frame, textvariable=self.end_page_var,
                                         width=6, font=('Arial', 9))
        self.end_page_entry.grid(row=0, column=3, sticky='e', padx=(4, 2))
        ttk.Label(pages_frame, text="(0=all)",
                  font=('Arial', 7)).grid(row=0, column=4, sticky='w')

        # 4. Путь сохранения
        ttk.Label(sf, text="Путь сохранения:",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))

        self.path_var = tk.StringVar(value=Config.DEFAULT_OUTPUT)
        self.path_entry = ttk.Entry(sf, textvariable=self.path_var,
                                    font=('Arial', 8))
        self.path_entry.pack(fill='x', padx=PAD, pady=(0, 2))

        ttk.Button(sf, text="📁 Выбрать папку",
                   command=self.choose_dir).pack(fill='x', padx=PAD, pady=(0, 4))

        # 5. Фильтр авторов
        ttk.Label(sf, text="Фильтр авторов (';'):",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))
        self.authors_text = tk.Text(sf, height=1, wrap=tk.WORD,
                                     font=('Arial', 8), bd=1, relief='solid')
        self.authors_text.pack(fill='x', padx=PAD, pady=1)

        # 6. Режим парсинга
        ttk.Label(sf, text="Режим:",
                  font=('Arial', 9)).pack(anchor='w', padx=PAD, pady=(4, 0))
        self.mode_combobox = ttk.Combobox(
            sf, textvariable=self.mode_var,
            values=MODE_OPTIONS, state='readonly',
            font=('Arial', 9), height=5
        )
        self.mode_combobox.pack(fill='x', padx=PAD, pady=1)

        # Кнопки управления
        btn_grid = ttk.Frame(sf)
        btn_grid.pack(fill='x', padx=PAD, pady=(6, 2))
        btn_grid.columnconfigure(0, weight=1)
        btn_grid.columnconfigure(1, weight=1)

        self.start_btn = ttk.Button(btn_grid, text="СТАРТ",
                                    command=self.start_parsing)
        self.start_btn.grid(row=0, column=0, columnspan=2, sticky='ew', pady=1)

        self.pause_btn = ttk.Button(btn_grid, text="ПАУЗА",
                                    command=self.pause_parsing, state='disabled')
        self.pause_btn.grid(row=1, column=0, sticky='ew', padx=(0, 2), pady=1)

        self.stop_btn = ttk.Button(btn_grid, text="СТОП",
                                   command=self.stop_parsing, state='disabled')
        self.stop_btn.grid(row=1, column=1, sticky='ew', padx=(2, 0), pady=1)

        ttk.Button(btn_grid, text="Очистить",
                   command=self.clear_log).grid(row=2, column=0, sticky='ew', padx=(0, 2), pady=1)
        ttk.Button(btn_grid, text="Выход",
                   command=self.confirm_exit).grid(row=2, column=1, sticky='ew', padx=(2, 0), pady=1)

        # Прогресс
        prog_frame = ttk.LabelFrame(sf, text="Прогресс")
        prog_frame.pack(fill='x', padx=PAD, pady=(4, 2))
        self.progress = ttk.Progressbar(prog_frame, orient='horizontal', mode='determinate')
        self.progress.pack(fill='x', padx=3, pady=(3, 1))
        self.status_label = ttk.Label(prog_frame, text="Готов", font=('Arial', 9))
        self.status_label.pack(anchor='w', padx=3)
        self.progress_line = ttk.Label(prog_frame, text="", font=('Arial', 8),
                                       foreground='#009999')
        self.progress_line.pack(anchor='w', padx=3, pady=(0, 3))

        # Лог
        log_frame = ttk.LabelFrame(sf, text="Лог")
        log_frame.pack(fill='both', expand=True, padx=PAD, pady=(2, 6))
        self.log_text = scrolledtext.ScrolledText(
            log_frame, wrap=tk.WORD, font=('Arial', 8),
            height=10
        )
        self.log_text.pack(fill='both', expand=True)
        # Теги для цветного вывода
        self.log_text.tag_config('info', foreground='#0066CC')
        self.log_text.tag_config('success', foreground='#008800')
        self.log_text.tag_config('error', foreground='#CC0000')
        self.log_text.tag_config('warning', foreground='#CC6600')
        self.log_text.tag_config('debug', foreground='#666666')
        self.log_text.tag_config('progress', foreground='#6600CC')

    def choose_dir(self):
        """Открывает диалог выбора папки."""
        initial = self.path_var.get().strip()
        if not initial or not os.path.isdir(initial):
            initial = Config.DEFAULT_OUTPUT
        d = filedialog.askdirectory(
            title="Выберите папку для сохранения",
            initialdir=initial
        )
        if d:
            self.path_var.set(d)

    def _get_url(self):
        return self.url_text.get('1.0', tk.END).strip()

    def _get_path(self):
        return self.path_var.get().strip()

    def _get_authors(self):
        return self.authors_text.get('1.0', tk.END).strip()

    def _get_selected_mode_index(self):
        val = self.mode_var.get()
        for i, opt in enumerate(MODE_OPTIONS):
            if opt == val:
                return i
        return 0

    def clear_log(self):
        """Очищает текстовое поле лога."""
        self.log_text.delete(1.0, tk.END)

    def log(self, msg, level='info'):
        """Добавляет сообщение в лог с временной меткой и цветом."""
        tag = level.lower()
        if tag not in ('info', 'success', 'error', 'warning', 'debug', 'progress'):
            tag = 'info'
        ts = time.strftime('%H:%M:%S')
        icons = {'info': '[i]', 'success': '[+]', 'error': '[!]', 'warning': '[~]', 'debug': '[.]', 'progress': '[>]'}
        icon = icons.get(tag, '[i]')
        formatted = f"{ts} {icon} {msg}\n"
        self.root.after(0, lambda: self._safe_log_insert(formatted, tag))

    def _safe_log_insert(self, msg, tag):
        """Безопасная вставка в лог (вызывается из главного потока)."""
        try:
            self.log_text.insert(tk.END, msg, tag)
            self.log_text.see(tk.END)
        except tk.TclError:
            pass

    def update_progress_line(self, text):
        """Обновляет текстовую строку прогресса."""
        self.root.after(0, lambda: self._safe_progress_line(text))

    def _safe_progress_line(self, text):
        try:
            self.progress_line.config(text=text)
        except tk.TclError:
            pass

    def update_progress_bar(self, current, total):
        """Обновляет индикатор прогресса (текущая страница / всего)."""
        self.root.after(0, lambda: self._safe_progress_bar(current, total))

    def _safe_progress_bar(self, current, total):
        try:
            if total and total > 0:
                self.progress['value'] = (current / total) * 100
                self.status_label.config(text=f"Страница {current}/{total}")
            else:
                self.progress['value'] = 0
                self.status_label.config(text=f"Страница {current}")
        except tk.TclError:
            pass

    def start_parsing(self):
        """Обработчик кнопки СТАРТ. Запускает парсинг в отдельном потоке."""
        if self.is_running:
            self.log("Парсинг уже запущен", "warning")
            return

        url = self._get_url()
        if not url.startswith('http'):
            self.log("Неверная ссылка", "error")
            return

        path = self._get_path()
        if not path:
            self.log("Укажите путь сохранения", "error")
            return

        base_path = Path(path)
        if not base_path.exists():
            try:
                base_path.mkdir(parents=True, exist_ok=True)
            except Exception as e:
                self.log(f"Не удалось создать папку: {e}", "error")
                return

        try:
            start_page = int(self.start_page_var.get().strip() or "0")
        except ValueError:
            start_page = 0
        if start_page < 0:
            start_page = 0

        try:
            end_page = int(self.end_page_var.get().strip() or "0")
        except ValueError:
            end_page = 0
        if end_page < 0:
            end_page = 0

        authors_str = self._get_authors()
        allowed = None
        if authors_str:
            allowed = {n.strip() for n in authors_str.split(';') if n.strip()}
            self.log(f"Фильтр авторов: {', '.join(sorted(allowed))}", "info")

        self.stop_flag = threading.Event()
        self.pause_flag = threading.Event()
        self.is_running = True
        self.start_btn.config(state='disabled')
        self.pause_btn.config(state='normal')
        self.stop_btn.config(state='normal')
        self.progress['value'] = 0
        self.status_label.config(text="Запуск...")
        self.progress_line.config(text="")
        self.log("Парсинг запущен", "info")

        if start_page <= 1:
            self.log("Начальная страница: автоматически первая", "info")
        else:
            self.log(f"Начальная страница: {start_page}", "info")
        if end_page > 0:
            self.log(f"Конечная страница: {end_page}", "info")
        else:
            self.log("Конечная страница: до последней", "info")

        mode_idx = self._get_selected_mode_index()

        if mode_idx == 0:
            self._run_text_parser(url, str(base_path), start_page, end_page, allowed)
        elif mode_idx == 1:
            self._run_media_parser(url, str(base_path), start_page, end_page, 'images', allowed)
        elif mode_idx == 2:
            self._run_media_parser(url, str(base_path), start_page, end_page, 'videos', allowed)
        elif mode_idx == 3:
            self._run_media_parser(url, str(base_path), start_page, end_page, 'both', allowed)
        elif mode_idx == 4:
            self._run_combined_parser(url, str(base_path), start_page, end_page, allowed)

    def _run_text_parser(self, url, base_path, start_page, end_page, allowed):
        """Запускает текстовый парсер в отдельном потоке."""
        def target():
            try:
                parser = TextParser(url, self.log, self.update_progress_bar,
                                    self.stop_flag, self.pause_flag, allowed,
                                    start_page=start_page, end_page=end_page)
                # Перехватываем сообщения прогресса, чтобы выводить их в отдельную строку
                orig_log = parser.on_log

                def progress_log(msg, level):
                    if level == 'progress':
                        self.update_progress_line(msg)
                    else:
                        orig_log(msg, level)

                parser.on_log = progress_log
                posts = parser.run(base_path)
                if posts and not self.stop_flag.is_set():
                    saved = parser.save_all()
                    self.log(f"Сохранено {len(posts)} постов в {len(saved)} файл(ов)", "success")
                    for s in saved:
                        self.log(f"  -> {s}", "info")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                else:
                    self.log("Посты не найдены", "warning")
            except Exception as e:
                self.log(f"Ошибка: {type(e).__name__}: {e}", "error")
                logger.error("Текст-парсер: %s\n%s", e, traceback.format_exc())
            finally:
                self.root.after(0, self._finish)

        self.parser_thread = threading.Thread(target=target, daemon=True)
        self.parser_thread.start()

    def _run_media_parser(self, url, base_path, start_page, end_page, media_type, allowed):
        """Запускает медиапарсер в отдельном потоке."""
        def target():
            try:
                parser = MediaParser(url, self.log, self.update_progress_bar,
                                     self.stop_flag, self.pause_flag, allowed,
                                     start_page=start_page, end_page=end_page,
                                     media_type=media_type)
                orig_log = parser.on_log

                def progress_log(msg, level):
                    if level == 'progress':
                        self.update_progress_line(msg)
                    else:
                        orig_log(msg, level)

                parser.on_log = progress_log
                count = parser.run(base_path)
                if count:
                    self.log(f"Скачано {count} файлов (отфильтровано: {parser.filtered_out})", "success")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                else:
                    self.log(f"Медиа не найдено (отфильтровано: {parser.filtered_out})", "warning")
            except Exception as e:
                self.log(f"Ошибка: {type(e).__name__}: {e}", "error")
                logger.error("Медиа-парсер: %s\n%s", e, traceback.format_exc())
            finally:
                self.root.after(0, self._finish)

        self.parser_thread = threading.Thread(target=target, daemon=True)
        self.parser_thread.start()

    def _run_combined_parser(self, url, base_path, start_page, end_page, allowed):
        """Запускает комбинированный парсинг (текст + медиа) в отдельном потоке."""
        def target():
            try:
                self.log("=== ЭТАП 1: Извлечение текста ===", "info")
                text_parser = TextParser(url, self.log, self.update_progress_bar,
                                         self.stop_flag, self.pause_flag, allowed,
                                         start_page=start_page, end_page=end_page)
                orig_log_text = text_parser.on_log

                def progress_log_text(msg, level):
                    if level == 'progress':
                        self.update_progress_line(f"[Текст] {msg}")
                    else:
                        orig_log_text(msg, level)

                text_parser.on_log = progress_log_text
                posts = text_parser.run(base_path)
                if posts and not self.stop_flag.is_set():
                    saved = text_parser.save_all()
                    self.log(f"[Текст] Сохранено {len(posts)} постов в {len(saved)} файл(ов)", "success")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                    return
                else:
                    self.log("[Текст] Посты не найдены", "warning")

                if self.stop_flag.is_set():
                    return

                self.update_progress_bar(0, 0)
                self.update_progress_line("")

                self.log("=== ЭТАП 2: Извлечение медиа ===", "info")
                media_parser = MediaParser(url, self.log, self.update_progress_bar,
                                           self.stop_flag, self.pause_flag, allowed,
                                           start_page=start_page, end_page=end_page,
                                           media_type='both')
                orig_log_media = media_parser.on_log

                def progress_log_media(msg, level):
                    if level == 'progress':
                        self.update_progress_line(f"[Медиа] {msg}")
                    else:
                        orig_log_media(msg, level)

                media_parser.on_log = progress_log_media
                count = media_parser.run(base_path)
                if count:
                    self.log(f"[Медиа] Скачано {count} файлов", "success")
                elif self.stop_flag.is_set():
                    self.log("Парсинг прерван пользователем", "warning")
                else:
                    self.log("[Медиа] Медиа не найдено", "warning")

                self.log("=== КОМБИНИРОВАННЫЙ ПАРСИНГ ЗАВЕРШЁН ===", "success")
                self.log(f"Итого: {len(posts)} постов + {count} медиафайлов", "success")

            except Exception as e:
                self.log(f"Ошибка: {type(e).__name__}: {e}", "error")
                logger.error("Комбинированный парсер: %s\n%s", e, traceback.format_exc())
            finally:
                self.root.after(0, self._finish)

        self.parser_thread = threading.Thread(target=target, daemon=True)
        self.parser_thread.start()

    def _finish(self):
        """Завершает парсинг, восстанавливает кнопки."""
        if not self.is_running:
            return
        self.is_running = False
        self.start_btn.config(state='normal')
        self.pause_btn.config(state='disabled', text="ПАУЗА")
        self.stop_btn.config(state='disabled')
        self.progress['value'] = 0
        self.status_label.config(text="Готов")
        self.progress_line.config(text="")
        self.log("Парсинг завершён", "info")

    def pause_parsing(self):
        """Ставит парсинг на паузу."""
        if self.is_running and self.pause_flag:
            self.pause_flag.set()
            self.pause_btn.config(text="ВОЗОБНОВИТЬ", command=self.resume_parsing)
            self.log("Пауза", "warning")
            self.update_progress_line("|| Пауза")

    def resume_parsing(self):
        """Возобновляет парсинг после паузы."""
        if self.is_running and self.pause_flag:
            self.pause_flag.clear()
            self.pause_btn.config(text="ПАУЗА", command=self.pause_parsing)
            self.log("Возобновление", "info")
            self.update_progress_line("> Возобновление")

    def stop_parsing(self):
        """Останавливает парсинг."""
        if self.is_running and self.stop_flag:
            self.stop_flag.set()
            if self.pause_flag:
                self.pause_flag.clear()
            self.log("Остановка...", "warning")
            self.update_progress_line("X Остановка...")
            self.root.after(100, self._check_stop)

    def _check_stop(self):
        """Периодически проверяет, завершился ли поток парсера."""
        if self.parser_thread and self.parser_thread.is_alive():
            self.root.after(100, self._check_stop)
        else:
            self._finish()

    def confirm_exit(self):
        """Подтверждение выхода, если парсинг активен."""
        if self.is_running:
            if messagebox.askokcancel("Выход", "Парсинг ещё выполняется. Прервать и выйти?"):
                self.stop_parsing()
                self.root.after(1500, self.root.destroy)
        else:
            self.root.destroy()


# ----------------------------------------------------------------------
# Точка входа
# ----------------------------------------------------------------------
if __name__ == '__main__':
    def main():
        try:
            root = tk.Tk()
            app = ParserApp(root)
            root.mainloop()
        except Exception as e:
            crash_msg = traceback.format_exc()
            try:
                with open(_CRASH_LOG, 'a', encoding='utf-8') as f:
                    f.write(f"\n{'='*70}\n{time.strftime('%Y-%m-%d %H:%M:%S')}\n{crash_msg}")
            except Exception:
                pass
            try:
                root = tk.Tk()
                root.withdraw()
                messagebox.showerror("Критическая ошибка", f"Программа завершилась с ошибкой:\n\n{e}\n\nПодробности в файле:\n{_CRASH_LOG}")
                root.destroy()
            except Exception:
                print(f"CRITICAL ERROR: {e}\n{crash_msg}", file=sys.stderr)

    main()

 

 

PD22.py


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

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

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

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


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

Отличный пример, как человек, который ни бум бум в программировании будет говнокодить

 

всё что требовалось, это вырезать нахуй из поста теги верхнего уровня цитат и готово

 

а тут :lol:

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

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

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


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

Отличный пример, как человек, который ни бум бум в программировании будет говнокодить

 

всё что требовалось, это вырезать нахуй из поста теги верхнего уровня цитат и готово

 

а тут :lol:

Могу парировать тем, что кодил не я, а GLM 5 turbo. 🤣🤣🤣


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

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

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


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

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

 

Мне было интересно посмотреть за процессом, чтобы понять чем вайбкодинг на деле хуже реальной разработки. В итоге чел наплодил легаси в новой проге:trollface:. Вместо нормального рефакторинга просто патчил свою залупу до победного, где победа = результат. Ну понятно. Куча спорных тяжелых решений, которые особо и не нужны, да. Один файл на весь проект отдельную у меня вызвал истерику) 

 

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

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


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

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

 

Мне было интересно посмотреть за процессом, чтобы понять чем вайбкодинг на деле хуже реальной разработки. В итоге чел наплодил легаси в новой проге:trollface:. Вместо нормального рефакторинга просто патчил свою залупу до победного, где победа = результат. Ну понятно. Куча спорных тяжелых решений, которые особо и не нужны, да. Один файл на весь проект отдельную у меня вызвал истерику) 

 

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

А то, что я со смартфона лёжа на диване вайбкодил это тебе похуй да? 🤣 Нахуй ты про один файл говоришь? Я и так заебался промпты печатать, а представь, я бы возился с разбивкой на модули. Я бы ебнулся нахуй. 😄😄😄


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

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

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

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


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

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

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

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

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

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

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

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

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

Загрузка...

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