Мои запросы растут, задачи усложняются. Сегодня впервые чуть не ударил монитор, видимо из-за того, что не было пива.
Расскажу в процессе.
Начнем с того, что любимый мной tkinter никак не будет дружить с русской раскладкой, ввиду этого горячие клавиши работали только на en раскладке.
Пришлось переписывать весь GUI на PySide, спасибо Chat GPT, 80-90% всего кода любезно написал за меня.
Выяснил, что сайты с картинками не любят когда их парсят.
Начнем жеж!
Обновление, скажем так, неудобное. Весь смысл открытого кода(в моем понимании) начинает терятся, но улучшается удобство.
Появилось 3 варианта запуска: очень сложный, неудобный и простой.
Оформление:


Новая кладка "Фото"

1. Раздел ссылок.
Можно вставлять ссылки на галерею и уникальную, работает с ссылками типа галерии и отдельно взятыми.
Работает исключительно с postimg, это не моя прихоть.
Gekkk не понравилось, что я пытаюсь забирать картинки программой и он послал меня на йух, Логику обнаружения картинок, вывод исходников и проверку я написал используя selenium. Но не фортануло, даже с открытым браузером - бан.
2. "Анализ" - начинает анализ предоставленных ссылок.
3. "Скачать все" - после анализа, позвовляет скачать все картинки из представленных ссылок, при скачивании автоматически переименовывает их по порядку, 1, 2, 3, 4, n.
4. "Сравнить" - локально проверяет все выбранные вами фото, которые уже сохранены у вас в системе.
5. "Порог хэша" - эта настройка регулирует чувствительность к дубликатам, если они слегка обрезаны или изменены.
6. "Результат" - обычное окно вывода информации, сбрасывается после начало каждого действия
Показываю на картинках

Тут мы видим как действует анализ при вложенный ссылке на галерею postimg

Тоже самое, только с прямыми ссылками

Результат работы локальной проверки
Переходим к интересному
Исходный код:
#!/usr/bin/env python3
import sys
import os
import re
import hashlib
import threading
import requests
from io import BytesIO
from urllib.parse import urljoin
from PIL import Image
import imagehash
from bs4 import BeautifulSoup
from geopy.distance import geodesic
from PySide6.QtWidgets import (
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
QLabel, QLineEdit, QPushButton, QListWidget, QTextEdit, QFileDialog,
QDoubleSpinBox, QSpinBox, QMessageBox, QDialog, QTabWidget, QProgressBar
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QTextCursor
from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
HOME = os.path.expanduser("~")
# === MapDialog ===
class MapDialog(QDialog):
def __init__(self, parent, coords, distances, threshold):
super().__init__(parent)
self.setWindowTitle("Карта координат")
self.resize(500, 400)
self.coords = coords
self.distances = distances
self.threshold = threshold
self._pan_active = False
self._orig_xlim = None
self._orig_ylim = None
self._press_xy = None
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
fig = Figure()
self.ax = fig.add_subplot(111)
self.ax.set_xlabel("Широта")
self.ax.set_ylabel("Долгота")
self.canvas = FigureCanvas(fig)
layout.addWidget(self.canvas)
self.canvas.mpl_connect("button_press_event", self._on_press)
self.canvas.mpl_connect("button_release_event", self._on_release)
self.canvas.mpl_connect("motion_notify_event", self._on_motion)
self.canvas.mpl_connect("scroll_event", self._on_scroll)
self._draw()
def _draw(self):
ax = self.ax
ax.clear()
thr = self.threshold.value()
reds = {i for (i, j), d in self.distances.items() if d < thr} | {
j for (i, j), d in self.distances.items() if d < thr
}
for idx, (lat, lon) in enumerate(self.coords, 1):
color = "red" if (idx - 1) in reds else "blue"
ax.plot(lon, lat, "o", color=color)
ax.text(lon, lat, str(idx), ha="center", va="center")
for (i, j), d in self.distances.items():
if d < thr:
xs = [self.coords[i][1], self.coords[j][1]]
ys = [self.coords[i][0], self.coords[j][0]]
ax.plot(xs, ys, "-", color="red")
ax.text((xs[0] + xs[1]) / 2, (ys[0] + ys[1]) / 2, f"{d:.2f} м")
if self.coords:
lats, lons = zip(*self.coords)
pad_lat = max((max(lats) - min(lats)) * 0.1, 0.01)
pad_lon = max((max(lons) - min(lons)) * 0.1, 0.01)
ax.set_xlim(min(lons) - pad_lon, max(lons) + pad_lon)
ax.set_ylim(min(lats) - pad_lat, max(lats) + pad_lat)
self._orig_xlim = ax.get_xlim()
self._orig_ylim = ax.get_ylim()
self.canvas.draw_idle()
def _on_press(self, event):
if event.button == 1 and event.xdata is not None:
self._pan_active = True
self._orig_xlim = self.ax.get_xlim()
self._orig_ylim = self.ax.get_ylim()
self._press_xy = (event.x, event.y)
def _on_release(self, event):
self._pan_active = False
self._press_xy = None
def _on_motion(self, event):
if not self._pan_active or event.x is None or event.y is None:
return
dx_pix = event.x - self._press_xy[0]
dy_pix = event.y - self._press_xy[1]
w_pix, h_pix = self.canvas.get_width_height()
x0, x1 = self._orig_xlim
y0, y1 = self._orig_ylim
dx_data = -dx_pix * (x1 - x0) / w_pix
dy_data = -dy_pix * (y1 - y0) / h_pix
self.ax.set_xlim(x0 + dx_data, x1 + dx_data)
self.ax.set_ylim(y0 + dy_data, y1 + dy_data)
self.canvas.draw_idle()
def _on_scroll(self, event):
if event.xdata is None or event.ydata is None:
return
base_scale = 1.2
scale = 1 / base_scale if event.button == "up" else base_scale
xdata, ydata = event.xdata, event.ydata
x0, x1 = self.ax.get_xlim()
y0, y1 = self.ax.get_ylim()
new_w = (x1 - x0) * scale
new_h = (y1 - y0) * scale
relx = (xdata - x0) / (x1 - x0)
rely = (ydata - y0) / (y1 - y0)
self.ax.set_xlim(xdata - relx * new_w, xdata + (1 - relx) * new_w)
self.ax.set_ylim(ydata - rely * new_h, ydata + (1 - rely) * new_h)
self._orig_xlim = self.ax.get_xlim()
self._orig_ylim = self.ax.get_ylim()
if self._pan_active:
self._press_xy = (event.x, event.y)
self.canvas.draw_idle()
# === CoordinateHelperTab ===
class CoordinateHelperTab(QWidget):
def __init__(self):
super().__init__()
self.coords = []
self.distances = {}
self._init_ui()
def _init_ui(self):
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Координаты (шир, долг):"))
self.entry = QLineEdit()
self.entry.returnPressed.connect(self._add)
layout.addWidget(self.entry)
btns = QHBoxLayout()
imp = QPushButton("Импорт координат .txt")
imp.clicked.connect(self._import)
btns.addWidget(imp)
layout.addLayout(btns)
thr = QHBoxLayout()
thr.addWidget(QLabel("Порог (м):"))
self.threshold = QDoubleSpinBox()
self.threshold.setRange(1, 100)
self.threshold.setValue(25)
thr.addWidget(self.threshold)
layout.addLayout(thr)
self.lst = QListWidget()
self.lst.setSelectionMode(QListWidget.ExtendedSelection)
layout.addWidget(self.lst)
self.lst.keyPressEvent = self._list_key_event
bot = QHBoxLayout()
mbtn = QPushButton("Открыть карту")
mbtn.clicked.connect(self._show_map)
bot.addWidget(mbtn)
ebtn = QPushButton("Экспорт GPX")
ebtn.clicked.connect(self._export)
bot.addWidget(ebtn)
layout.addLayout(bot)
def _add(self):
vals = re.findall(r"[-+]?\d*\.\d+|\d+", self.entry.text())
if len(vals) >= 2:
self.coords.append((float(vals[0]), float(vals[1])))
self.entry.clear()
self._refresh()
else:
QMessageBox.critical(self, "Ошибка", "Неверный формат")
def _import(self):
self.coords.clear()
fn, _ = QFileDialog.getOpenFileName(self, "Файл", HOME, "*.txt")
if fn:
with open(fn, encoding="utf-8") as f:
for ln in f:
p = re.findall(r"[-+]?\d*\.\d+|\d+", ln)
if len(p) >= 2:
self.coords.append((float(p[0]), float(p[1])))
self._refresh()
def _list_key_event(self, event):
if event.key() in (Qt.Key_Delete, Qt.Key_Backspace):
for it in self.lst.selectedItems():
del self.coords[self.lst.row(it)]
self._refresh()
else:
super(QListWidget, self.lst).keyPressEvent(event)
def _refresh(self):
self.lst.clear()
self.distances.clear()
for i in range(len(self.coords)):
for j in range(i + 1, len(self.coords)):
d = geodesic(self.coords[i], self.coords[j]).m
self.distances[(i, j)] = d
for idx, (la, lo) in enumerate(self.coords, 1):
self.lst.addItem(f"{idx}. {la}, {lo}")
def _show_map(self):
if self.coords:
dlg = MapDialog(self, self.coords, self.distances, self.threshold)
dlg.exec()
def _export(self):
fn, _ = QFileDialog.getSaveFileName(self, "GPX", HOME, "*.gpx")
if fn:
with open(fn, "w", encoding="utf-8") as f:
f.write('<?xml version="1.0"?><gpx>')
for i, (la, lo) in enumerate(self.coords, 1):
f.write(f'<wpt lat="{la}" lon="{lo}"><name>#{i}</name></wpt>')
f.write("</gpx>")
# === PostimgParserTab ===
class PostimgParserTab(QWidget):
append_signal = Signal(str)
update_hash_status = Signal(int, int)
update_download_status = Signal(int, int)
def __init__(self):
super().__init__()
self.all_images = []
self.direct_map = {}
self.hashes = {}
self._init_ui()
self.append_signal.connect(self._append_output)
self.update_hash_status.connect(self._show_hash_checked)
self.update_download_status.connect(self._show_download)
def _init_ui(self):
layout = QVBoxLayout(self)
layout.addWidget(QLabel("Postimg.cc ссылки:"))
self.input_text = QTextEdit()
self.input_text.setFixedHeight(60)
layout.addWidget(self.input_text)
# Первая группа кнопок: Анализ и Скачать все
btns1 = QHBoxLayout()
self.parse_btn = QPushButton("🔍 Анализ")
self.parse_btn.clicked.connect(self._on_parse)
btns1.addWidget(self.parse_btn)
self.download_btn = QPushButton("💾 Скачать все")
self.download_btn.clicked.connect(self._on_download_all)
self.download_btn.setEnabled(False)
btns1.addWidget(self.download_btn)
layout.addLayout(btns1)
# Вторая группа кнопок: Импорт ссылок и Сравнить
btns2 = QHBoxLayout()
imp = QPushButton("📂 Импорт ссылок")
imp.clicked.connect(self._on_import)
btns2.addWidget(imp)
cmp = QPushButton("📂 Сравнить")
cmp.clicked.connect(self._on_compare_photos)
btns2.addWidget(cmp)
layout.addLayout(btns2)
# Остальные элементы
hthr = QHBoxLayout()
hthr.addWidget(QLabel("Порог хеша (бит):"))
self.hash_threshold = QSpinBox()
self.hash_threshold.setRange(0, 64)
self.hash_threshold.setValue(5)
hthr.addWidget(self.hash_threshold)
layout.addLayout(hthr)
self.progress = QProgressBar()
self.progress.setRange(0, 100)
self.progress.setValue(0)
layout.addWidget(self.progress)
layout.addWidget(QLabel("Результат:"))
self.output = QTextEdit()
self.output.setReadOnly(True)
layout.addWidget(self.output)
def _append_output(self, text):
self.output.append(text)
def _show_hash_checked(self, count, total):
self._status("Хэш проверен", f"{count}/{total}")
self.progress.setValue(int(count / total * 100))
def _show_download(self, done, total):
self._status("Скачано", f"{done}/{total}")
def _status(self, prefix, text):
doc = self.output.document()
for ln in range(doc.blockCount()):
block = doc.findBlockByLineNumber(ln)
if block.text().startswith(prefix):
cursor = self.output.textCursor()
cursor.setPosition(block.position())
cursor.select(QTextCursor.LineUnderCursor)
cursor.removeSelectedText()
cursor.insertText(f"{prefix} {text}")
return
self.output.append(f"{prefix} {text}")
def _on_import(self):
self.output.clear()
fn, _ = QFileDialog.getOpenFileName(self, "TXT", HOME, "*.txt")
if fn:
with open(fn, "r", encoding="utf-8") as f:
self.input_text.setPlainText(f.read())
def _on_parse(self):
self.output.clear()
self.download_btn.setEnabled(False)
self.progress.setValue(0)
threading.Thread(target=self._parse_and_check, daemon=True).start()
def _parse_and_check(self):
sess = requests.Session()
sess.headers.update({"User-Agent": "Mozilla/5.0"})
urls = [u.strip() for u in self.input_text.toPlainText().splitlines() if u.strip()]
if not urls:
self.append_signal.emit("Введите ссылку")
return
self.all_images.clear()
self.direct_map.clear()
self.hashes.clear()
self.append_signal.emit("=== Анализ дубликатов — поиск исходных ссылок ===")
for url in urls:
if not url.startswith("https://postimg.cc/"):
self.append_signal.emit(f"Пропущено: {url}")
continue
try:
r = sess.get(url, timeout=10)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
pages = ([urljoin(r.url, a["href"]) for a in soup.select("div#thumb-list a.img[href]")]
if "gallery" in url else [url])
for page in pages:
r2 = sess.get(page, timeout=10)
r2.raise_for_status()
s2 = BeautifulSoup(r2.text, "html.parser")
img = s2.find("img", id="main-image") or next(
(t for t in s2.find_all("img", src=True) if "i.postimg.cc" in t["src"]), None)
if img:
link = urljoin(r2.url, img["src"])
self.direct_map[link] = page
self.all_images.append(link)
except Exception:
self.append_signal.emit(f"Ошибка парсинга: {url}")
total = len(self.all_images)
if total == 0:
self.append_signal.emit("Ничего не найдено.")
return
self.append_signal.emit("\n=== Исходные ссылки ===")
for idx, link in enumerate(self.all_images, 1):
self.append_signal.emit(f"{idx}. {self.direct_map[link]}")
for idx, img_url in enumerate(self.all_images, 1):
try:
data = sess.get(img_url, timeout=10).content
h = imagehash.average_hash(Image.open(BytesIO(data)))
self.hashes[img_url] = h
self.update_hash_status.emit(idx, total)
except Exception:
self.append_signal.emit(f"Ошибка при получении хеша: {img_url}")
thr = self.hash_threshold.value()
dup_count = 0
visited = set()
self.append_signal.emit("\n=== Дубликаты (hamming <= " + str(thr) + ") ===")
for i in range(total):
for j in range(i + 1, total):
u1, u2 = self.all_images[i], self.all_images[j]
h1, h2 = self.hashes.get(u1), self.hashes.get(u2)
if h1 is not None and h2 is not None and (h1 - h2) <= thr and (u1, u2) not in visited:
dup_count += 1
self.append_signal.emit(f"{dup_count}. Дубликаты:")
self.append_signal.emit(f" - {self.direct_map[u1]}")
self.append_signal.emit(f" - {self.direct_map[u2]}")
self.append_signal.emit("")
visited.add((u1, u2))
if dup_count == 0:
self.append_signal.emit("Совпадений не найдено.")
self.append_signal.emit("=== Анализ завершен ===")
self.download_btn.setEnabled(True)
def _on_download_all(self):
self.output.clear()
folder = QFileDialog.getExistingDirectory(self, "Папка")
if not folder:
return
total = len(self.all_images)
for idx, url in enumerate(self.all_images, 1):
try:
data = requests.get(url, timeout=15).content
ext = url.split('.')[-1].split('?')[0][:4]
fname = os.path.join(folder, f"{idx}.{ext}")
with open(fname, "wb") as f:
f.write(data)
self.update_download_status.emit(idx, total)
except Exception as e:
self.append_signal.emit(f"Ошибка скачивания: {url}\n{e}")
def _on_compare_photos(self):
self.output.clear()
fns, _ = QFileDialog.getOpenFileNames(self, "Выберите фото", HOME, "Images (*.jpg *.png)")
if len(fns) < 2:
QMessageBox.information(self, "Внимание", "Нужно минимум 2 фото")
return
self.append_signal.emit("\n=== Сравнение фото ===")
groups = {}
for p in fns:
try:
key = hashlib.md5(open(p, "rb").read()).hexdigest()
groups.setdefault(key, []).append(os.path.basename(p))
except Exception:
pass
dup_count = 0
for dup in groups.values():
if len(dup) > 1:
dup_count += 1
self.append_signal.emit(f"{dup_count}. Найдены совпадения:")
for name in dup:
self.append_signal.emit(f" - {name}")
self.append_signal.emit("")
if dup_count == 0:
self.append_signal.emit("Совпадений не найдено.")
# === MainWindow ===
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("EVSEGN Cord Helper")
self.resize(333, 400)
tabs = QTabWidget()
tabs.addTab(CoordinateHelperTab(), "Координаты")
tabs.addTab(PostimgParserTab(), "Фото")
self.setCentralWidget(tabs)
if __name__ == "__main__":
app = QApplication(sys.argv)
win = MainWindow()
win.show()
sys.exit(app.exec())
Что же нам понадобится для запуска этого чуда? Как и обещал 3 варианта:
Установка:
1. Сложный
Я использвал Debian, в Tails не пробовал, возможны ошибки
1. Устанавливаем необходимые пакеты:
test@debian:~/Downloads$ sudo apt install \
python3 \
python3-venv \
python3-pip \
build-essential \
libxcb-cursor0 \
libxcb-xinerama0 \
libxkbcommon-x11-0
2. Сохраняем скрипт
Копируем полностью исходный код в текстовый редактор
Сохраняем файл с названием cordhelper.py
в удобный для вас раздел (я сохранил в Downloads) (проверьте название файла, так cordhelper.py.txt
НЕ правильно)
3. Подготовка виртуального окружения
Переходим в папку где у вас сохранен скрипт
test@debian:~$ cd ~/Downloads
Создайте виртуальное окружение и активируйте его:
test@debian:~/Downloads$ python3 -m venv venv
test@debian:~/Downloads$ source venv/bin/activate
После этого в приглашении терминала вы увидите префикс (venv).
(venv) test@debian:~/Downloads$
В активированном venv выполните:
(venv) test@debian:~/Downloads$ pip install --upgrade pip
pip install
PySide6
pillow
imagehash
beautifulsoup4
requests
geopy
matplotlib
Сделайте его исполняемым:
(venv) test@debian:~/Downloads$ chmod +x cordhelper.py
(venv) test@debian:~/Downloads$ ./cordhelper.py
7. Установите PyInstaller: (делаем бинарник)
(venv) test@debian:~/Downloads$ pip install pyinstaller
Собираем:
(venv) test@debian:~/Downloads$ pyinstaller
--onefile
--name cordhelper
--windowed
cordhelper.py
Готовый бинарник будет в dist/cordhelper.
Скопируйте в /usr/local/bin для удобства:
(venv) test@debian:~/Downloads$ sudo cp dist/cordhelper /usr/local/bin/cordhelper
(venv) test@debian:~/Downloads$ sudo chmod +x /usr/local/bin/cordhelper
Запуск теперь будет через команду
test@debian:~$ cordhelper
2. Неудобный
1. Устанавливаем необходимые пакеты:
test@debian:~/Downloads$ sudo apt install \
python3 \
python3-venv \
python3-pip \
build-essential \
libxcb-cursor0 \
libxcb-xinerama0 \
libxkbcommon-x11-0
2. Сохраняем скрипт
Копируем полностью исходный код в текстовый редактор
Сохраняем файл с названием cordhelper.py
в удобный для вас раздел (я сохранил в Downloads) (проверьте название файла, так cordhelper.py.txt
НЕ правильно)
3. Подготовка виртуального окружения
Переходим в папку где у вас сохранен скрипт
test@debian:~$ cd ~/Downloads
Создайте виртуальное окружение и активируйте его:
test@debian:~/Downloads$ python3 -m venv venv
test@debian:~/Downloads$ source venv/bin/activate
После этого в приглашении терминала вы увидите префикс (venv).
(venv) test@debian:~/Downloads$
В активированном venv выполните:
(venv) test@debian:~/Downloads$ pip install --upgrade pip
pip install
PySide6
pillow
imagehash
beautifulsoup4
requests
geopy
matplotlib
Сделайте его исполняемым:
(venv) test@debian:~/Downloads$ chmod +x cordhelper.py
(venv) test@debian:~/Downloads$ ./cordhelper.py
5. Запуск скрипта напрямую (без доступа к Интернету)
(venv) test@debian:~/Downloads$ python3 cordhelper.py
6. Запуск через Tor (для доступа в Интернет в Tails)
Вариант A: через torsocks
(venv) test@debian:~/Downloads$ torsocks ./cordhelper.py
Вариант B: через переменные окружения SOCKS5
(venv) test@debian:~/Downloads$ export HTTP_PROXY="socks5h://127.0.0.1:9050"
(venv) test@debian:~/Downloads$ export HTTPS_PROXY="socks5h://127.0.0.1:9050"
(venv) test@debian:~/Downloads$ python3 cordhelper.py
Важно: не запускайте под sudo, иначе переменные окружения и torsocks не подхватятся.
3. Легкий
Скачаваем бинарник
Скачать
VT ссылка
VT бинарник
Перемещаем его в раздел Persistent
amnesia@amnesia:~$ mv ~/Downloads/cordhelper ~/Persistent
Делаем исполняемым
amnesia@amnesia:~$~/Persistent$ sudo chmod +x ~/Persistent/cordhelper
4.
Запуск теперь будет через команду
amnesia@amnesia:~$ cd ~/Persistent && torsocks ./cordhelper
ИЛИ
Двойной клик ЛКМ на файле (бинарнике) (доступа к интернету не будет)
P.S. я ни на что не претендую, сделал и поделился, возможно кто-то будет использовать. Я не програмист и не претендую на звание.
Спасибо за внимание.
Уважаемый @Atos может создать отдельную ветку для скрипта? Для привлечения внимания к нему😇