2026-09-01 22:36:13 -04:00
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__ ) ) )
2026-10-14 00:27:18 -04:00
from suvi import db , index , paths
2026-09-01 22:36:13 -04:00
directory_url = r " https://data.ngdc.noaa.gov/platforms/solar-space-observing-satellites/goes/ "
stored_images_dir = paths . data_root ( )
2026-10-14 00:27:18 -04:00
ignore_folder_names = [ " Parent Directory " , " l1b " , " goes17 " , " 2017 " , " 2018 " , " 2019 " , " 2020 " , " 2021 " , " 2022 " ]
2026-09-01 22:36:13 -04:00
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 ' (?<=<a href= " )([^ ?:]*)(?= " >.* \ 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 ' (?<=<td align= " right " >)( \ 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><td align= " right " >)([ . \ 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 )
2026-10-14 00:27:18 -04:00
def parse_listing ( html_content ) :
""" Parse one Apache index page into [(link, mtime, size), ...].
Listing timestamps are UTC , so they are read as UTC . Reading them as local
time - - which is what a bare ` ` mktime ` ` does - - makes every recorded mtime an
artefact of the machine ' s timezone, and moving the archive to a box further
east would then make the whole archive look newer than its records and be
re - downloaded on top of itself .
Raises ValueError if the page does not parse , which the caller treats as a
directory it failed to list rather than as a fatal error .
"""
links = links_matcher . findall ( html_content )
times = times_matcher . findall ( html_content )
sizes = sizes_matcher . findall ( html_content )
if ( len ( links ) != len ( times ) ) or ( len ( times ) != len ( sizes ) ) :
raise ValueError (
f " mismatched links/times/sizes: { len ( links ) } / { len ( times ) } / { len ( sizes ) } "
)
for i in range ( len ( times ) ) :
dt = datetime . datetime . strptime ( times [ i ] . strip ( ) , " % Y- % m- %d % H: % M " )
times [ i ] = dt . replace ( tzinfo = datetime . timezone . utc ) . timestamp ( )
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 size symbol while parsing links page: { _ } " )
return list ( zip ( links , times , sizes ) )
def find_links_worker ( query_work_queue , query_result_queue , crawl_errors ,
attempt_count = 3 , randomize_order = True ) :
""" Crawl directory listings, queueing subdirectories and file records.
Every directory this fails to fetch or parse is appended to ` crawl_errors `
instead of raising . An exception here used to escape into a daemon thread and
kill it silently ; with only ` nfetchworkers ` of them , one malformed page could
end the crawl while the run still reported success .
"""
2026-09-01 22:36:13 -04:00
while True :
job = query_work_queue . get ( )
if job == None :
return
url = job
2026-10-14 00:27:18 -04:00
2026-09-01 22:36:13 -04:00
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 ' \n After { 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
2026-10-14 00:27:18 -04:00
crawl_errors . append ( ( url , " could not be fetched " ) )
2026-09-01 22:36:13 -04:00
continue
2026-10-14 00:27:18 -04:00
try :
results = parse_listing ( html_content )
except Exception as e :
tqdm . tqdm . write ( f " \n Could not parse listing { url } : { e } " )
crawl_errors . append ( ( url , f " could not be parsed: { e } " ) )
continue
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
2026-09-01 22:36:13 -04:00
else :
2026-10-14 00:27:18 -04:00
# 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 ) )
2026-09-01 22:36:13 -04:00
# 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 ) :
2026-10-14 00:27:18 -04:00
""" Fetch each queued URL, writing it into place only once it is complete.
Exactly ` attempt_count ` attempts are made and exactly one result is reported ,
so a URL that keeps failing releases the worker instead of holding it . The
counter used to go un - incremented on the first failure , which turned any
non - 404 error into an unbounded , undelayed retry loop : three such URLs pinned
all three workers and stalled the pass with no output .
The body is written to a sibling ` . part ` file and renamed over the target , so
a frame is only ever replaced by a complete download . An interrupted fetch
leaves the existing copy intact .
"""
2026-09-01 22:36:13 -04:00
while True :
job = download_work_queue . get ( )
if job == None :
return
url , path , t , s = job
# print(f"Next job: {url}, {path}, {t}, {s}")
2026-10-14 00:27:18 -04:00
for attempt in range ( attempt_count ) :
partial = path + " .part "
2026-09-01 22:36:13 -04:00
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 " )
2026-10-14 00:27:18 -04:00
if not math . isclose ( len ( image_data ) , s , rel_tol = 0.05 ) :
raise ValueError ( f " Downloaded file is the wrong size: { len ( image_data ) } bytes, listing said { s } " )
os . makedirs ( os . path . split ( path ) [ 0 ] , exist_ok = True )
with open ( partial , ' wb ' ) as handle :
handle . write ( image_data )
os . replace ( partial , path )
download_result_queue . put ( ( True , url , t ) )
break
2026-09-01 22:36:13 -04:00
except Exception as e :
2026-10-14 00:27:18 -04:00
try :
os . remove ( partial )
except OSError :
pass
# A 404 is expected once a file has been withdrawn from the site, and
# retrying will not bring it back.
expired = getattr ( e , " code " , None ) == 404
if not expired and ( attempt + 1 < attempt_count ) :
2026-09-01 22:36:13 -04:00
# tqdm.tqdm.write(f"\nA problem occurred on file: {url} | {e}")
time . sleep ( 1 + random . random ( ) )
2026-10-14 00:27:18 -04:00
continue
if not expired :
tqdm . tqdm . write ( f ' \n After { attempt_count } attempts, could not fetch: { url } ' )
tqdm . tqdm . write ( f " Exception: { e } " )
download_result_queue . put ( ( False , url , t ) )
break
2026-09-01 22:36:13 -04:00
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 } " )
2026-10-14 00:27:18 -04:00
# Listing times are now read as UTC; the records written before that fix hold
# local-time epochs. Until migrate_mtime_utc.py has normalised them, every
# recorded mtime reads as later than the listing says, so an updated file is
# skipped rather than re-fetched. That is the harmless direction to fail in --
# nothing is deleted or re-downloaded -- but it is not a state to sit in quietly.
if known and db . get_meta ( conn , " remote_mtime_utc " ) != " done " :
print ( " WARNING: remote_file mtimes have not been migrated to UTC. " )
print ( " Updated files will not be re-fetched until you run: " )
print ( " migrate_mtime_utc.py --apply (with the puller stopped) " )
crawl_errors = [ ]
2026-09-01 22:36:13 -04:00
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 ) :
2026-10-14 00:27:18 -04:00
t = Thread ( target = find_links_worker , args = ( query_work_queue , query_result_queue , crawl_errors , 3 , randomize_order ) , daemon = True )
2026-09-01 22:36:13 -04:00
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 = [ ]
2026-10-14 00:27:18 -04:00
urllen = len ( directory_url )
2026-09-01 22:36:13 -04:00
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 ( )
def index_frame ( local_path ) :
""" 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 } " )
2026-10-14 00:27:18 -04:00
def drain_results ( ) :
""" Record every download the workers have finished reporting on. """
global fetched_image_count , failed_image_count
while True :
try :
r_success , r_url , r_t = download_result_queue . get_nowait ( )
except queue . Empty :
return
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
2026-09-01 22:36:13 -04:00
try :
while True :
fetched_image_count = 0
already_had_image_count = 0
failed_image_count = 0
2026-10-14 00:27:18 -04:00
crawl_errors . clear ( )
2026-09-01 22:36:13 -04:00
query_work_queue . put ( directory_url )
2026-10-14 00:27:18 -04:00
# A quiet result queue is how a pass ends: nobody queues a sentinel, so
# the crawl is over once no worker has produced a file for 30 seconds.
# Catching that here rather than around the whole loop is what lets a
# pass report its own result -- and lets --interval actually repeat.
try :
for l , t , s in tqdm . tqdm ( iter ( partial ( query_result_queue . get , timeout = 30.0 ) , None ) , desc = " Downloading files " ) :
drain_results ( )
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 None :
# 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 " )
# Otherwise the remote copy is newer. The existing file is
# left alone: the worker renames the replacement over it once
# the download is complete, so a failed fetch costs us nothing.
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 ) )
except Empty :
pass
2026-09-01 22:36:13 -04:00
2026-10-14 00:27:18 -04:00
drain_results ( )
2026-09-01 22:36:13 -04:00
flush_records ( force = True )
print ( f " Downloaded { fetched_image_count } | Already had { already_had_image_count } | Failed { failed_image_count } " )
2026-10-14 00:27:18 -04:00
# Say so when the pass did not cover the archive. Unlisted directories
# are invisible in the counts above -- their files were never considered
# at all -- so without this an aborted crawl looks like a clean one.
if crawl_errors :
print ( f " WARNING: pass was incomplete -- { len ( crawl_errors ) } directories could not be listed: " )
for bad_url , reason in crawl_errors [ : 10 ] :
print ( f " { bad_url } ( { reason } ) " )
if len ( crawl_errors ) > 10 :
print ( f " ... and { len ( crawl_errors ) - 10 } more " )
if not query_work_queue . empty ( ) :
print ( f " WARNING: pass ended with { query_work_queue . qsize ( ) } directories still queued; "
" the crawl was quiet for 30 s while work remained. " )
2026-09-01 22:36:13 -04:00
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. " )
for _ in range ( nfetchworkers ) :
try :
query_work_queue . put ( None )
except Exception :
break
2026-10-14 00:27:18 -04:00
# Shut the download workers down, draining results throughout: a worker that
# finishes while the result queue is full would otherwise block there forever,
# and a frame whose result is never read is a frame we re-discover next pass.
print ( " Waiting for in-flight downloads... " )
deadline = time . time ( ) + 60.0
sentinels = ndownloadworkers
while sentinels and time . time ( ) < deadline :
drain_results ( )
2026-09-01 22:36:13 -04:00
try :
2026-10-14 00:27:18 -04:00
download_work_queue . put ( None , timeout = 1.0 )
sentinels - = 1
except queue . Full :
continue
while any ( w . is_alive ( ) for w in workers ) and time . time ( ) < deadline :
drain_results ( )
time . sleep ( 0.5 )
drain_results ( )
2026-09-01 22:36:13 -04:00
flush_records ( force = True )
conn . close ( )
2026-10-14 00:27:18 -04:00
print ( f " Downloaded { fetched_image_count } | Already had { already_had_image_count } | Failed { failed_image_count } " )
2026-09-01 22:36:13 -04:00
print ( " Waiting for workers to shutdown... " )
for w in workers :
w . join ( 5.0 )