1+ import pathlib
2+ import google .cloud .bigquery as bq
3+ import json
4+ import argparse
5+ from collections import defaultdict
6+ import logging
7+ import os
8+ import re
9+ from urllib .parse import urlparse
10+ from difflib import SequenceMatcher
11+
12+
13+ def get_pool_data_BQ (force_query ):
14+ """
15+ Queries the BigQuery database for pool data and writes it to a json file.
16+ :param force_query: flag to specify whether to query for project data regardless if the relevant data already exist.
17+ :returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
18+ pool's metadata (name, ticker, homepage, description)
19+ """
20+ logging .info ("Getting pool data.." )
21+ filename = 'cardano_pool_data_BQ.json'
22+ file = pathlib .Path (mapping_info_dir / "cardano_preprocessing" / filename )
23+ if not force_query and file .is_file ():
24+ # logging.info('Pool data already exists locally. '
25+ # 'For querying anyway please run the script using the flag --force-query')
26+ with open (file ) as f :
27+ pool_data = json .load (f )
28+ return pool_data
29+
30+ client = bq .Client .from_service_account_json (json_credentials_path = mapping_info_dir / "google-service-account-key.json" )
31+
32+ query = ('SELECT pool_hash, json as metadata FROM `iog-data-analytics.cardano_mainnet.pool_offline_data`' )
33+ logging .info ("Fetching pool data from BigQuery.." )
34+ query_job = client .query (query )
35+ try :
36+ rows = query_job .result ()
37+ except Exception as e :
38+ logging .info (f'The following exception was raised: { repr (e )} ' )
39+ return None
40+ pool_data = {row [0 ]: eval (row [1 ]) for row in rows }
41+ # write json to file
42+ with open (file , 'w' ) as f :
43+ json .dump (pool_data , f , indent = 4 )
44+ logging .info ('Pool data saved locally.' )
45+ return pool_data
46+
47+
48+ def get_pool_data_node ():
49+ """
50+ Gets pool data that has been fetched from a Cardano node.
51+ :returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
52+ pool's metadata (name, ticker, homepage, description)
53+ """
54+ filename = 'cardano_pool_data_node.json'
55+ file = pathlib .Path (mapping_info_dir / "cardano_preprocessing" / filename )
56+ with open (file ) as f :
57+ pool_data = json .load (f )
58+ return pool_data
59+
60+
61+ def merge_pool_data (pool_data_BQ , pool_data_node ):
62+ """
63+ Merges pool data from BigQuery and from a Cardano node, giving priority to node data in case of conflicts.
64+ :param pool_data_BQ: dictionary with pool data that was fetched from BigQuery
65+ :param pool_data_node: dictionary with pool data that was fetched from a Cardano node
66+ :returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
67+ pool's metadata (name, ticker, homepage, description).
68+ """
69+ logging .info ("Merging pool data sources.." )
70+ merged = pool_data_BQ | pool_data_node
71+ return merged
72+
73+ def parse_pool_identifiers (pool_data ):
74+ """
75+ Extracts identifier information from pool data .
76+ Specifically, for each unique identifier (ticker) it saves the corresponding pool name and homepage and writes
77+ all of them to a json file.
78+ Conflicting tickers (same ticker, different domains) are flagged and
79+ excluded from identifiers to avoid false mapping.
80+ :param pool_data: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
81+ pool's metadata (name, ticker, homepage, description)
82+ """
83+ logging .info ("Parsing pool identifiers.." )
84+
85+ identifiers = dict ()
86+ conflicts = dict ()
87+
88+ for pool in pool_data .values ():
89+ ticker = pool .get ('ticker' , '' ).strip ().upper ()
90+ name = pool .get ('name' , '' ).strip ()
91+ homepage = pool .get ('homepage' , '' ).strip ()
92+
93+ if not ticker :
94+ continue
95+
96+ if ticker not in identifiers :
97+ identifiers [ticker ] = {'name' : name , 'link' : homepage }
98+ else :
99+ existing = identifiers [ticker ]
100+ same_domain = (
101+ get_domain (existing ['link' ]) == get_domain (homepage )
102+ and bool (filter_homepage (homepage ))
103+ )
104+ if not same_domain :
105+ # Genuine conflict — flag it
106+ if ticker not in conflicts :
107+ conflicts [ticker ] = [{'name' : existing ['name' ], 'link' : existing ['link' ]}]
108+ conflicts [ticker ].append ({'name' : name , 'link' : homepage })
109+
110+ # Remove conflicting tickers from identifiers to avoid false mapping
111+ for ticker in conflicts :
112+ identifiers .pop (ticker , None )
113+
114+ return identifiers , conflicts
115+
116+
117+ def score_same_entity (p1 , p2 ):
118+ """
119+ Scores how likely two pools belong to the same operator entity.
120+ Returns an integer score; higher = more likely same entity.
121+
122+ Scoring:
123+ +3 same non-trivial domain
124+ +2 same ticker
125+ +2 name similarity > 0.85 (after stripping trailing digits)
126+ +1 same description
127+ -1 same ticker but different non-trivial domains (conflict signal)
128+ """
129+ score = 0
130+
131+ # Domain match
132+ d1 = get_domain (p1 .get ('homepage' , '' ))
133+ d2 = get_domain (p2 .get ('homepage' , '' ))
134+ filtered_d1 = filter_homepage (p1 .get ('homepage' , '' ))
135+ filtered_d2 = filter_homepage (p2 .get ('homepage' , '' ))
136+ domains_valid = bool (filtered_d1 and filtered_d2 and d1 and d2 )
137+ domains_match = domains_valid and d1 == d2
138+
139+ # Ticker match
140+ t1 = p1 .get ('ticker' , '' ).strip ().upper ()
141+ t2 = p2 .get ('ticker' , '' ).strip ().upper ()
142+ tickers_match = bool (t1 and t2 and t1 == t2 )
143+
144+ if domains_match :
145+ score += 3
146+ if tickers_match :
147+ score += 2
148+ if domains_valid and not domains_match :
149+ score -= 1 # same ticker, different real domains → possibly different entities
150+
151+ # Name similarity (strip trailing digits to catch "Pool1" vs "Pool2")
152+ n1 = re .sub (r'\s*\d+$' , '' , p1 .get ('name' , '' ).strip ().lower ())
153+ n2 = re .sub (r'\s*\d+$' , '' , p2 .get ('name' , '' ).strip ().lower ())
154+ if n1 and n2 and name_similarity (n1 , n2 ) > 0.85 :
155+ score += 2
156+
157+ # Description match
158+ desc1 = p1 .get ('description' , '' ).strip ()
159+ desc2 = p2 .get ('description' , '' ).strip ()
160+ if desc1 and desc2 and desc1 == desc2 :
161+ score += 1
162+
163+ return score
164+
165+
166+ def parse_pool_clusters (pool_data , score_threshold = 3 ):
167+ """
168+ Clusters pools by operator entity.
169+ Step 1: group pools that share the same valid domain (strong anchor).
170+ Step 2: merge any two domain-groups that share a ticker AND score >= threshold
171+ (catches operators with different domains but same ticker/name).
172+ Step 3: assign cluster names and return.
173+ :param pool_data: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the
174+ pool's metadata (name, ticker, homepage, description)
175+ :param score_threshold: the minimum score (as returned by score_same_entity) for two pools to be clustered together
176+ :returns: dictionary, where each key is a pool's hash and the corresponding value is a dictionary with the pool's name, the name of the cluster it belongs to and the source of the clustering information
177+ """
178+ logging .info ("Clustering pools by operator entity.." )
179+
180+ pool_hashes = list (pool_data .keys ())
181+ pools = list (pool_data .values ())
182+
183+ # --- Step 1: seed clusters from shared domain ---
184+ domain_to_indices = defaultdict (list )
185+ for i , pool in enumerate (pools ):
186+ domain = get_domain (pool .get ('homepage' , '' ))
187+ if domain and filter_homepage (pool .get ('homepage' , '' )):
188+ domain_to_indices [domain ].append (i )
189+
190+ # Each cluster is a set of pool indices. Start one cluster per shared domain.
191+ # Pools with unique/no domain each get their own singleton cluster.
192+ clusters = []
193+ index_to_cluster = {} # pool index -> cluster list index
194+ index_to_source = {} # pool index -> how it was clustered
195+
196+ for domain , indices in domain_to_indices .items ():
197+ if len (indices ) > 1 :
198+ cluster_id = len (clusters )
199+ clusters .append (set (indices ))
200+ for i in indices :
201+ index_to_cluster [i ] = cluster_id
202+ index_to_source [i ] = 'homepage'
203+
204+ for i in range (len (pools )):
205+ if i not in index_to_cluster :
206+ cluster_id = len (clusters )
207+ clusters .append ({i })
208+ index_to_cluster [i ] = cluster_id
209+ index_to_source [i ] = None
210+
211+ # --- Step 2: merge clusters that share a ticker and score >= threshold
212+ ticker_to_cluster_ids = defaultdict (set )
213+ for i , pool in enumerate (pools ):
214+ ticker = pool .get ('ticker' , '' ).strip ().upper ()
215+ if ticker :
216+ ticker_to_cluster_ids [ticker ].add (index_to_cluster [i ])
217+
218+ def merge_clusters (id_a , id_b ):
219+ """Merge cluster id_b into cluster id_a, updating index_to_cluster.
220+ Pools moving from id_b get source 'multi_signal' unless they already
221+ had a more specific source (e.g. 'homepage') assigned in Step 1."""
222+ if id_a == id_b :
223+ return
224+ for i in clusters [id_b ]:
225+ index_to_cluster [i ] = id_a
226+ if index_to_source [i ] is None :
227+ index_to_source [i ] = 'multi_signal'
228+ clusters [id_a ].update (clusters [id_b ])
229+ clusters [id_b ] = set ()
230+
231+ for ticker , cluster_ids in ticker_to_cluster_ids .items ():
232+ cluster_ids = list (cluster_ids )
233+ for a in range (len (cluster_ids )):
234+ for b in range (a + 1 , len (cluster_ids )):
235+ if not clusters [cluster_ids [a ]] or not clusters [cluster_ids [b ]]:
236+ continue
237+ id_a = index_to_cluster [next (iter (clusters [cluster_ids [a ]]))]
238+ id_b = index_to_cluster [next (iter (clusters [cluster_ids [b ]]))]
239+ if id_a == id_b :
240+ continue
241+ rep_a = pools [next (iter (clusters [id_a ]))]
242+ rep_b = pools [next (iter (clusters [id_b ]))]
243+ if score_same_entity (rep_a , rep_b ) >= score_threshold :
244+ for i in clusters [id_a ]:
245+ if index_to_source [i ] is None :
246+ index_to_source [i ] = 'multi_signal'
247+ merge_clusters (id_a , id_b )
248+
249+ # --- Step 3: build output ---
250+ output = {}
251+ for cluster in clusters :
252+ if not cluster :
253+ continue
254+ pool_names = [pools [i ].get ('name' , '' ) for i in cluster ]
255+ cluster_name = determine_cluster_name (pool_names )
256+ for i in cluster :
257+ pool_hash = pool_hashes [i ]
258+ output [pool_hash ] = {
259+ 'cluster' : cluster_name ,
260+ 'pool' : pools [i ].get ('name' , '' ),
261+ 'source' : index_to_source [i ] if index_to_source [i ] is not None else 'singleton'
262+ }
263+
264+ return output
265+
266+ def get_domain (url ):
267+ """Extracts the domain from a URL, stripping www."""
268+ try :
269+ domain = urlparse (url ).netloc .lower ()
270+ return re .sub (r'^www\.' , '' , domain )
271+ except :
272+ return ''
273+
274+ def name_similarity (a , b ):
275+ """Returns similarity ratio between two strings."""
276+ return SequenceMatcher (None , a .lower (), b .lower ()).ratio ()
277+
278+ def filter_homepage (homepage ):
279+ """
280+ Filters out dummy homepages. Specifically, it ignores homepage entries that are empty,
281+ have an 'empty' name (e.g. 'n/a') or include a dummy keyword in their name (e.g. foo.com)
282+ :param homepage: the homepage to be filtered (string)
283+ :returns: the homepage (as it is) if it is not a dummy homepage, else None
284+ """
285+ if not homepage :
286+ return None
287+ homepage = homepage .strip ()
288+ if not homepage :
289+ return None
290+
291+ homepage_lower = homepage .lower ()
292+
293+ INVALID_EXACT = {
294+ 'https://' , 'http://' , 'n/a' , 'na' , '-' , '--' , '---' , '....' , '...' ,
295+ 'tbd' , 'coming' , 'coming soon' , 'in process' , 'no webside' , 'no website' ,
296+ 'none' , 'null' , 'undefined' , 'unknown'
297+ }
298+ if homepage_lower in INVALID_EXACT :
299+ return None
300+
301+ INVALID_SUBSTRINGS = [
302+ 'foo.com' , 'example.com' , 'invalidurl' , 'test.com' ,
303+ 'localhost' , '127.0.0.1' , 'yourdomain' , 'yoursite' ,
304+ 'mysite.com' , 'mypool.com' , 'poolname.com'
305+ ]
306+
307+ if any (kw in homepage_lower for kw in INVALID_SUBSTRINGS ):
308+ return None
309+
310+ return homepage
311+
312+
313+ def determine_cluster_name (pool_names ):
314+ """
315+ Determines the name of a cluster of pools.
316+ First, it checks if there is a common prefix among all pool names. If there is, it uses that as the cluster name.
317+ If no common prefix exists, then it names the cluster after the first pool name (in alphabetical order and
318+ prioritizing names that don't start with a digit)
319+ :param pool_names: list of pool names that belong to the same cluster
320+ :returns: the name of the cluster
321+ """
322+ # make sure pool names have consistent case and filter out empty names
323+ pool_names = [pool_name .title () for pool_name in pool_names if pool_name ]
324+ if not pool_names :
325+ return ''
326+ common_prefix = os .path .commonprefix (pool_names )
327+ if common_prefix :
328+ return common_prefix
329+ # if there is no common prefix, sort pool names alphabetically, prioritizing names that don't start with a digit
330+ # and use the first one as the cluster name
331+ pool_names = sorted (pool_names , key = lambda x : (x [0 ].isdigit (), x ))
332+ return pool_names [0 ]
333+
334+
335+ if __name__ == '__main__' :
336+ logging .basicConfig (format = '[%(asctime)s] %(message)s' , datefmt = '%Y/%m/%d %I:%M:%S %p' , level = logging .INFO )
337+
338+ parser = argparse .ArgumentParser ()
339+ parser .add_argument (
340+ '--force-query' ,
341+ action = 'store_true' ,
342+ default = False ,
343+ help = 'Flag to specify whether to query for project data regardless if the relevant data already exist.'
344+ )
345+ args = parser .parse_args ()
346+
347+ mapping_info_dir = pathlib .Path (__file__ ).parent .parent
348+ pool_data_dir = pathlib .Path (__file__ ).parent
349+
350+ pool_data_BQ = get_pool_data_BQ (force_query = args .force_query )
351+ pool_data_node = get_pool_data_node ()
352+
353+ merged_pool_data = merge_pool_data (pool_data_BQ , pool_data_node )
354+
355+ identifiers , conflicts = parse_pool_identifiers (merged_pool_data )
356+ clusters = parse_pool_clusters (merged_pool_data )
357+
358+ identifiers_dir = mapping_info_dir / 'identifiers'
359+ with open (identifiers_dir / 'cardano.json' , 'w' ) as f :
360+ json .dump (identifiers , f , indent = 4 )
361+
362+ clusters_dir = mapping_info_dir / 'clusters'
363+ with open (clusters_dir / 'cardano.json' , 'w' ) as f :
364+ json .dump (clusters , f , indent = 4 )
365+
366+ with open (mapping_info_dir / 'cardano_preprocessing' / 'ticker_conflicts.json' , 'w' ) as f :
367+ json .dump (conflicts , f , indent = 4 )
368+
369+ logging .info (f"Done. { len (identifiers )} identifiers, { len (clusters )} clustered pools, { len (conflicts )} conflicts." )
0 commit comments