def main(): ## {{{ getopts() print(Color().heading(title)) main_items = [ 'single', 'bulk', 'get youtube playlist urls', 'video available formats', 'help' ] main_item = fzf(main_items) if main_item == 'single': total_duration = single() print(Color().brown({ 'time': get_datetime('jhms'), 'total duration': total_duration })) elif main_item == 'bulk': total_duration = bulk() append_to_log( str({ 'time': get_datetime('jhms'), 'total duration': total_duration }) + '\n') print(Color().brown({ 'time': get_datetime('jhms'), 'total duration': total_duration })) elif main_item == 'get youtube playlist urls': get_youtube_playlist_urls() elif main_item == 'video available formats': video_available_formats() elif main_item == 'help': help()
def get_youtube_playlist_urls(): ## {{{ global attempt, should_break prompt('-p', '-o', '-c') print(Color().white_dim(display_args)) print() options = { 'proxy': proxy, 'skip_download': True, 'extract_flat': True, 'dumpjson': True, 'logger': Ydl_W_Logger(), } playlist_url = f'https://www.youtube.com/playlist?list={playlist_id}' for attempt in attempts: should_break = False if attempt > 1: attempt_message = {'attempt': attempt} print(Color().blue(attempt_message)) try: ## https://stackoverflow.com/questions/53288922/youtube-dl-dump-json-returning-different-extractor-output-for-playlist-when-ca with YoutubeDL(options) as Y_DL: response = Y_DL.extract_info(playlist_url, download=False) entries = response['entries'] count = len(entries) for indx, entry in enumerate(entries, start=1): video_id = entry['id'] video_title = entry['title'] video_dur = entry['duration'] ## 500.0 video_uploader = entry['uploader'] text = f'## {indx}/{count} Duration: {duration(video_dur)} Title: {video_title}\nhttps://www.youtube.com/watch?v={video_id}' with open(output_file, 'a') as OUTPUT_FILE: OUTPUT_FILE.write(f'{text}\n') should_break = True except Exception as exc: analyze(f'{exc!r}') if should_break: break
def generate(): ## {{{ u = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' l = u.lower() d = '0123456789' s = '!@#$%^-*()_+={}\[\]\|\\;\':\",.<>/?`~' ## & intentionally exclided to prevent possible shell errors/problems ## with all the characters print('all characters:') up, lo, di, sy = True, True, True, True letters = '' if up: letters += u if lo: letters += l if di: letters += d if sy: letters += s global length if length > len(letters): print(Color().orange(f'Length exceeded maximumm number.\nLength is {len(letters)} now.')) length = len(letters) for x in range(5): password = ''.join(sample(letters, length)) print(password) print() ## with no symbols print('no symbols:') up, lo, di, sy = True, True, True, False letters = '' if up: letters += u if lo: letters += l if di: letters += d if sy: letters += s if length > len(letters): print(Color().orange(f'Length exceeded maximumm number.\nLength is {len(letters)} now.')) length = len(letters) for x in range(5): password = ''.join(sample(letters, length)) print(password)
def video_available_formats(): ## {{{ global attempt, should_break prompt('-u', '-i', '-c') print(Color().white_dim(display_args)) print() order = {} title, uploader, channel, dur, view_count, like_count, dislike_count, ext, outputname, error = Video( ).get_info() current = Video(order, get_datetime('jhms'), url, title, uploader, channel, dur, view_count, like_count, dislike_count, ext, outputname, error) for attempt in attempts: should_break = False if attempt == 1: attempt_message = current.info_dict else: attempt_message = {'attempt': attempt} print(Color().blue(attempt_message)) options = { 'no_color': True, ## better be uncommented, otherwise analyze can't read error messages properly 'proxy': proxy, 'skip_download': True, 'listformats': True, 'logger': Ydl_DW_Logger(), } try: with YoutubeDL(options) as Y_DL: Y_DL.download([url]) should_break = True except Exception as exc: analyze(f'{exc!r}', caller='video_available_formats') if should_break: break
def wget_bar( downloaded, total_bytes, width=80 ): ## https://www.itersdesktop.com/2020/09/06/downloading-files-in-python-using-wget-module/ downloaded_perc = downloaded / total_bytes * 100 downloaded_conv = convert_byte(downloaded) total_conv = convert_byte(total_bytes) download_info = { f'{downloaded_conv}/{total_conv}': f'%{downloaded_perc:.2f}' } print(Color().purple_dim(download_info), end='\r')
def restart_tor(): ## {{{ ## sometimes tor is restarted just because error is unknown although torsocks was not passed for o ## so we need the if statement here: if torsocks or file_type in ['v', 's', 'vs', 'a', 't']: if path.exists('/bin/pacman'): run('sudo systemctl restart tor', shell=True) elif path.exists('/bin/apt'): run('sudo service tor restart', shell=True) else: print(Color().red({'ERROR': 'TOR not restarted (unknown OS)'})) return dorm(15)
def single(): ## {{{ global outputname, attempt, should_break prompt('-f', '-u', '-d', '-i', '-c') order = {} if file_type in ['v', 's', 'vs', 'a', 't']: title, uploader, channel, dur, view_count, like_count, dislike_count, ext, outputname, error = Video( ).get_info() current = Video(order, get_datetime('jhms'), url, title, uploader, channel, dur, view_count, like_count, dislike_count, ext, outputname, error) elif file_type == 'o': size, raw_size, content_type, last_modified, outputname, error = File( ).get_info() current = File(order, get_datetime('jhms'), url, size, raw_size, content_type, last_modified, outputname, error) outputname = f'{hnd}/{outputname}' outputname = if_exists(outputname) current.outputname = sub(getenv('HOME'), '~', outputname) print(Color().white_dim(display_args)) print() for attempt in attempts: should_break = False if attempt == 1: attempt_message = current.info_dict else: attempt_message = {'attempt': attempt} print(Color().blue(attempt_message)) download(caller='single') if should_break: break
def analyze_locally(local_cmd_stderr: str): '''it is called "local" because it is meant for within download function o section only''' # global reason if 'ERROR' in local_cmd_stderr: reason = 'ERROR' elif 'IGNORED' in local_cmd_stderr: reason = 'IGNORED' elif 'ZeroDivisionError' in local_cmd_stderr: reason = '0 raw size' else: reason = f'UNKNOWN: {local_cmd_stderr}' print(Color().orange({'BAR ERROR': reason}))
def hook( response ): ## https://stackoverflow.com/questions/23727943/how-to-get-information-from-youtube-dl-in-python status = response['status'] ## try is used just to make sure nothing goes wrong try: ## we have to use the if statement and drop else and finally ## otherwise the download_info will be screwed when the exception swoops ## with a message like {'ERROR': "KeyError('speed')"} when download is finished if status == 'downloading': speed = response['speed'] speed = convert_byte(speed) elapsed = response['elapsed'] elapsed = duration(int(elapsed)) eta = response['eta'] eta = duration(int(eta)) total_bytes = response['total_bytes'] total_conv = convert_byte(total_bytes) downloaded_bytes = response['downloaded_bytes'] downloaded_conv = convert_byte(downloaded_bytes) downloaded_percent = (downloaded_bytes * 100) / total_bytes download_info = { f'{downloaded_conv}/{total_conv}': f'%{downloaded_percent:.2f}', 'speed': speed, 'elapsed': elapsed, 'ETA': eta } print(Color().purple_dim(download_info), end='\r') except Exception as exc: ## TODO analyze erros locally, like the one we do for o down below. We just need more examples of exc to use in analyze [14000223000000] download_info = {'ERROR': f'{status} {exc!r}'}
## with no symbols print('no symbols:') up, lo, di, sy = True, True, True, False letters = '' if up: letters += u if lo: letters += l if di: letters += d if sy: letters += s if length > len(letters): print(Color().orange(f'Length exceeded maximumm number.\nLength is {len(letters)} now.')) length = len(letters) for x in range(5): password = ''.join(sample(letters, length)) print(password) ## }}} getopts() print(Color().heading(title)) main_items = ['password', 'help'] main_item = fzf(main_items) if main_item == 'password': prompt('-l') generate() elif main_item == 'help': help()
def download(caller: str): ## {{{ global should_break if file_type in ['v', 's', 'vs', 'a', 't']: ## {{{ ## useful links {{{ ## https://stackoverflow.com/questions/18054500/how-to-use-youtube-dl-from-a-python-program ## https://github.com/ytdl-org/youtube-dl/blob/master/README.md#embedding-youtube-dl ## https://github.com/ytdl-org/youtube-dl/blob/3e4cedf9e8cd3157df2457df7274d0c842421945/youtube_dl/YoutubeDL.py#L137-L312 (available options) ## https://github.com/ytdl-org/youtube-dl/blob/master/youtube_dl/YoutubeDL.py#L128-L278 (available options) ## https://www.programcreek.com/python/example/98358/youtube_dl.YoutubeDL ## }}} ## FIXME does not display for s and t [14000223000000] def hook( response ): ## https://stackoverflow.com/questions/23727943/how-to-get-information-from-youtube-dl-in-python status = response['status'] ## try is used just to make sure nothing goes wrong try: ## we have to use the if statement and drop else and finally ## otherwise the download_info will be screwed when the exception swoops ## with a message like {'ERROR': "KeyError('speed')"} when download is finished if status == 'downloading': speed = response['speed'] speed = convert_byte(speed) elapsed = response['elapsed'] elapsed = duration(int(elapsed)) eta = response['eta'] eta = duration(int(eta)) total_bytes = response['total_bytes'] total_conv = convert_byte(total_bytes) downloaded_bytes = response['downloaded_bytes'] downloaded_conv = convert_byte(downloaded_bytes) downloaded_percent = (downloaded_bytes * 100) / total_bytes download_info = { f'{downloaded_conv}/{total_conv}': f'%{downloaded_percent:.2f}', 'speed': speed, 'elapsed': elapsed, 'ETA': eta } print(Color().purple_dim(download_info), end='\r') except Exception as exc: ## TODO analyze erros locally, like the one we do for o down below. We just need more examples of exc to use in analyze [14000223000000] download_info = {'ERROR': f'{status} {exc!r}'} options = { # 'outtmpl': '%(title)s--%(width)sx%(height)s-f%(format_id)s.%(ext)s', 'outtmpl': f'{outputname}.%(ext)s', 'no_color': True, ## better be uncommented, otherwise analyze can't display error messages properly 'proxy': proxy, 'nooverwrites': True, 'progress_hooks': [hook], 'logger': Ydl_W_Logger(), } if downloader: options = { **options, 'external_downloader': 'curl' } ## <--,-- wget throws DownloadError('ERROR: wget exited with code 8') for v, vs and a but wrorks well for s and t ## '-- axel throws DownloadError('ERROR: axel exited with code 1') for v, vs and a but wrorks well for s and t langs = ['en', 'en-AU', 'en-CA', 'en-GB', 'en-IE', 'en-NZ', 'en-US'] if file_type == 'v': options = {**options, 'format': video_quality} elif file_type == 's': options = { **options, 'writesubtitles': True, 'writeautomaticsub': True, 'subtitleslangs': langs, 'skip_download': True } elif file_type == 'vs': options = { **options, 'writesubtitles': True, 'writeautomaticsub': True, 'subtitleslangs': langs, 'format': video_quality } elif file_type == 'a': options = { **options, 'format': 'bestaudio', 'postprocessors': [{ 'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3' }] } elif file_type == 't': options = { **options, 'write_all_thumbnails': True, 'writethumbnail': True, 'skip_download': True } try: with YoutubeDL(options) as Y_DL: Y_DL.download([url]) print() ## to prevent the removal of download_info should_break = True except Exception as exc: analyze(f'{exc!r}', caller=caller) ## }}} elif file_type == 'o': ## {{{ def check_raw_size_validity(): global total_bytes total_bytes = int(raw_size) ## make sure raw_size is an int _ = 1 / raw_size ## make sure raw_size is not 0 def analyze_locally(local_cmd_stderr: str): '''it is called "local" because it is meant for within download function o section only''' # global reason if 'ERROR' in local_cmd_stderr: reason = 'ERROR' elif 'IGNORED' in local_cmd_stderr: reason = 'IGNORED' elif 'ZeroDivisionError' in local_cmd_stderr: reason = '0 raw size' else: reason = f'UNKNOWN: {local_cmd_stderr}' print(Color().orange({'BAR ERROR': reason})) try: if downloader: def wget_bar( downloaded, total_bytes, width=80 ): ## https://www.itersdesktop.com/2020/09/06/downloading-files-in-python-using-wget-module/ downloaded_perc = downloaded / total_bytes * 100 downloaded_conv = convert_byte(downloaded) total_conv = convert_byte(total_bytes) download_info = { f'{downloaded_conv}/{total_conv}': f'%{downloaded_perc:.2f}' } print(Color().purple_dim(download_info), end='\r') if torsocks: ## FIXME find how to set proxy for wget [14000314155813] continue_without_proxy = get_single_input( 'Setting torsocks for downloader for o is not possible at the moment. Continue without proxy?' ) if not continue_without_proxy == 'y': exit() ## File.get_info() may send raw_size as IGNORED (if requested so), ERROR or 0, therefore try is needed here ## wget is able to get downloaded and total_bytes on the go anyway, but it is better to respct the info already present try: check_raw_size_validity() wget_download(url=url, out=outputname, bar=wget_bar) except Exception as exc: analyze_locally(f'{exc!r}') wget_download(url=url, out=outputname) #, bar=wget_error_bar) else: if torsocks: s.proxies = {'http': proxy, 'https': proxy} ## https://stackoverflow.com/questions/16694907/download-large-file-in-python-with-requests with s.get(url, headers=hdrs, timeout=20, stream=True) as SESSION: SESSION.raise_for_status() with open(outputname, 'wb') as OUTPUTNAME: chunksize = 8192 ## 8192 is 8KB downloaded_bytes = 0 ## File.get_info() may send raw_size as IGNORED (if requested so), ERROR or 0, therefore try is needed here try: check_raw_size_validity() total_conv = convert_byte(total_bytes) for chunk in SESSION.iter_content( chunk_size=chunksize): OUTPUTNAME.write(chunk) downloaded_bytes += chunksize downloaded_conv = convert_byte( downloaded_bytes) downloaded_percent = ( downloaded_bytes * 100 ) / total_bytes ## FIXME <--,-- exceeds 100 [14000223000000] ## |-- also, adding if chunk: before OUTPUTNAME.write(chunk) ## '-- prevents downloaded_percent from reaching 100 download_info = { f'{downloaded_conv}/{total_conv}': f'%{downloaded_percent:.2f}' } print( Color().purple_dim(download_info), end='\r' ) ## we can't move this line to finally because bytes are written chunk by chunk except Exception as exc: analyze_locally(f'{exc!r}') for chunk in SESSION.iter_content( chunk_size=chunksize): OUTPUTNAME.write(chunk) print() ## to prevent the removal of download_info should_break = True except Exception as exc: analyze(f'{exc!r}', caller=caller)
def analyze(cmd_stderr: str, caller: str = ''): ## {{{ global reason, should_break, video_quality, failed_sites, order, url, attempt, downloader should_restart_tor = False conditions = [ 'ERROR' not in cmd_stderr, 'Error' not in cmd_stderr, 'WARNING' not in cmd_stderr, 'Could not resolve host' not in cmd_stderr, 'Unable to connect to server' not in cmd_stderr, 'unable to resolve host address' not in cmd_stderr ] no_errors = all(conditions) if no_errors: should_break = True return ## possible errors (i.e. keys) and what we should do (i.e. values) if they happen errors = { ## <<< mainly happening in youtube-dl >>> 'No video formats found': '', ## 'break' not necessary because it was seen to be running ok in the next attempt 'Too Many Requests': 'restart', 'requested format not available': 'best', 'PERROR torsocks': 'break', 'video doesnt have subtitles': 'break', 'No subtitle format found matching': 'break', 'Unable to extract video title': 'break', 'is not a valid URL': 'break', 'This video is unavailable': 'break', 'This video is not available': 'break', 'YouTube said: Invalid parameters': 'break', 'unable to download video data': 'break', 'No video results': 'break', 'An extractor error has occurred': 'break', 'Unsupported URL': 'break', 'Signature extraction failed': 'break', 'Incorrect padding': 'break', '404 Not Found': 'break', '403: Forbidden': 'break', 'unable to resolve host address': 'break', 'Could not resolve host': 'break', 'Unable to connect to server': 'break', ## <<< mainly happening in youtube_dl and wget modules >>> 'Connection refused': 'break', ## happened when tor is off ## Full: Unable to download webpage: <urlopen error [Errno 111] Connection refused> 'ConnectionError': 'break', ## happened for o when url (domain part) is misspelled (e.g. https://www.davoni.ir/Files/Fun/002.jpg) ## Full: ConnectionError(MaxRetryError("HTTPSConnectionPool(host=\'www.davoni.ir\', port=443): Max retries exceeded with url: /Files/Fun/002.jp ## (Caused by NewConnectionError(\'<urllib3.connection.HTTPSConnection object at 0x7f3a26fcba60>: ## Failed to establish a new connection: [Errno -2] Name or service not known\'))")) 'ConnectTimeout': 'break', ## happened for File.get_info() when url is censored ## Full: ConnectTimeout(MaxRetryError("HTTPConnectionPool(host=\'wsdownload.bbc.co.uk\', port=80): Max retries exceeded with url: ## /learningenglish/pdf/2014/09/140924101602_140924_vwitn_inmates_bank.pdf (Caused by ConnectTimeoutError(<urllib3.connection.HTTPConnection ## object at 0x7fcb37ab2b20>, \'Connection to wsdownload.bbc.co.uk timed out. (connect timeout=20)\'))")) 'DownloadError': 'break', ## Full: DownloadError("ERROR: Unable to download API page: Remote end closed connection without response (caused by RemoteDisconnected(\'Remote ## and closed connection without response\')); please report this issue on https://yt-dl.org/bug . Make sure you are using the latest version; ## see https://yt-dl.org/update on how to update. Be sure to call youtube-dl with the --verbose flag and include its complete output.") ## ## happened when youtube url is incomplete ## Full: DownloadError('ERROR: Incomplete YouTube ID gllU. URL https://www.youtube.com/watch?v=gllU looks truncated.') ## ## happened when youtube video is private ## Full: DownloadError("ERROR: Private video\\nSign in if you\'ve been granted access to this video") 'HTTPError': 'break', ## happened for o when url (file name part) is misspelled (e.g. https://www.davoudarsalani.ir/Files/Fun/002.jp) ## Full: HTTPError('404 Client Error: Not Found for url: https://www.davoudarsalani.ir/Files/Fun/002.jp') ## ## happened for o when url is censored ## Full: HTTPError('403 Client Error: Forbidden for url: http://open.live.bbc.co.uk/p09cn7sc.mp3') 'IndexError': 'break', ## happened for Video.get_info() when url is misspelled (e.g. it's incomplete) private and can't get info, and therefore 'items' list is empty ## Full: IndexError('list index out of range') 'URLError': 'break', ## happened for File.get_info() when url is censored ## Full: URLError(ConnectionResetError(104, 'Connection reset by peer')) ## ## happened for File.get_info() with wget module when url (domain part) is missplelled (e.g. https://www.davoni.ir/Files/Fun/002.jpg) ## Full: URLError(gaierror(-2, 'Name or service not known')) 'ExtractorError': 'break', ## Full: ExtractorError('No video formats found') 'RegexNotFoundError': 'break', ## Full: RegexNotFoundError('Unable to extract Initial JS player signature function name; please report this issue on https://yt-dl.org/bug . ## Make sure you are using the latest version; type youtube-dl -U to update. Be sure to call youtube-dl with the --verbose flag ## and include its complete output.',)); please report this issue on https://yt-dl.org/bug . Make sure you are using the latest version; ## type youtube-dl -U to update. Be sure to call youtube-dl with the --verbose flag and include its complete output. ## <<< happening in error files (i.e. error files of bwu, weather, etc. in $HOME/scripts/.last directory) just to add more examples >>> 'AttributeError': 'break', ## Full: AttributeError("'NoneType' object has no attribute 'text'") 'OSError': 'break', ## Full: OSError(101, 'Network is unreachable') 'ReadTimeout': 'break', ## Full: ReadTimeout(ReadTimeoutError("HTTPSConnectionPool(host='api.openweathermap.org', port=443): Read timed out. (read timeout=20)")) 'ServerNotFoundError': 'break', ## Full: ServerNotFoundError('Unable to find the server at youtube.googleapis.com') 'SSLCertVerificationError': 'break', ## Full: SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain (_ssl.c:1123)') 'SSLEOFError': 'break', ## Full: SSLEOFError(8, 'EOF occurred in violation of protocol (_ssl.c:1123)') 'SSLError': 'break', ## Full: SSLError(MaxRetryError("HTTPSConnectionPool(host='<ADDR>', port=443): Max retries exceeded with url: <ADDR> (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self signed certificate in certificate chain (_ssl.c:1123)')))")) 'TypeError': 'break', ## Full: TypeError('Missing required parameter "part"') 'TimeoutError': 'break', ## Full: TimeoutError(110, 'Connection timed out') 'timeout': 'break', ## Full: <--,-- timeout('_ssl.c:1112: The handshake operation timed out') ## '-- timeout('timed out') } if attempt < len(attempts): for err_key, err_value in errors.items(): if err_key in cmd_stderr: if complete_error: reason = cmd_stderr else: reason = err_key if err_value == 'break': should_break = True elif err_value == 'restart': should_restart_tor = True elif err_value == 'best': video_quality = 'best' break else: reason = f'UNKNOWN: {cmd_stderr}' should_restart_tor = True elif attempt == len(attempts): reason = f'Failed after {attempt} attempts. Last error: {cmd_stderr}' should_break = True ## this is the last attempt and the loop will automatically break anyawy, but it is needed here for the url to be added to failed_sites ## now let's see what we should do if caller == 'bulk': append_to_log(str({'ERROR': reason}) + '\n') if should_break: failed_sites.append({**order, 'url': url, 'ERROR': reason}) ## print error if not caller == 'get_info': print(Color().red({'ERROR': reason})) if should_restart_tor: restart_tor()
def warning(self, msg): ## we don't want to see warnings like 'en-AU subtitles not available for WpqCLcAXkJs' for s and vs if not 'subtitles not available for' in msg: append_to_log(str({'WARNING': msg}) + '\n') print(Color().yellow_dim({'WARNING': msg}))
def bulk(): ## {{{ global dest_dir, log_file, outputname, failed_sites, order, should_break, attempt, urls, url, x_value failed_sites = [] prompt('-w', '-f', '-s', '-x', '-d', '-i', '-c') mkdir(dest_dir) chdir(dest_dir) log_file = f'{dest_dir}/log' append_to_log(str(display_args) + '\n\n') print(Color().white_dim(display_args)) print() while True: permission = check_to_start() if when == 'h' and not permission: ## not yet append_to_log(str({'time': get_datetime('jhms')}) + '\n') print(Color().brown({'time': get_datetime('jhms')})) dorm(60) else: for url in urls: urls_length = len(urls) nth = urls.index(url) + 1 perc = int(nth * 100 / urls_length) order = {f'{nth}/{urls_length}': f'%{perc}'} if file_type in ['v', 's', 'vs', 'a', 't']: title, uploader, channel, dur, view_count, like_count, dislike_count, ext, outputname, error = Video( ).get_info() current = Video(order, get_datetime('jhms'), url, title, uploader, channel, dur, view_count, like_count, dislike_count, ext, outputname, error) elif file_type == 'o': size, raw_size, content_type, last_modified, outputname, error = File( ).get_info() current = File(order, get_datetime('jhms'), url, size, raw_size, content_type, last_modified, outputname, error) outputname = f'{dest_dir}/{outputname}' current.outputname = sub(getenv('HOME'), '~', outputname) for attempt in attempts: should_break = False should_exit = check_to_exit() if when == 'h' and should_exit: append_to_log('\nHappy hours over. Exit.\n') print('\nHappy hours over. Exit.') exit() if attempt == 1: attempt_message = current.info_dict else: attempt_message = {'attempt': attempt} append_to_log(f'{attempt_message}\n') print(Color().blue(attempt_message)) url_duration = download(caller='bulk') if should_break: break ## END attempt raw_dest_dir_size = dest_dir_size() converted_dest_dir_size = convert_byte(raw_dest_dir_size) append_to_log( str({ 'url duration': url_duration, 'dest dir size': converted_dest_dir_size }) + '\n') print(Color().brown({ 'url duration': url_duration, 'dest dir size': converted_dest_dir_size })) append_to_log('-' * 60 + '\n') print(separator()) if x_value: x_value += 1 ## END urls ## fails report if failed_sites: table_header = [f'{len(failed_sites)} fails'] table_rows = [[failed_site] for failed_site in failed_sites] failed_table = draw_table(table_rows, table_header) append_to_log(f'{failed_table}\n') print(Color().red(failed_table)) break