import os import sys import urllib.request import urllib.parse import re import time import random import datetime from threading import Thread import queue import math from functools import partial from queue import Empty import tqdm sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from suvi import db, index, paths, vfs directory_url = r"https://data.ngdc.noaa.gov/platforms/solar-space-observing-satellites/goes/" stored_images_dir = paths.data_root() ignore_folder_names = ["Parent Directory", "l1b", "goes17", "2017", "2018", "2019", "2020", "2021", "2022", "2023"] fetch_interval = 0 # 60*60 nfetchworkers = 2 # Be nice to the servers, this value is how many threads will be asking for links and file info at the same time ndownloadworkers = 3 # Be nice to the servers, this value is how many threads will be downloading files at the same time randomize_order = False links_regex_pattern = r'(?<=.*\d{4}-\d{2}-\d{2} \d{2}:\d{2})' # Find href links that do not contain question marks or whitespace times_regex_pattern = r'(?<=)(\d{4}-\d{2}-\d{2} \d{2}:\d{2})(?= )' # Find timestamps in UTC in the format YYYY-MM-DD HH:mm sizes_regex_pattern = r'(?<=\d{4}-\d{2}-\d{2} \d{2}:\d{2} <\/td>)([ .\d]{1,3})([KMG]|- |0 | )(?=<\/td>)' # Find file size markers in one of many possible formats links_matcher = re.compile(links_regex_pattern) times_matcher = re.compile(times_regex_pattern) sizes_matcher = re.compile(sizes_regex_pattern) def find_links_worker(query_work_queue, query_result_queue, attempt_count = 3, randomize_order = True): while True: job = query_work_queue.get() if job == None: return url= job attempts = 0 html_content = None while attempts < attempt_count: try: with urllib.request.urlopen(url) as response: html_content = response.read().decode('utf-8') break except Exception as e: tqdm.tqdm.write(f"Exception while fetching links: {e}") time.sleep(1 + random.random()) attempts += 1 if (attempt_count > 1) and (attempts == attempt_count): tqdm.tqdm.write(f'\nAfter {attempt_count} retries, could not fetch: {url}') if html_content is None: # We failed to fetch the link for some reason, continue to the next job continue else: links = links_matcher.findall(html_content) times = times_matcher.findall(html_content) sizes = sizes_matcher.findall(html_content) for i in range(len(times)): dt = datetime.datetime.strptime(times[i].strip(), "%Y-%m-%d %H:%M") dt.replace(tzinfo=datetime.timezone.utc) times[i] = time.mktime(dt.timetuple()) for i in range(len(sizes)): match sizes[i][1].strip(): case '-': sizes[i] = 0 case 'K': sizes[i] = int(float(sizes[i][0].strip()) * 1024) case 'M': sizes[i] = int(float(sizes[i][0].strip()) * 1024 * 1024) case 'G': sizes[i] = int(float(sizes[i][0].strip()) * 1024 * 1024 * 1024) case "0": sizes[i] = 0 case "": sizes[i] = int(float(sizes[i][0].strip())) case _: raise(ValueError(f"Unexpected symbol while parsing links page: {_}")) if (len(links) != len(times)) or (len(times) != len(sizes)): raise(ValueError("Links parsing error, mismatched numbers of links, times, or sizes!")) results = list(zip(links, times, sizes)) if randomize_order: random.shuffle(results) for _link, _time, _size in results: if _link.endswith("/"): if _link.split(r"/")[-2] in ignore_folder_names: continue else: # print(f"Queueing folder: {urllib.parse.urljoin(url,_link)}") query_work_queue.put(urllib.parse.urljoin(url,_link)) else: # print(f"Queueing file: {urllib.parse.urljoin(url,_link)}") query_result_queue.put((urllib.parse.urljoin(url,_link), _time, _size)) # Fetch file from url and store it to path, retrying on failure def file_download_worker(download_work_queue, download_result_queue, attempt_count = 2): while True: job = download_work_queue.get() if job == None: return url, path, t, s = job # print(f"Next job: {url}, {path}, {t}, {s}") attempts = 0 while attempts < attempt_count: try: starttime = time.time() req = urllib.request.Request(url, data=None) image_data = urllib.request.urlopen(req, timeout=10.0).read() endtime = time.time() tqdm.tqdm.write(f"Downloaded {url} in {endtime-starttime:0.2f} s | {len(image_data)/1024/1024/(endtime-starttime):0.2f} MB/s") if math.isclose(len(image_data), s, rel_tol=0.05): os.makedirs(os.path.split(path)[0], exist_ok=True) open(path, 'wb').write(image_data) download_result_queue.put((True, url, t)) break else: raise ValueError("Downloaded file is the wrong size!") except Exception as e: if hasattr(e, "code") and e.code == 404: # This is expected if the file has been removed from the site (at least for swpc.noaa.gov) attempts += attempt_count elif (0 < attempts) and (attempts < attempt_count): # tqdm.tqdm.write(f"\nA problem occurred on file: {url} | {e}") time.sleep(1 + random.random()) attempts += 1 if (attempt_count > 1) and (attempts == attempt_count): tqdm.tqdm.write(f'\nAfter {attempt_count} retries, could not fetch: {url}') tqdm.tqdm.write(f"Exception: {e}") download_result_queue.put((False, url, t)) break if __name__ == "__main__": import argparse parser = argparse.ArgumentParser( description="Mirror NOAA GOES SUVI L2 FITS into the local archive.", ) parser.add_argument("--db", default=None, help="SQLite index (default: $SUVI_DB)") parser.add_argument("--root", default=None, help="archive root (default: $SUVI_DATA_ROOT)") parser.add_argument("--interval", type=int, default=fetch_interval, help="seconds to sleep between passes; 0 runs once and exits") args = parser.parse_args() if args.root: stored_images_dir = os.path.abspath(args.root) fetch_interval = args.interval # Download bookkeeping lives in the index. It used to be an 800 MB JSON object # parsed into memory on every run; see migrate_urlcache.py for the conversion. # Losing this state means re-fetching the whole archive, so writes are committed # as they happen rather than only at the end of a pass. conn = db.connect(args.db) known = conn.execute("SELECT count(*) c FROM remote_file").fetchone()["c"] print(f"Download records in index: {known}") query_work_queue = queue.Queue() query_result_queue = queue.Queue() download_work_queue = queue.Queue(maxsize=ndownloadworkers) download_result_queue = queue.Queue(maxsize=ndownloadworkers) workers = [] for _ in range(nfetchworkers): t = Thread(target=find_links_worker, args=(query_work_queue, query_result_queue, 3, randomize_order), daemon=True) t.start() workers.append(t) for _ in range(ndownloadworkers): t = Thread(target=file_download_worker, args=(download_work_queue, download_result_queue), daemon=True) t.start() workers.append(t) fetched_image_count = already_had_image_count = failed_image_count = 0 pending_records = [] def flush_records(force=False): """Commit download records in batches, and always before a long pause.""" if pending_records and (force or len(pending_records) >= 200): db.record_remote_files(conn, pending_records) conn.commit() pending_records.clear() reliever = vfs.Reliever(label="puller") def index_frame(local_path): reliever.tick() """Record a freshly written frame in the index. The downloader already knows this file exists, so indexing it here means the index never has to be rebuilt by traversing the archive -- which on this virtiofs mount is an operation to be avoided rather than merely optimised (see suvi/index.py). Failure to index is not worth losing a download over; `filter_FITS.py index` will pick the frame up from its directory mtime. """ try: index.record_downloaded(conn, local_path, stored_images_dir) except Exception as exc: tqdm.tqdm.write(f"Could not index {local_path}: {exc}") try: while True: fetched_image_count = 0 already_had_image_count = 0 failed_image_count = 0 urllen = len(directory_url) query_work_queue.put(directory_url) for l, t, s in tqdm.tqdm(iter(partial(query_result_queue.get, timeout=30.0), None), desc="Downloading files"): # Collect completed jobs and record completion status while True: try: r_success, r_url, r_t = download_result_queue.get_nowait() if r_success: fetched_image_count += 1 pending_records.append((r_url, r_t, None, None, time.time())) index_frame(os.path.join(stored_images_dir, r_url[urllen:].replace("/", os.sep))) else: failed_image_count += 1 except queue.Empty: break flush_records() file_portion_of_link = l[urllen:] filepath = os.path.join(stored_images_dir, file_portion_of_link.replace("/", os.sep)) recorded_mtime = db.get_remote_mtime(conn, l) # The archive no longer carries _f/_e suffixes, so a frame is either # at its published filename or absent -- no variant probing needed. if recorded_mtime is None or t > recorded_mtime: if os.path.exists(filepath): if recorded_mtime is not None: # We have a record, so the remote copy is newer: replace it. try: os.remove(filepath) except OSError: pass else: # No record, but the file is here. Adopt it if the size # matches rather than re-downloading the whole archive. if math.isclose(os.path.getsize(filepath), s, rel_tol=0.05): pending_records.append((l, t, s, filepath, time.time())) index_frame(filepath) already_had_image_count += 1 continue tqdm.tqdm.write(f"Size mismatch on {filepath}; redownloading") download_work_queue.put((l, filepath, t, s)) elif os.path.exists(filepath): already_had_image_count += 1 else: # Recorded as downloaded but gone from disk; fetch it again. download_work_queue.put((l, filepath, t, s)) flush_records(force=True) print(f"Downloaded {fetched_image_count} | Already had {already_had_image_count} | Failed {failed_image_count}") if fetch_interval > 0: print(f"Run complete!, sleeping for {fetch_interval} seconds.") time.sleep(fetch_interval) else: print("Run complete!, exiting...") break except KeyboardInterrupt: print("Saving download records and shutting down.") except Empty: print("Work Complete, Shutting down...") for _ in range(nfetchworkers): try: query_work_queue.put(None) except Exception: break time.sleep(1) print(f"Downloaded {fetched_image_count} | Already had {already_had_image_count} | Failed {failed_image_count}") while len(download_work_queue.queue) > 0: print(f"Waiting for {len(download_work_queue.queue)} downloads in queue...") time.sleep(1) for _ in range(ndownloadworkers): try: download_work_queue.put(None, timeout=5.0) except Exception: break # Drain any results the workers finished after the main loop ended. while True: try: r_success, r_url, r_t = download_result_queue.get_nowait() if r_success: pending_records.append((r_url, r_t, None, None, time.time())) index_frame(os.path.join(stored_images_dir, r_url[len(directory_url):].replace("/", os.sep))) except queue.Empty: break flush_records(force=True) conn.close() print("Waiting for workers to shutdown...") for w in workers: w.join(5.0)