Всем здравствуйте.
Заинтересовался решением @evsegn (в честь неё и название).
За основу была взята модификация от @Atos
Кому интересен апдейт путь - читаем и листаем вниз тут
Решил что скрипт имеет место быть и в целом относительно продолжительное время я ее смогу поддерживать. В всяком случае пока у меня есть к этому жгучий (уже в пятой точке) интерес. Спасибо Chat GPT!
Начну с представления, функционала и описания функций.
1) Интерфейс
Карта координат и основной интерфейс программы

2) Проверка изображений

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

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

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

Результат работы локальной проверки
4) Карты и координаты
1. Открываем график и сохраняем локальную ссылку

2. Открываем HTML файл в TOR браузере. Точки, линейка берутся из программы, подгрузка карт через браузер.

ввиду закрытости Tails, сделать карту внутри программы - невозможно
ИСХОДНЫЙ КОД:
#!/usr/bin/env python3
import sys
import os
import re
import hashlib
import threading
from io import BytesIO
from urllib.parse import urljoin
import requests
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, QPushButton, QListWidget, QTextEdit, QFileDialog,
QDoubleSpinBox, QSpinBox, QMessageBox, QDialog, QTabWidget,
QProgressBar
)
from PySide6.QtCore import Qt, Signal, QObject, QEvent
from PySide6.QtGui import (
QClipboard, QGuiApplication, QAction, QKeySequence, QShortcut, QKeyEvent
)
from matplotlib.figure import Figure
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
import matplotlib.patheffects as pe
from matplotlib.backend_bases import MouseButton
HOME = os.path.expanduser("~")
# ========= Общие хелперы копирования =========
def copy_to_system_clipboards(text: str) -> None:
"""
Копирует текст в системные буферы:
- Clipboard (основной)
- Selection (Linux X11/Wayland — вставка в другие приложения)
"""
try:
cb = QGuiApplication.clipboard()
cb.setText(text or "", QClipboard.Clipboard)
cb.setText(text or "", QClipboard.Selection) # важно для Linux
except Exception:
try:
QGuiApplication.clipboard().setText(text or "")
except Exception:
pass
def enable_copy_actions(widget, get_selected_text, get_all_text):
"""ПКМ меню: 'Копировать' и 'Копировать всё'."""
widget.setContextMenuPolicy(Qt.ActionsContextMenu)
act_copy = QAction("Копировать", widget)
act_copy.setShortcut(QKeySequence.Copy)
act_copy.triggered.connect(lambda: copy_to_system_clipboards(get_selected_text()))
act_copy_all = QAction("Копировать всё", widget)
act_copy_all.setShortcut("Ctrl+Shift+C")
act_copy_all.triggered.connect(lambda: copy_to_system_clipboards(get_all_text()))
widget.addAction(act_copy)
widget.addAction(act_copy_all)
class CopyHotkeyFilter(QObject):
"""
Перехватывает Ctrl+C / Ctrl+Shift+C / Ctrl+V и копирует/вставляет
через системные буферы (в т.ч. Selection на Linux).
"""
def __init__(self, parent, get_selected_text, get_all_text=None, paste_target=None):
super().__init__(parent)
self.get_selected_text = get_selected_text
self.get_all_text = get_all_text
self.paste_target = paste_target # QTextEdit/QLineEdit, если хотим Ctrl+V
def eventFilter(self, obj, event):
if event.type() == QEvent.KeyPress:
# Ctrl+C — копировать выделенное
if event.matches(QKeySequence.Copy):
copy_to_system_clipboards(self.get_selected_text() or "")
return True
# Ctrl+Shift+C — копировать всё
if (self.get_all_text is not None
and (event.modifiers() & Qt.ControlModifier)
and (event.modifiers() & Qt.ShiftModifier)
and event.key() == Qt.Key_C):
copy_to_system_clipboards(self.get_all_text() or "")
return True
# Ctrl+V — вставка
if self.paste_target is not None and event.matches(QKeySequence.Paste):
cb = QGuiApplication.clipboard()
text = cb.text(QClipboard.Clipboard) or cb.text(QClipboard.Selection)
if hasattr(self.paste_target, "insertPlainText"): # QTextEdit
self.paste_target.insertPlainText(text)
elif hasattr(self.paste_target, "insert"): # QLineEdit
self.paste_target.insert(text)
return True
return False
def install_copy_hotkeys(widget, get_selected_text, get_all_text=None, enable_paste=False):
paste_target = widget if enable_paste else None
filt = CopyHotkeyFilter(widget, get_selected_text, get_all_text, paste_target)
widget.installEventFilter(filt)
# удерживаем ссылку, чтобы GC не собрал
if not hasattr(widget, "_copy_filters"):
widget._copy_filters = []
widget._copy_filters.append(filt)
# ========= Ввод с отправкой по Enter =========
class SubmitTextEdit(QTextEdit):
submitted = Signal()
def keyPressEvent(self, event: QKeyEvent):
if event.key() in (Qt.Key_Return, Qt.Key_Enter):
if event.modifiers() == Qt.NoModifier:
self.submitted.emit()
event.accept()
return
elif event.modifiers() & Qt.ShiftModifier:
super().keyPressEvent(event)
return
super().keyPressEvent(event)
# ========= MapDialog =========
class MapDialog(QDialog):
def __init__(self, parent, coords, distances, threshold_spinbox):
super().__init__(parent)
self.setWindowTitle("Карта координат")
self.resize(750, 560)
self.coords = coords
self.distances = distances
self.threshold = threshold_spinbox # общий спинбокс порога
# pan/zoom matplotlib
self._pan_active = False
self._orig_xlim = None
self._orig_ylim = None
self._press_xy = None
self.osm_html = "" # HTML для сохранения
self._init_ui()
self.threshold.valueChanged.connect(self._redraw_all)
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)
self.canvas.setFocusPolicy(Qt.ClickFocus)
self.canvas.setFocus()
layout.addWidget(self.canvas)
self.save_btn = QPushButton("Сохранить карту (OSM HTML)")
self.save_btn.clicked.connect(self._save_osm_as_html_and_show_path)
layout.addWidget(self.save_btn)
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._redraw_all()
def _redraw_all(self):
self._draw_mpl()
self._build_osm_html()
def _draw_mpl(self):
ax = self.ax
ax.clear()
thr = float(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):
is_red = (idx - 1) in reds
color = "red" if is_red else "blue"
ax.scatter([lon], [lat], s=110, c=color, edgecolors="white",
linewidths=1.8, zorder=3)
ax.text(lon, lat, str(idx),
ha="center", va="center",
fontsize=9, fontweight="bold", color="white", zorder=4,
path_effects=[pe.withStroke(linewidth=2.2, foreground="black")])
for (i, j), d in self.distances.items():
if d <= thr:
x1, y1 = self.coords[i][1], self.coords[i][0]
x2, y2 = self.coords[j][1], self.coords[j][0]
ax.plot([x1, x2], [y1, y2], "-", color="red", linewidth=1.6, zorder=2)
mx, my = (x1 + x2) / 2.0, (y1 + y2) / 2.0
ax.text(mx, my, f"{d:.1f} м",
fontsize=9, fontweight="bold", color="black", zorder=5,
path_effects=[pe.withStroke(linewidth=3.0, foreground="white")])
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.inaxes is None:
return
if event.button in (MouseButton.LEFT, 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
if event.inaxes 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.inaxes is None or event.xdata is None or event.ydata is None:
return
base_scale = 1.2
if hasattr(event, "step") and event.step != 0:
zoom_in = event.step > 0
else:
zoom_in = getattr(event, "button", "") == "up"
scale = (1 / base_scale) if zoom_in 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)
if self._pan_active:
self._orig_xlim = self.ax.get_xlim()
self._orig_ylim = self.ax.get_ylim()
self._press_xy = (event.x, event.y)
self.canvas.draw_idle()
def _build_osm_html(self):
"""OSM + Google. Красные точки и линии при d ≤ порога. Ссылки для MAPS.ME и geo:."""
if not self.coords:
self.osm_html = "<html><body><h3 style='font-family:sans-serif'>Нет точек</h3></body></html>"
return
lat_center = sum(lat for lat, _ in self.coords) / len(self.coords)
lon_center = sum(lon for _, lon in self.coords) / len(self.coords)
thr = float(self.threshold.value())
# Сегменты ≤ порога
red_lines = []
for (i, j), d in self.distances.items():
if d <= thr:
red_lines.append((self.coords[i], self.coords[j], d))
# Индексы "красных" точек (0-based)
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}
# Маркеры: [lat, lon, idx(1-based), is_red(0/1)]
markers_js = ",".join(
f"[{lat:.8f},{lon:.8f},{idx},{1 if ((idx - 1) in reds) else 0}]"
for idx, (lat, lon) in enumerate(self.coords, 1)
)
# Сегменты для рисования: только те, что ≤ порога
segments_js = ",".join(
f"[[{a[0]:.8f},{a[1]:.8f}],[{b[0]:.8f},{b[1]:.8f}],{dist_m:.1f}]"
for (a, b, dist_m) in red_lines
)
# OSM URL собираем в JS, чтобы не экранировать {x}/{y}/{z} в f-string
tile_url_js = "'https://tile.openstreetmap.org/' + '{z}' + '/' + '{x}' + '/' + '{y}' + '.png'"
self.osm_html = f"""<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<style>
html, body, #map {{ height: 100%; margin: 0; }}
.num {{
color: white; font-weight: bold; font-size: 10px;
text-shadow: 0 0 2px black, 1px 1px 2px black, -1px -1px 2px black;
}}
.dist {{
color: black; font-weight: bold; font-size: 12px;
text-shadow: -1px -1px 0 white, 1px -1px 0 white,
-1px 1px 0 white, 1px 1px 0 white, 0 0 3px white;
}}
.leaflet-control.custom-control button {{
background: white; border: 1px solid #888; padding: 4px 8px; margin: 2px;
cursor: pointer; font-size: 12px; border-radius: 4px;
}}
.leaflet-control.custom-control button.active {{ background: #cce5ff; }}
</style>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
</head>
<body>
<div id="map"></div>
<script>
// Карта (EPSG:3857), координаты [lat, lon]
var map = L.map('map', {{ crs: L.CRS.EPSG3857 }}).setView([{lat_center:.8f}, {lon_center:.8f}], 13);
// OSM
var tileUrl = {tile_url_js};
var osm = L.tileLayer(tileUrl, {{
maxZoom: 22,
maxNativeZoom: 19,
attribution: '© OpenStreetMap contributors'
}});
// Google
var gmap = L.tileLayer('https://mt1.google.com/vt/lyrs=m&x={{x}}&y={{y}}&z={{z}}', {{ maxZoom: 22, maxNativeZoom: 21, attribution: '© Google' }});
var gsat = L.tileLayer('https://mt1.google.com/vt/lyrs=s&x={{x}}&y={{y}}&z={{z}}', {{ maxZoom: 22, maxNativeZoom: 21, attribution: '© Google' }});
var ghyb = L.tileLayer('https://mt1.google.com/vt/lyrs=y&x={{x}}&y={{y}}&z={{z}}', {{ maxZoom: 22, maxNativeZoom: 21, attribution: '© Google' }});
// По умолчанию — OSM
osm.addTo(map);
// Переключатель слоёв (OSM/Google)
L.control.layers(
{{
'OSM': osm,
'Google (карта)': gmap,
'Google (спутник)': gsat,
'Google (гибрид)': ghyb
}},
{{}},
{{ position: 'topright', collapsed: true }}
).addTo(map);
var markers = [{markers_js}];
var segments = [{segments_js}];
var bounds = [];
// Маркеры + попапы с ссылками в MAPS.ME и geo:
markers.forEach(function(m) {{
var lat = m[0], lon = m[1], idx = m[2], isRed = (m[3] === 1);
var color = isRed ? 'red' : 'blue';
var iconHtml = '<div style="width:18px;height:18px;border-radius:50%;background-color:'+color+';display:flex;align-items:center;justify-content:center;border:2px solid white;">'
+ '<span class="num">'+idx+'</span></div>';
var icon = L.divIcon({{className:'', html: iconHtml, iconSize:[18,18], iconAnchor:[9,9]}});
var marker = L.marker([lat, lon], {{icon: icon}}).addTo(map);
bounds.push([lat, lon]);
var name = encodeURIComponent('#' + idx);
var mm = 'mapsme://map?ll=' + lat.toFixed(6) + ',' + lon.toFixed(6) + '&n=' + name;
var geo = 'geo:' + lat.toFixed(6) + ',' + lon.toFixed(6) + '?q=' + lat.toFixed(6) + ',' + lon.toFixed(6) + '(' + name + ')';
var pop = '<div style="min-width:200px">'
+ '<div><b>Точка #' + idx + '</b></div>'
+ '<div style="margin-top:6px"><a href="'+mm+'">Открыть в MAPS.ME</a></div>'
+ '<div><a href="'+geo+'">Открыть через geo:</a></div>'
+ '</div>';
marker.bindPopup(pop);
}});
// Линии и подписи расстояний (только те, что ≤ порога)
segments.forEach(function(seg) {{
var p1 = seg[0], p2 = seg[1], dist = seg[2];
L.polyline([p1, p2], {{color: 'red', weight: 2}}).addTo(map);
var mid = [(p1[0]+p2[0])/2.0, (p1[1]+p2[1])/2.0];
var labelHtml = '<div class="dist">'+dist+' м</div>';
L.marker(mid, {{ icon: L.divIcon({{className:'', html: labelHtml}}), interactive: false }}).addTo(map);
}});
if (bounds.length > 0) {{
map.fitBounds(L.latLngBounds(bounds).pad(0.2));
}}
// Линейка
var measureEnabled = false, measurePoints = [], measureLines = [], measureLabels = [];
function toggleMeasure(btn) {{ measureEnabled = !measureEnabled; if (btn) btn.classList.toggle('active', measureEnabled); }}
function clearMeasure() {{
measurePoints.forEach(function(p) {{ map.removeLayer(p); }});
measureLines.forEach(function(l) {{ map.removeLayer(l); }});
measureLabels.forEach(function(lbl) {{ map.removeLayer(lbl); }});
measurePoints = []; measureLines = []; measureLabels = [];
}}
var Ctrl = L.Control.extend({{
onAdd: function(map) {{
var div = L.DomUtil.create('div', 'leaflet-control custom-control');
var btnR = L.DomUtil.create('button', '', div); btnR.textContent = 'Линейка';
btnR.onclick = function(e) {{ L.DomEvent.stop(e); toggleMeasure(btnR); }};
var btnC = L.DomUtil.create('button', '', div); btnC.textContent = 'Очистить';
btnC.onclick = function(e) {{ L.DomEvent.stop(e); clearMeasure(); }};
L.DomEvent.disableClickPropagation(div); return div;
}},
onRemove: function(map) {{}}
}});
(new Ctrl({{position:'topright'}})).addTo(map);
map.on('click', function(e) {{
if (!measureEnabled) return;
var m = L.marker(e.latlng).addTo(map); measurePoints.push(m);
if (measurePoints.length % 2 === 0) {{
var p1 = measurePoints[measurePoints.length-2].getLatLng();
var p2 = measurePoints[measurePoints.length-1].getLatLng();
var dist = map.distance(p1, p2).toFixed(1);
var line = L.polyline([p1, p2], {{color: 'green', weight: 2, dashArray: '5,5'}}).addTo(map);
measureLines.push(line);
var mid = L.latLng((p1.lat+p2.lat)/2, (p1.lng+p2.lng)/2);
var lbl = L.marker(mid, {{
icon: L.divIcon({{className:'', html:'<div class="dist">'+dist+' м</div>'}}),
interactive: false
}}).addTo(map);
measureLabels.push(lbl);
}}
}});
</script>
</body>
</html>"""
@staticmethod
def _copy_text_all_clipboards(text: str):
"""Вспомогательное копирование пути (используется при сохранении)."""
copy_to_system_clipboards(text)
def _save_osm_as_html_and_show_path(self):
if not self.osm_html:
QMessageBox.information(self, "Нет данных", "Нет карты для сохранения.")
return
default_name = "map_osm.html"
path, _ = QFileDialog.getSaveFileName(
self, "Сохранить OSM как HTML", default_name, "HTML (*.html)"
)
if not path:
return
try:
with open(path, "w", encoding="utf-8") as f:
f.write(self.osm_html)
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить файл:\n{e}")
return
self._copy_text_all_clipboards(path)
QMessageBox.information(self, "Готово", f"HTML сохранён.\nПуть скопирован в буфер обмена:\n\n{path}")
# ========= 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("Координаты (шир, долг):"))
# Ввод с отправкой по Enter
self.entry = SubmitTextEdit()
self.entry.setPlaceholderText(
"Вставьте несколько координат (каждая строка — пара lat, lon).\n"
"Примеры:\n55.75, 37.62\n55.751244 37.618423\n+55.75 -37.62"
)
self.entry.setFixedHeight(80)
self.entry.submitted.connect(self._add_bulk)
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, 10000)
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)
# ПКМ + горячие клавиши
enable_copy_actions(
self.entry,
get_selected_text=lambda: self.entry.textCursor().selectedText().replace("\u2029", "\n"),
get_all_text=lambda: self.entry.toPlainText(),
)
install_copy_hotkeys(
self.entry,
get_selected_text=lambda: self.entry.textCursor().selectedText().replace("\u2029", "\n"),
get_all_text=lambda: self.entry.toPlainText(),
enable_paste=True,
)
enable_copy_actions(
self.lst,
get_selected_text=lambda: "\n".join(it.text() for it in self.lst.selectedItems()) or
"\n".join(self.lst.item(i).text() for i in range(self.lst.count())),
get_all_text=lambda: "\n".join(self.lst.item(i).text() for i in range(self.lst.count())),
)
install_copy_hotkeys(
self.lst,
get_selected_text=lambda: "\n".join(it.text() for it in self.lst.selectedItems()) or
"\n".join(self.lst.item(i).text() for i in range(self.lst.count())),
get_all_text=lambda: "\n".join(self.lst.item(i).text() for i in range(self.lst.count())),
enable_paste=False,
)
@staticmethod
def _parse_pairs_from_text(text: str):
pairs = []
nums = re.findall(r"[-+]?(?:\d*\.\d+|\d+)", text)
vals = [float(n) for n in nums]
if len(vals) < 2:
return pairs
for i in range(0, len(vals) - 1, 2):
lat, lon = vals[i], vals[i + 1]
pairs.append((lat, lon))
return pairs
def _add_bulk(self):
text = self.entry.toPlainText().strip()
if not text:
QMessageBox.information(self, "Пусто", "Вставьте координаты.")
self.entry.setFocus()
return
pairs = self._parse_pairs_from_text(text)
if not pairs:
QMessageBox.critical(self, "Ошибка", "Не удалось распознать пары координат.")
self.entry.setFocus()
return
self.coords.extend(pairs)
self.entry.clear()
self._refresh()
self.entry.setFocus()
def _list_key_event(self, event):
# Удаляем только по Delete, исключаем Backspace
if event.key() == Qt.Key_Delete:
rows = sorted([self.lst.row(it) for it in self.lst.selectedItems()], reverse=True)
for r in rows:
del self.coords[r]
self._refresh()
else:
QListWidget.keyPressEvent(self.lst, event)
def _recalc_distances(self):
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
def _refresh(self):
self.lst.clear()
self._recalc_distances()
for idx, (la, lo) in enumerate(self.coords, 1):
self.lst.addItem(f"{idx}. {la}, {lo}")
def _show_map(self):
if self.coords:
self._recalc_distances()
dlg = MapDialog(self, self.coords, self.distances, self.threshold)
dlg.exec()
else:
QMessageBox.information(self, "Пусто", "Нет координат для показа.")
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>")
def _import(self):
fn, _ = QFileDialog.getOpenFileName(self, "Файл с координатами", HOME,
"Text files (*.txt);;All files (*)")
if not fn:
return
try:
with open(fn, encoding="utf-8") as f:
content = f.read()
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось прочитать файл:\n{e}")
return
pairs = self._parse_pairs_from_text(content)
if not pairs:
QMessageBox.critical(self, "Ошибка", "В файле не найдены координаты.")
return
self.coords.extend(pairs)
self._refresh()
# ========= 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)
# ПКМ + горячие клавиши
enable_copy_actions(
self.input_text,
get_selected_text=lambda: self.input_text.textCursor().selectedText().replace("\u2029", "\n"),
get_all_text=lambda: self.input_text.toPlainText(),
)
install_copy_hotkeys(
self.input_text,
get_selected_text=lambda: self.input_text.textCursor().selectedText().replace("\u2029", "\n"),
get_all_text=lambda: self.input_text.toPlainText(),
enable_paste=True,
)
enable_copy_actions(
self.output,
get_selected_text=lambda: self.output.textCursor().selectedText().replace("\u2029", "\n"),
get_all_text=lambda: self.output.toPlainText(),
)
install_copy_hotkeys(
self.output,
get_selected_text=lambda: self.output.textCursor().selectedText().replace("\u2029", "\n"),
get_all_text=lambda: self.output.toPlainText(),
enable_paste=False,
)
# ===== UI helpers =====
def _append_output(self, text):
self.output.append(text)
def _show_hash_checked(self, count, total):
self._status("Хэш проверен", f"{count}/{total}")
if 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}")
# ===== Actions =====
def _on_import(self):
self.output.clear()
fn, _ = QFileDialog.getOpenFileName(self, "TXT", HOME, "*.txt")
if fn:
try:
with open(fn, "r", encoding="utf-8") as f:
self.input_text.setPlainText(f.read())
except Exception as e:
QMessageBox.critical(self, "Ошибка", f"Не удалось прочитать файл:\n{e}")
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()
# ===== Core logic =====
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=15)
r.raise_for_status()
soup = BeautifulSoup(r.text, "html.parser")
# если это галерея — собираем ссылки на страницы изображений
if "gallery" in r.url or soup.select_one("#thumb-list"):
pages = [urljoin(r.url, a["href"]) for a in soup.select("div#thumb-list a.img[href]")]
else:
pages = [r.url]
for page in pages:
r2 = sess.get(page, timeout=15)
r2.raise_for_status()
s2 = BeautifulSoup(r2.text, "html.parser")
# основной <img id="main-image"> или резервный поиск
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 and img.get("src"):
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.get(link, link)}")
# Получаем perceptual hash для всех изображений
for idx, img_url in enumerate(self.all_images, 1):
try:
data = sess.get(img_url, timeout=20).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:
dist = (h1 - h2)
if dist <= thr and (u1, u2) not in visited:
dup_count += 1
self.append_signal.emit(f"{dup_count}. Дубликаты (расстояние {dist}):")
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=20).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 *.jpeg *.png *.webp *.bmp *.tif *.tiff)"
)
if len(fns) < 2:
QMessageBox.information(self, "Внимание", "Нужно минимум 2 фото")
return
thr = self.hash_threshold.value()
self.output.append("\n=== Сравнение фото (локально) ===")
self.output.append(f"Порог хеша (Hamming) ≤ {thr}\n")
# Собираем md5 и perceptual hash (aHash)
infos = [] # [(path, basename, md5, ahash)]
for p in fns:
try:
with open(p, "rb") as fh:
data = fh.read()
md5 = hashlib.md5(data).hexdigest()
img = Image.open(BytesIO(data)).convert("RGB")
ah = imagehash.average_hash(img)
infos.append((p, os.path.basename(p), md5, ah))
except Exception as e:
self.output.append(f"Пропуск файла (ошибка): {p}\n{e}")
if len(infos) < 2:
self.output.append("Недостаточно валидных изображений для сравнения.")
return
# 1) Точные совпадения (MD5)
self.output.append("— Точные совпадения (MD5) —")
md5_groups = {}
for _, name, m, _ in infos:
md5_groups.setdefault(m, []).append(name)
exact_found = 0
for group in md5_groups.values():
if len(group) > 1:
exact_found += 1
self.output.append(f"{exact_found}. Найдены совпадения:")
for n in group:
self.output.append(f" - {n}")
self.output.append("")
if exact_found == 0:
self.output.append("Совпадений не найдено.\n")
# 2) Похожие изображения (по aHash с порогом)
self.output.append("— Похожие изображения (aHash) —")
near_found = 0
visited = set()
total = len(infos)
for i in range(total):
for j in range(i + 1, total):
n1, n2 = infos[i][1], infos[j][1]
h1, h2 = infos[i][3], infos[j][3]
dist = (h1 - h2)
if dist <= thr and (n1, n2) not in visited:
near_found += 1
self.output.append(f"{near_found}. Похожая пара (расстояние {dist}):")
self.output.append(f" - {n1}")
self.output.append(f" - {n2}\n")
visited.add((n1, n2))
if near_found == 0:
self.output.append("Похожих изображений не найдено при заданном пороге.")
self.output.append("=== Анализ завершен ===")
# ========= Main Window =========
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("EVSEGN Cord Helper")
self.resize(320, 480)
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())
УСТАНОВКА
Есть 2 пути:
Сложный - вы копируете код выше и самостоятельно все устанавливаете.
Простой - все уже сделано.
Сложный:
1) Устанавливаем необходимые пакеты
amnesia@amnesia: ~$ sudo apt install python3 python3-venv python3-pip build-essential libxcb-cursor0 libxcb-xinerama0 libxkbcommon-x11-0
2. Сохраняем скрипт
Копируем полностью исходный код в текстовый редактор
Сохраняем файл с названием cordhelper.py
в удобный для вас раздел (я сохранил в Downloads) (проверьте название файла, так cordhelper.py.txt
НЕ правильно)
По совету разобрался как все это реализовать в Tails.
3. Устанавливаем Proxychains
amnesia@amnesia:~$ sudo apt install proxychains
Проверьте раскоментированы ли строки и правильно ли указан прокси (локальный в Tails)
amnesia@amnesia: ~$ sudo nano /etc/proxychains.conf
proxy_dns
tcp_read_time_out 15000
tcp_connect_time_out 8000
[ProxyList]
socks5 127.0.0.1 9050
4. Подготовка виртуального окружения
Переходим в папку где у вас сохранен скрипт (у меня он был сохранен в Persistent)
amnesia@amnesia: ~$ cd ~/Persistent
amnesia@amnesia: ~/Persistent$ python3 -m venv venv
amnesia@amnesia: ~/Persistent$ source venv/bin/activate
5. Устанавливаем зависимости
(venv) amnesia@amnesia: ~/Persistent$ proxychains pip install PySide6 pillow imagehash beautifulsoup4 requests geopy matplotlib
6. Устанавливаем упаковщик и упаковываем
(venv) amnesia@amnesia: ~/Persistent$ proxychains pip install pyinstaller
(venv) amnesia@amnesia: ~/Persistent$ proxychains pyinstaller --onefile --name cordhelper --windowed cordhelper.py
Теперь у вас в папке dist в Persistent появился бинарник.
Перемещаете в удобное место и уже можно запускать.
Делаем исполняемым
amnesia@amnesia: ~/Persistent$ chmod +x ~/Persistent/dist/cordhelper
ИЛИ

amnesia@amnesia: cd ~/Persistent
amnesia@amnesia: torsocks cordhelper
Но мы сделаем лучше!
1. Создаем отдельный каталог для бинарника, перемещаем, переименовываем и делаем исполняемым
amnesia@amnesia:~/Persistent$ mkdir -p ~/Persistent/bin
amnesia@amnesia:~/Persistent$ mv ~/dist/cordhelper ~/Persistent/bin/cordhelper_real
amnesia@amnesia:~/Persistent$ chmod +x ~/Persistent/bin/cordhelper_real
2. Создаем скрипт, что бы он всегда запускался через torsocks
amnesia@amnesia:~/Persistent$ cat > ~/Persistent/bin/cordhelper << 'EOF'
> #!/bin/bash
> exec torsocks "$HOME/Persistent/bin/cordhelper_real" "$@"
> EOF
3. Делаем исполняемым, создаем каталог для dotfiles, конфигурационный файл, добавляем в окружение PATH.
amnesia@amnesia:~/Persistent$ chmod +x ~/Persistent/bin/cordhelper
amnesia@amnesia:~/Persistent$ mkdir -p ~/Persistent/dotfiles
amnesia@amnesia:~/Persistent$ touch ~/.bashrc
amnesia@amnesia:~/Persistent$ echo 'export PATH="$HOME/Persistent/bin:$PATH"' >> ~/.bashrc
4. Копируем, выполняем, проверяем
amnesia@amnesia:~/Persistent$ cp ~/.bashrc ~/Persistent/dotfiles/.bashrc
amnesia@amnesia:~/Persistent$ source ~/.bashrc
amnesia@amnesia:~/Persistent$ which cordhelper
/home/amnesia/Persistent/bin/cordhelper (проверка)
5. Теперь после перезагрузки, мы всегда сможем запустить программу командой и она будет через torsocks
amnesia@amnesia:~/Persistent$ cordhelper
Простой
1. Скачиваем бинарник
Скачать
VT ссылка
VT файл
2. Запускаем скрипт
Скачать
VT ссылка
ИЛИ
Загружаем код в тектовый файл, сохраняем как setup_cordhelper_torsocks.sh
#!/bin/bash
# setup_cordhelper_torsocks.sh
# Использование: ./setup_cordhelper_torsocks.sh [/путь/к/бинарнику]
# Если путь не указан — берём ~/dist/cordhelper
set -euo pipefail
SRC_BIN="${1:-"$HOME/dist/cordhelper"}"
PERSIST="$HOME/Persistent"
BIN_DIR="$PERSIST/bin"
DOT_DIR="$PERSIST/dotfiles"
REAL_BIN="$BIN_DIR/cordhelper_real"
WRAP_BIN="$BIN_DIR/cordhelper"
echo ">>> Проверка исходного бинарника: $SRC_BIN"
if [[ ! -f "$SRC_BIN" ]]; then
echo "Ошибка: не найден файл $SRC_BIN"
echo "Подскаска: собери PyInstaller-ом (в dist/cordhelper) или укажи путь явно."
exit 1
fi
mkdir -p "$BIN_DIR"
mkdir -p "$DOT_DIR"
echo ">>> Копируем бинарник в $REAL_BIN"
cp -f "$SRC_BIN" "$REAL_BIN"
chmod +x "$REAL_BIN"
echo ">>> Создаём обёртку $WRAP_BIN (всегда через torsocks)"
cat > "$WRAP_BIN" <<'EOF'
#!/bin/bash
exec torsocks "$HOME/Persistent/bin/cordhelper_real" "$@"
EOF
chmod +x "$WRAP_BIN"
echo ">>> Добавляем ~/Persistent/bin в PATH (в ~/.bashrc)"
touch "$HOME/.bashrc"
LINE='export PATH="$HOME/Persistent/bin:$PATH"'
grep -qxF "$LINE" "$HOME/.bashrc" || echo "$LINE" >> "$HOME/.bashrc"
echo ">>> Сохраняем ~/.bashrc в Dotfiles, чтобы переживало перезагрузку"
cp -f "$HOME/.bashrc" "$DOT_DIR/.bashrc"
echo ">>> Применяем PATH в текущем терминале"
# shellcheck source=/dev/null
source "$HOME/.bashrc"
echo ">>> Проверка доступности команды"
if command -v cordhelper >/dev/null 2>&1; then
echo "ОК: команда 'cordhelper' доступна: $(command -v cordhelper)"
else
echo "ВНИМАНИЕ: команда 'cordhelper' пока не доступна в PATH."
echo "Открой новый терминал или выполните: source ~/.bashrc"
fi
echo
echo "Готово "
echo "Запуск сейчас: cordhelper"
echo "После перезагрузки (с разблокированным Persistent Storage): cordhelper"
echo
echo "Примечание: обёртка всегда запускает через torsocks."
3. Запускаем скрипт
amnesia@amnesia: chmod +x setup_cordhelper_torsocks.sh
amnesia@amnesia: ./setup_cordhelper_torsocks.sh
Теперь запускать можно так даже после перегзагрузки:
amnesia@amnesia: cordhelper
Буду рад фидбэку, предложениям и гневным комментариям.
В планах добавить:
Расширить количество сайтов для анализа
Спарсить расположение гос. учреждений, площадок
Автоматическое распознование координат с фотографии (сложно и долго)
P.S. я ни на что не претендую, сделал и поделился, возможно кто-то будет использовать. Я не програмист и не претендую на звание.
Спасибо за внимание.