From c3e0dbb7dfbf7596fd84a5bd9d1cf1114beb6dd2 Mon Sep 17 00:00:00 2001 From: cevoj35548 Date: Fri, 26 May 2023 16:27:00 +0000 Subject: [PATCH] Refactor code, add comments, optimizations (gevent), bump to 1.15 --- FmhyChecker.pyw | 302 +++++++++++++++++++++++++++++------------------- 1 file changed, 185 insertions(+), 117 deletions(-) diff --git a/FmhyChecker.pyw b/FmhyChecker.pyw index 4c377ce..26d5f76 100644 --- a/FmhyChecker.pyw +++ b/FmhyChecker.pyw @@ -1,3 +1,4 @@ +import gevent import grequests import requests from fake_headers import Headers @@ -22,49 +23,69 @@ from queue import Queue # fake headers headers = Headers(headers=True) # use queues to keep track of connection pools -dist_cnxns = Queue(maxsize=50) +dist_cnxns = Queue(maxsize=40) def resource_path(relative_path): + # wrapper to retrieve the absolute path from a relative path try: base_path = sys._MEIPASS except Exception: base_path = os.path.dirname(__file__) return os.path.join(base_path, relative_path) + def handle_req(url, item, callback): - dist_cnxns.put(1) + # process the request & send back to main event loop item.setText(2, 'Testing...') try: resp = requests.head(url, headers=headers.generate(), timeout=10, allow_redirects=True) - if resp is None: resp = 'Failed' except ReadTimeout: - callback(url, 'Timeout', item) + callback(url, None, item, 'Timeout') except ConnectionError: - callback(url, 'Error', item) + callback(url, None, item, 'Error') except Exception as e: - callback(url, str(e), item) + callback(url, None, item, str(e).split('\n')[0]) else: - if resp.status_code != 200: - resp = resp.reason or 'Unknown' - callback(url, resp, item) + send_resp(url, resp, item, callback) dist_cnxns.get() -def async_request(*args): +def send_resp(url, resp, item, callback): + # if resp completely failed + if resp is None: + return callback(url, resp, item, 'Failed') + # if response was not OK + if resp.status_code != 200: + return callback(url, resp, item, resp.reason.capitalize() or 'Unknown') + # response was success + return callback(url, resp, item, None) + + +def add_request(*args): + dist_cnxns.put(1) # wait for when <40 requests are running. blocks if full thread = Thread(target=handle_req, args=args, daemon=True) + # spawn thread to handle request thread.start() +def async_request(*args): + # spawn greenlets to wait their turn in the queue + gevent.spawn(add_request, *args) + gevent.sleep() # workaround for gevent bug + class UI(QMainWindow): - group_url_regex = re.compile(r'((?:https?|ftp|file):\/\/(?:ww(?:w|\d+)\.)?)((?:[\w_-]+(?:\.[\w_-]+)+)[\w.,@?^=%&:\/~+#-]*[\w@?^=%&~+-])') - call_back_checkLinks = pyqtSignal() - http_test_sig = pyqtSignal(str, object, object) + checkLinks_callback = pyqtSignal() + http_test_sig = pyqtSignal(str, object, object, str) + # regex for slicing links into groups ( ) + # i only check if group 1 is in the wiki to determine if the link is unique, then add the full link + grouped_wiki_regex = re.compile(r'((?:https?|ftp|file):\/\/(?:ww(?:w|\d+)\.)?)((?:[\w_-]+(?:\.[\w_-]+)+)[\w.,@?^=%&:\/~+#-]*[\w@?^=%&~+-])') def __init__(self): super(UI, self).__init__() uic.loadUi(resource_path('MainWindow.ui'), self) self.setWindowIcon(QtGui.QIcon(resource_path('assets\\icon.ico'))) + # palette coloring if darkdetect.isDark(): self._highlight_col = QtGui.QColor(157, 93, 24) dark_palette() @@ -73,12 +94,13 @@ class UI(QMainWindow): else: self._highlight_col = QtGui.QColor(255, 128, 0) + # set up main window (hide elements, set column widths, etc.) self.checkSelected.setVisible(False) self.progressBarFrame.setVisible(False) self.outputTree.header().setSectionsMovable(False) self.outputTree.setColumnWidth(0, 50) self.outputTree.setColumnWidth(1, 180) - + # allow multiple selection self.outputTree.setSelectionMode(QtWidgets.QAbstractItemView.ExtendedSelection) # status code font @@ -106,11 +128,11 @@ class UI(QMainWindow): self.copyValid.clicked.connect(lambda: copy('\n'.join(self.valid_links))) self.copyTested.clicked.connect(lambda: copy('\n'.join(self.getTestedLinks()))) self.exportCsv.clicked.connect(self.exportCsvDialog) - self.checkSelected.clicked.connect(self._testSelectedLinks) + self.checkSelected.clicked.connect(self.test_selected_links) self.inputBox.textChanged.connect(self.checkLinks) self.outputTree.itemSelectionChanged.connect(self.onSelection) self.http_test_sig.connect(self.finishTest) - self.call_back_checkLinks.connect(self.checkLinks) + self.checkLinks_callback.connect(self.checkLinks) self.testing_items = set() self.tested_items = {} @@ -123,6 +145,7 @@ class UI(QMainWindow): splash.hide() self.show() + # csv exporting def exportCsvDialog(self): file_dialog = QtWidgets.QFileDialog() file_dialog.setAcceptMode(QtWidgets.QFileDialog.AcceptSave) @@ -132,97 +155,124 @@ class UI(QMainWindow): if not file_dialog.exec_(): return file_path = file_dialog.selectedFiles()[0] - links = re.findall(self.group_url_regex, self.inputBox.toPlainText()) + links = re.findall(self.grouped_wiki_regex, self.inputBox.toPlainText()) try: with open(file_path, 'w', newline='') as csvfile: - writer = csv.writer(csvfile, dialect='excel', quoting=csv.QUOTE_MINIMAL) - writer.writerow(['Request URL', 'Final URL', 'Unique?', '# Redirects', 'Status', 'Reason']) - for link in links: - full_link = ''.join(link) - if full_link in self.tested_items: - if type(self.tested_items[full_link]) is str: - redirects = status_code = '' - reason = self.tested_items[full_link] - final_url = '' - else: - redirects = str(len(self.tested_items[full_link].history)) - reason = self.tested_items[full_link].reason - status_code = '=CONCAT('+', " > ", '.join( - f'HYPERLINK("{r.url}", "{r.status_code}")' - for r in ( - *self.tested_items[full_link].history, - self.tested_items[full_link]) - )+')' - final_url = self.tested_items[full_link].url - else: - reason = redirects = final_url = status_code = '' - writer.writerow([ - re.sub(r'[^\x00-\x7F]+', '?', full_link), # remove non ascii characters - final_url, - redirects, - 'FALSE' if link[1] in wiki else 'TRUE', - redirects, - status_code, - reason - ]) + self.writeCsvFile(csvfile, links) except PermissionError: QtWidgets.QMessageBox.critical(self.centralwidget, "Error", "File permission denied.") - def getTestedLinks(self): + def writeCsvFile(self, csvfile, links): + # write links to csv file + writer = csv.writer(csvfile, dialect='excel', quoting=csv.QUOTE_MINIMAL) + # csv header + writer.writerow(['Request URL', 'Final URL', 'Unique?', '# Redirects', 'Status', 'Reason']) + for link in links: + full_link = ''.join(link) + if full_link in self.tested_items: + # if the response was a message + if type(self.tested_items[full_link]) is str: + # set values to blank, and reason to message + final_url = redirects = status_code = '' + reason = self.tested_items[full_link] + else: + # get the number of redirects + redirects = str(len(self.tested_items[full_link].history)) + # get the reason for the status code + reason = self.tested_items[full_link].reason + # hyperlink status codes to the final url. chain together redirects with ' > ' + status_code = '=CONCAT('+', " > ", '.join( + f'HYPERLINK("{r.url}", "{r.status_code}")' + for r in ( + *self.tested_items[full_link].history, + self.tested_items[full_link]) + )+')' + final_url = self.tested_items[full_link].url + else: + # if the link was not tested, set values to blank + reason = redirects = final_url = status_code = '' + # write row to csv + writer.writerow([ + re.sub(r'[^\x00-\x7F]+', '?', full_link), # remove non ascii characters + final_url, + redirects, + 'FALSE' if link[1] in wiki else 'TRUE', + redirects, + status_code, + reason + ]) + + # return a list of unique links that have 200 status codes + def getTestedLinks(self) -> list: return [ l for l in self.tested_items + # if link is valid and not a message if l in self.valid_links and type(self.tested_items[l]) is not str + # and status code was OK and self.tested_items[l].status_code in range(200, 300) ] - def finishTest(self, url, resp, item): + # add the satus code chain to the tree + def finishTest(self, url, resp, item, message=None): + # remove from testing items, and add the resp to tested items if url in self.testing_items: self.testing_items.remove(url) - self.tested_items[url] = resp + self.tested_items[url] = resp or message self.copyTested.setEnabled(True) + # check if the tree item was deleted try: item.text(2) except RuntimeError: return # item was deleted + # create a new horizontal layout for the status code chain widget = QtWidgets.QWidget() widget.setLayout(layout := QtWidgets.QHBoxLayout()) layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) layout.setAlignment(QtCore.Qt.AlignLeft) + # add layout to the tree item self.outputTree.setItemWidget(item, 2, widget) - item.setText(2, "") - if type(resp) is str: - label = QtWidgets.QLabel(f' {resp} ') - label.setFont(self.status_font) - color = self.reason_colors.get(resp, '#A12729') - label.setStyleSheet(f'background-color: {color}; color: white; border-radius: 6px;') - layout.addWidget(label) - return + item.setText(2, "") # remove the loading text + # if a message was passed, only add it to the layout + if message: + color = self.reason_colors.get(message, '#A12729') + return self.add_status_label(layout, message, color) + # else add the status code chain for r in (*resp.history, resp): - label = QtWidgets.QLabel(f" {r.status_code} ") color = next((self.status_colors[k] for k in self.status_colors if r.status_code in k), '#000000') - label.setStyleSheet(f'background-color: {color}; color: white; border-radius: 6px;') - label.setToolTip(r.url) - label.setToolTipDuration(-1) - label.setFont(self.status_font) - layout.addWidget(label) + self.add_status_label(layout, r.status_code, color, r.url) - def _testSelectedLinks(self): + def add_status_label(self, layout, text, color, tooltip=None): + # add label to the layout + label = QtWidgets.QLabel(f' {text} ') + label.setStyleSheet(f'background-color: {color}; color: white; border-radius: 6px;') + label.setFont(self.status_font) + if tooltip: + label.setToolTip(tooltip) + label.setToolTipDuration(-1) + layout.addWidget(label) + + def test_selected_links(self): + # get selected tree items selected = self.getRanItems() self.testing_items.update([i.text(1) for i in selected]) # remember tested items + # set to "Queued" for item in selected: item.setText(2, "Queued") self.outputTree.clearSelection() self.checkSelected.setVisible(False) + # send requests for item in selected: async_request(item.text(1), item, self.http_test_sig.emit) QtWidgets.QApplication.processEvents() # allow GUI to update - def getRanItems(self): + def getRanItems(self) -> list: + # return a list of selected items that have not been tested return [i for i in self.outputTree.selectedItems() if i.text(1) not in {*self.tested_items, *self.testing_items}] def onSelection(self): + # when an untested tree item is selected, show the "Test" button if selected := self.getRanItems(): self.checkSelected.setText(QtCore.QCoreApplication.translate("MainWindow", f"Test ({len(selected)}) \U0001F50D")) self.checkSelected.setVisible(True) @@ -230,11 +280,13 @@ class UI(QMainWindow): self.checkSelected.setVisible(False) def _waitForEvent(self): + # if checkLinks was already running, wait for it to complete then call it back self._new_event = True self._is_free.wait() - self.call_back_checkLinks.emit() + self.checkLinks_callback.emit() def checkLinks(self): + # only allow one instance of checkLinks to run if not self._is_free.is_set(): if self.line_thread and self.line_thread.is_alive(): return @@ -242,19 +294,21 @@ class UI(QMainWindow): self.line_thread.start() return self._is_free.clear() + # get the text from the input box text = self.inputBox.toPlainText() if text: self.inputBox.setPlaceholderText('') else: self.inputBox.setPlaceholderText(self._placeholderText) self.outputTree.clear() + # disable buttons while running self.copyValid.setEnabled(False) self.copyDupes.setEnabled(False) self.copyTested.setEnabled(False) self.exportCsv.setEnabled(False) self.checkSelected.setVisible(False) - - links = re.findall(self.group_url_regex, text) + # get all links from the input text using regex + links = re.findall(self.grouped_wiki_regex, text) self.valid_links, self.duped_links, self.tested_links = [], [], [] # enable progress bar self.progressBar.setMaximum(len(links)) @@ -267,24 +321,30 @@ class UI(QMainWindow): self._new_event = False self._is_free.set() return + # create new tree item item = QtWidgets.QTreeWidgetItem(self.outputTree) full_link = ''.join(link) item.setText(1, full_link) + # process in chunks to allow for UI updates if len(links) > 100 and not n % 100: - # process in chunks to allow for UI updates self.progressBar.setValue(n) QtWidgets.QApplication.processEvents() + # add item to tree with suppress(RuntimeError): if full_link in self.tested_items: + # if the link was already tested, use the previous result self.finishTest(full_link, self.tested_items[full_link], item) elif full_link in self.testing_items: + # if the link is currently being tested, indicate "Testing..." item.setText(2, "Testing...") if link[1] in wiki: + # if link is in the wiki item.setText(0, "\u274C") for _ in range(3): item.setBackground(_, self._highlight_col) self.duped_links.append(full_link) else: + # if link is NOT in the wiki item.setText(0, "\u2705") self.valid_links.append(full_link) # show finished tree @@ -299,9 +359,9 @@ class UI(QMainWindow): self._is_free.set() def retranslateUi(self): - # Set text (with translations) + # set text (with translations) _translate = QtCore.QCoreApplication.translate - self.setWindowTitle(_translate("MainWindow", "Dupe Checker v1.14")) + self.setWindowTitle(_translate("MainWindow", "Dupe Checker v1.15")) self.label.setText(_translate("MainWindow", "FMHY Dupe Tester")) self.label_2.setText(_translate("MainWindow", "by cevoj")) self._placeholderText = _translate("MainWindow", "Paste a list of links here...") @@ -332,6 +392,7 @@ def dark_title_bar(hwnd): def dark_palette(): + # create darker palette with Fusion style app.setStyle('Fusion') palette = QtGui.QPalette() palette.setColor(QtGui.QPalette.Window, QtGui.QColor(25,35,45)) @@ -351,13 +412,57 @@ def dark_palette(): app.setPalette(palette) -def build_wiki_set(): - # scrape wiki - elapsed = time.perf_counter() - try: - resps = grequests.map([grequests.get(l) for l in URLS], size=len(URLS)) - except ConnectionError: - # show connection error +class WikiScraper: + # regex for scraping links from wikis (links must include leading http(s)://) + wiki_regex = re.compile(r'(?:https?|ftp|file):\/\/(?:ww(?:w|\d+)\.)?((?:[\w_-]+(?:\.[\w_-]+)+)[\w.,@?^=%&:\/~+#-]*[\w@?^=%&~+-])') + # regex for scraping lists of urls (for links not including http(s)://) + list_regex = re.compile(r'^[\w]*\.[\w.,@?^=%&:\/~+#-]*[\w@?^=%&~+-]', re.MULTILINE) + # regex for scraping base64 encoded links + b64_regex = re.compile(r'`aHR0(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?`') + + ''' + FOR ANYONE IN THE FUTURE TRYING TO MAINTAIN THIS + you can add new urls to scrape by adding new key-value pairs to the dict below + : + ''' + def __init__(self): + self.URLS = { + 'https://raw.githubusercontent.com/nbats/FMHYedit/main/single-page': (self.handle_wiki, self.handle_b64), + 'https://gitlab.com/nbatman_/deleted-links/-/raw/main/deleted-links': (self.handle_list,), + } + + def build_wiki_set(self) -> set: + elapsed = time.perf_counter() + try: + resps = grequests.map([grequests.get(l) for l in self.URLS], size=len(self.URLS)) + except ConnectionError: + self.error_msg() # show connection error + wiki = set() + for resp, funcs in zip(resps, self.URLS.values()): + for func in funcs: + wiki.update(func(resp.text)) + + print(f'Wiki scraped in {time.perf_counter() - elapsed:0.4f} sec. Found {len(wiki)} links.') + return wiki + + @staticmethod + def from_regex(regex, text) -> set: + return set(re.findall(regex, text)) + + def handle_wiki(self, text) -> set: + return self.from_regex(self.wiki_regex, text) + + def handle_list(self, text) -> set: + return self.from_regex(self.list_regex, text) + + def handle_b64(self, text) -> set: + data = '\n'.join( + b64decode(m.strip('`')).decode() + for m in re.findall(self.b64_regex, text) + ) + return self.handle_list(data) + + def error_msg(self): msg = QtWidgets.QMessageBox() msg.setIcon(QtWidgets.QMessageBox.Critical) msg.setText("Could not connect to the internet. Please check your connection and try again.") @@ -365,42 +470,6 @@ def build_wiki_set(): splash.hide() msg.exec_() exit(1) - wiki = set() - for resp, exps in zip(resps, URLS.values()): - for exp in exps: - if exp is b64_regex: - wiki.update(handle_b64(resp.text)) - else: - wiki.update(set(re.findall(exp, resp.text))) - - print(f'Wiki scraped in {time.perf_counter() - elapsed:0.4f} sec. Found {len(wiki)} links.') - return wiki - - -def handle_b64(content): - data = '\n'.join( - b64decode(m.strip('`')).decode() - for m in re.findall(b64_regex, content) - ) - return set(re.findall(url_regex, data)) - - -# regex for scraping links from wikis (links must include leading http(s)://) -url_regex = re.compile(r'(?:https?|ftp|file):\/\/(?:ww(?:w|\d+)\.)?((?:[\w_-]+(?:\.[\w_-]+)+)[\w.,@?^=%&:\/~+#-]*[\w@?^=%&~+-])') -# regex for scraping lists of urls (for links not including http(s)://) -list_regex = re.compile(r'^[\w]*\.[\w.,@?^=%&:\/~+#-]*[\w@?^=%&~+-]', re.MULTILINE) -# regex for scraping base64 encoded links -b64_regex = re.compile(r'`aHR0(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/][AQgw]==|[A-Za-z0-9+/]{2}[AEIMQUYcgkosw048]=)?`') - -''' -FOR ANYONE IN THE FUTURE TRYING TO MAINTAIN THIS -you can add a wikis to scrape by adding new key-value pairs to the dict below -the key is the url of the wiki, and the value is a tuple of regexes to use for scraping -''' -URLS = { - 'https://gitlab.com/nbatman_/deleted-links/-/raw/main/deleted-links': (list_regex,), - 'https://raw.githubusercontent.com/nbats/FMHYedit/main/single-page': (url_regex, b64_regex), -} if __name__ == "__main__": @@ -411,8 +480,7 @@ if __name__ == "__main__": splash_icon = QtGui.QPixmap(resource_path('assets/splash.svg')) splash = QtWidgets.QSplashScreen(splash_icon, QtCore.Qt.WindowStaysOnTopHint) splash.show() - wiki = build_wiki_set() - + wiki = WikiScraper().build_wiki_set() fonts_dir = resource_path('assets/fonts') for f in os.listdir(fonts_dir):