]> git.mxchange.org Git - fba.git/blob - fba/fba.py
95176a9112bbaa5d91e3a5e4a6b3e8b305ef16e1
[fba.git] / fba / fba.py
1 # Fedi API Block - An aggregator for fetching blocking data from fediverse nodes
2 # Copyright (C) 2023 Free Software Foundation
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as published
6 # by the Free Software Foundation, either version 3 of the License, or
7 # (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17 import bs4
18 import hashlib
19 import re
20 import reqto
21 import requests
22 import json
23 import sqlite3
24 import sys
25 import time
26 import validators
27
28 from urllib.parse import urlparse
29
30 from fba import blacklist
31 from fba import cache
32 from fba import config
33 from fba import instances
34
35 from fba.federation import lemmy
36 from fba.federation import misskey
37 from fba.federation import peertube
38
39 # Array with pending errors needed to be written to database
40 pending_errors = {
41 }
42
43 # "rel" identifiers (no real URLs)
44 nodeinfo_identifier = [
45     "https://nodeinfo.diaspora.software/ns/schema/2.1",
46     "https://nodeinfo.diaspora.software/ns/schema/2.0",
47     "https://nodeinfo.diaspora.software/ns/schema/1.1",
48     "https://nodeinfo.diaspora.software/ns/schema/1.0",
49     "http://nodeinfo.diaspora.software/ns/schema/2.1",
50     "http://nodeinfo.diaspora.software/ns/schema/2.0",
51     "http://nodeinfo.diaspora.software/ns/schema/1.1",
52     "http://nodeinfo.diaspora.software/ns/schema/1.0",
53 ]
54
55 # HTTP headers for non-API requests
56 headers = {
57     "User-Agent": config.get("useragent"),
58 }
59
60 # HTTP headers for API requests
61 api_headers = {
62     "User-Agent": config.get("useragent"),
63     "Content-Type": "application/json",
64 }
65
66 # URL for fetching peers
67 get_peers_url = "/api/v1/instance/peers"
68
69 # Connect to database
70 connection = sqlite3.connect("blocks.db")
71 cursor = connection.cursor()
72
73 # Pattern instance for version numbers
74 patterns = [
75     # semantic version number (with v|V) prefix)
76     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(?:-(?P<prerelease>(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+(?P<buildmetadata>[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)?$"),
77     # non-sematic, e.g. 1.2.3.4
78     re.compile("^(?P<version>v|V{0,1})(\.{0,1})(?P<major>0|[1-9]\d*)\.(?P<minor>0+|[1-9]\d*)(\.(?P<patch>0+|[1-9]\d*)(\.(?P<subpatch>0|[1-9]\d*))?)$"),
79     # non-sematic, e.g. 2023-05[-dev]
80     re.compile("^(?P<year>[1-9]{1}[0-9]{3})\.(?P<month>[0-9]{2})(-dev){0,1}$"),
81     # non-semantic, e.g. abcdef0
82     re.compile("^[a-f0-9]{7}$"),
83 ]
84
85 ##### Other functions #####
86
87 def is_primitive(var: any) -> bool:
88     # DEBUG: print(f"DEBUG: var[]='{type(var)}' - CALLED!")
89     return type(var) in {int, str, float, bool} or var == None
90
91 def fetch_instances(domain: str, origin: str, software: str, script: str, path: str = None):
92     # DEBUG: print(f"DEBUG: domain='{domain}',origin='{origin}',software='{software}',path='{path}' - CALLED!")
93     if type(domain) != str:
94         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
95     elif domain == "":
96         raise ValueError(f"Parameter 'domain' is empty")
97     elif type(origin) != str and origin != None:
98         raise ValueError(f"Parameter origin[]={type(origin)} is not 'str'")
99     elif type(script) != str:
100         raise ValueError(f"Parameter script[]={type(script)} is not 'str'")
101     elif domain == "":
102         raise ValueError(f"Parameter 'domain' is empty")
103
104     if not is_instance_registered(domain):
105         # DEBUG: print("DEBUG: Adding new domain:", domain, origin)
106         add_instance(domain, origin, script, path)
107
108     # DEBUG: print("DEBUG: Fetching instances for domain:", domain, software)
109     peerlist = get_peers(domain, software)
110
111     if (peerlist is None):
112         print("ERROR: Cannot fetch peers:", domain)
113         return
114     elif instances.has_pending_instance_data(domain):
115         # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo data, flushing ...")
116         instances.update_instance_data(domain)
117
118     print(f"INFO: Checking {len(peerlist)} instances from {domain} ...")
119     for instance in peerlist:
120         if instance == None:
121             # Skip "None" types as tidup() cannot parse them
122             continue
123
124         # DEBUG: print(f"DEBUG: instance='{instance}' - BEFORE")
125         instance = tidyup_domain(instance)
126         # DEBUG: print(f"DEBUG: instance='{instance}' - AFTER")
127
128         if instance == "":
129             print("WARNING: Empty instance after tidyup_domain(), domain:", domain)
130             continue
131         elif not validators.domain(instance.split("/")[0]):
132             print(f"WARNING: Bad instance='{instance}' from domain='{domain}',origin='{origin}',software='{software}'")
133             continue
134         elif blacklist.is_blacklisted(instance):
135             # DEBUG: print("DEBUG: instance is blacklisted:", instance)
136             continue
137
138         # DEBUG: print("DEBUG: Handling instance:", instance)
139         try:
140             if not is_instance_registered(instance):
141                 # DEBUG: print("DEBUG: Adding new instance:", instance, domain)
142                 add_instance(instance, domain, script)
143         except BaseException as e:
144             print(f"ERROR: instance='{instance}',exception[{type(e)}]:'{str(e)}'")
145             continue
146
147     # DEBUG: print("DEBUG: EXIT!")
148
149 def add_peers(rows: dict) -> list:
150     # DEBUG: print(f"DEBUG: rows()={len(rows)} - CALLED!")
151     peers = list()
152     for key in ["linked", "allowed", "blocked"]:
153         # DEBUG: print(f"DEBUG: Checking key='{key}'")
154         if key in rows and rows[key] != None:
155             # DEBUG: print(f"DEBUG: Adding {len(rows[key])} peer(s) to peers list ...")
156             for peer in rows[key]:
157                 # DEBUG: print(f"DEBUG: peer='{peer}' - BEFORE!")
158                 peer = tidyup_domain(peer)
159
160                 # DEBUG: print(f"DEBUG: peer='{peer}' - AFTER!")
161                 if blacklist.is_blacklisted(peer):
162                     # DEBUG: print(f"DEBUG: peer='{peer}' is blacklisted, skipped!")
163                     continue
164
165                 # DEBUG: print(f"DEBUG: Adding peer='{peer}' ...")
166                 peers.append(peer)
167
168     # DEBUG: print(f"DEBUG: peers()={len(peers)} - EXIT!")
169     return peers
170
171 def remove_version(software: str) -> str:
172     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
173     if not "." in software and " " not in software:
174         print(f"WARNING: software='{software}' does not contain a version number.")
175         return software
176
177     temp = software
178     if ";" in software:
179         temp = software.split(";")[0]
180     elif "," in software:
181         temp = software.split(",")[0]
182     elif " - " in software:
183         temp = software.split(" - ")[0]
184
185     # DEBUG: print(f"DEBUG: software='{software}'")
186     version = None
187     if " " in software:
188         version = temp.split(" ")[-1]
189     elif "/" in software:
190         version = temp.split("/")[-1]
191     elif "-" in software:
192         version = temp.split("-")[-1]
193     else:
194         # DEBUG: print(f"DEBUG: Was not able to find common seperator, returning untouched software='{software}'")
195         return software
196
197     matches = None
198     match = None
199     # DEBUG: print(f"DEBUG: Checking {len(patterns)} patterns ...")
200     for pattern in patterns:
201         # Run match()
202         match = pattern.match(version)
203
204         # DEBUG: print(f"DEBUG: match[]={type(match)}")
205         if type(match) is re.Match:
206             break
207
208     # DEBUG: print(f"DEBUG: version[{type(version)}]='{version}',match='{match}'")
209     if type(match) is not re.Match:
210         print(f"WARNING: version='{version}' does not match regex, leaving software='{software}' untouched.")
211         return software
212
213     # DEBUG: print(f"DEBUG: Found valid version number: '{version}', removing it ...")
214     end = len(temp) - len(version) - 1
215
216     # DEBUG: print(f"DEBUG: end[{type(end)}]={end}")
217     software = temp[0:end].strip()
218     if " version" in software:
219         # DEBUG: print(f"DEBUG: software='{software}' contains word ' version'")
220         software = strip_until(software, " version")
221
222     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
223     return software
224
225 def strip_powered_by(software: str) -> str:
226     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
227     if software == "":
228         print(f"ERROR: Bad method call, 'software' is empty")
229         raise Exception("Parameter 'software' is empty")
230     elif not "powered by" in software:
231         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
232         return software
233
234     start = software.find("powered by ")
235     # DEBUG: print(f"DEBUG: start[{type(start)}]='{start}'")
236
237     software = software[start + 11:].strip()
238     # DEBUG: print(f"DEBUG: software='{software}'")
239
240     software = strip_until(software, " - ")
241
242     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
243     return software
244
245 def strip_hosted_on(software: str) -> str:
246     # DEBUG: print(f"DEBUG: software='{software}' - CALLED!")
247     if software == "":
248         print(f"ERROR: Bad method call, 'software' is empty")
249         raise Exception("Parameter 'software' is empty")
250     elif not "hosted on" in software:
251         print(f"WARNING: Cannot find 'hosted on' in '{software}'!")
252         return software
253
254     end = software.find("hosted on ")
255     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
256
257     software = software[0, start].strip()
258     # DEBUG: print(f"DEBUG: software='{software}'")
259
260     software = strip_until(software, " - ")
261
262     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
263     return software
264
265 def strip_until(software: str, until: str) -> str:
266     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
267     if software == "":
268         print(f"ERROR: Bad method call, 'software' is empty")
269         raise Exception("Parameter 'software' is empty")
270     elif until == "":
271         print(f"ERROR: Bad method call, 'until' is empty")
272         raise Exception("Parameter 'until' is empty")
273     elif not until in software:
274         print(f"WARNING: Cannot find '{until}' in '{software}'!")
275         return software
276
277     # Next, strip until part
278     end = software.find(until)
279
280     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
281     if end > 0:
282         software = software[0:end].strip()
283
284     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
285     return software
286
287 def remove_pending_error(domain: str):
288     if type(domain) != str:
289         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
290     elif domain == "":
291         raise ValueError(f"Parameter 'domain' is empty")
292
293     try:
294         # Prevent updating any pending errors, nodeinfo was found
295         del pending_errors[domain]
296
297     except:
298         pass
299
300     # DEBUG: print("DEBUG: EXIT!")
301
302 def get_hash(domain: str) -> str:
303     if type(domain) != str:
304         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
305     elif domain == "":
306         raise ValueError(f"Parameter 'domain' is empty")
307
308     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
309
310 def log_error(domain: str, response: requests.models.Response):
311     # DEBUG: print("DEBUG: domain,response[]:", domain, type(response))
312     if type(domain) != str:
313         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
314     elif domain == "":
315         raise ValueError(f"Parameter 'domain' is empty")
316
317     try:
318         # DEBUG: print("DEBUG: BEFORE response[]:", type(response))
319         if isinstance(response, BaseException) or isinstance(response, json.decoder.JSONDecodeError):
320             response = str(response)
321
322         # DEBUG: print("DEBUG: AFTER response[]:", type(response))
323         if type(response) is str:
324             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, 999, ?, ?)",[
325                 domain,
326                 response,
327                 time.time()
328             ])
329         else:
330             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, ?, ?, ?)",[
331                 domain,
332                 response.status_code,
333                 response.reason,
334                 time.time()
335             ])
336
337         # Cleanup old entries
338         # DEBUG: print(f"DEBUG: Purging old records (distance: {config.get('error_log_cleanup')})")
339         cursor.execute("DELETE FROM error_log WHERE created < ?", [time.time() - config.get("error_log_cleanup")])
340     except BaseException as e:
341         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
342         sys.exit(255)
343
344     # DEBUG: print("DEBUG: EXIT!")
345
346 def update_last_error(domain: str, response: requests.models.Response):
347     # DEBUG: print("DEBUG: domain,response[]:", domain, type(response))
348     if type(domain) != str:
349         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
350     elif domain == "":
351         raise ValueError(f"Parameter 'domain' is empty")
352
353     # DEBUG: print("DEBUG: BEFORE response[]:", type(response))
354     if isinstance(response, BaseException) or isinstance(response, json.decoder.JSONDecodeError):
355         response = f"{type}:str(response)"
356
357     # DEBUG: print("DEBUG: AFTER response[]:", type(response))
358     if type(response) is str:
359         # DEBUG: print(f"DEBUG: Setting last_error_details='{response}'");
360         instances.set("last_status_code"  , domain, 999)
361         instances.set("last_error_details", domain, response)
362     else:
363         # DEBUG: print(f"DEBUG: Setting last_error_details='{response.reason}'");
364         instances.set("last_status_code"  , domain, response.status_code)
365         instances.set("last_error_details", domain, response.reason)
366
367     # Running pending updated
368     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
369     instances.update_instance_data(domain)
370
371     log_error(domain, response)
372
373     # DEBUG: print("DEBUG: EXIT!")
374
375 def update_last_nodeinfo(domain: str):
376     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
377     if type(domain) != str:
378         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
379     elif domain == "":
380         raise ValueError(f"Parameter 'domain' is empty")
381
382     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
383     instances.set("last_nodeinfo", domain, time.time())
384     instances.set("last_updated" , domain, time.time())
385
386     # Running pending updated
387     # DEBUG: print(f"DEBUG: Invoking instances.update_instance_data({domain}) ...")
388     instances.update_instance_data(domain)
389
390     # DEBUG: print("DEBUG: EXIT!")
391
392 def get_peers(domain: str, software: str) -> list:
393     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},software={software} - CALLED!")
394     if type(domain) != str:
395         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
396     elif domain == "":
397         raise ValueError(f"Parameter 'domain' is empty")
398     elif type(software) != str and software != None:
399         raise ValueError(f"software[]={type(software)} is not 'str'")
400
401     if software == "misskey":
402         # DEBUG: print(f"DEBUG: Invoking misskey.get_peers({domain}) ...")
403         return misskey.get_peers(domain)
404     elif software == "lemmy":
405         # DEBUG: print(f"DEBUG: Invoking lemmy.get_peers({domain}) ...")
406         return lemmy.get_peers(domain)
407     elif software == "peertube":
408         # DEBUG: print(f"DEBUG: Invoking peertube.get_peers({domain}) ...")
409         return peertube.get_peers(domain)
410
411     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}',software='{software}' ...")
412     peers = list()
413     try:
414         response = get_response(domain, get_peers_url, api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
415
416         data = json_from_response(response)
417
418         # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
419         if not response.ok or response.status_code >= 400:
420             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
421             response = get_response(domain, "/api/v3/site", api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
422
423             data = json_from_response(response)
424             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
425             if not response.ok or response.status_code >= 400:
426                 print("WARNING: Could not reach any JSON API:", domain)
427                 update_last_error(domain, response)
428             elif response.ok and isinstance(data, list):
429                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
430                 sys.exit(255)
431             elif "federated_instances" in data:
432                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
433                 peers = peers + add_peers(data["federated_instances"])
434                 # DEBUG: print("DEBUG: Added instance(s) to peers")
435             else:
436                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
437                 update_last_error(domain, response)
438         else:
439             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
440             peers = data
441
442     except BaseException as e:
443         print("WARNING: Some error during get():", domain, e)
444         update_last_error(domain, e)
445
446     # DEBUG: print(f"DEBUG: Adding '{len(peers)}' for domain='{domain}'")
447     instances.set("total_peers", domain, len(peers))
448
449     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
450     instances.update_last_instance_fetch(domain)
451
452     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
453     return peers
454
455 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> dict:
456     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}',parameter='{parameter}',extra_headers()={len(extra_headers)} - CALLED!")
457     if type(domain) != str:
458         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
459     elif domain == "":
460         raise ValueError(f"Parameter 'domain' is empty")
461     elif type(path) != str:
462         raise ValueError(f"path[]={type(path)} is not 'str'")
463     elif path == "":
464         raise ValueError("Parameter 'path' cannot be empty")
465     elif type(parameter) != str:
466         raise ValueError(f"parameter[]={type(parameter)} is not 'str'")
467
468     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
469     data = {}
470     try:
471         response = reqto.post(
472             f"https://{domain}{path}",
473             data=parameter,
474             headers={**api_headers, **extra_headers},
475             timeout=(config.get("connection_timeout"), config.get("read_timeout"))
476         )
477
478         data = json_from_response(response)
479         # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
480         if not response.ok or response.status_code >= 400:
481             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},response.status_code='{response.status_code}',data[]='{type(data)}'")
482             update_last_error(domain, response)
483
484     except BaseException as e:
485         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception[{type(e)}]:'{str(e)}'")
486
487     # DEBUG: print(f"DEBUG: Returning data({len(data)})=[]:{type(data)}")
488     return data
489
490 def fetch_nodeinfo(domain: str, path: str = None) -> list:
491     # DEBUG: print(f"DEBUG: domain='{domain}',path={path} - CALLED!")
492     if type(domain) != str:
493         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
494     elif domain == "":
495         raise ValueError(f"Parameter 'domain' is empty")
496     elif type(path) != str and path != None:
497         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
498
499     # DEBUG: print(f"DEBUG: Fetching nodeinfo from domain='{domain}' ...")
500     nodeinfo = fetch_wellknown_nodeinfo(domain)
501
502     # DEBUG: print(f"DEBUG: nodeinfo({len(nodeinfo)})={nodeinfo}")
503     if len(nodeinfo) > 0:
504         # DEBUG: print("DEBUG: nodeinfo()={len(nodeinfo))} - EXIT!")
505         return nodeinfo
506
507     request_paths = [
508        "/nodeinfo/2.1.json",
509        "/nodeinfo/2.1",
510        "/nodeinfo/2.0.json",
511        "/nodeinfo/2.0",
512        "/nodeinfo/1.0",
513        "/api/v1/instance"
514     ]
515
516     data = {}
517     for request in request_paths:
518         if path != None and path != "" and path != f"https://{domain}{path}":
519             # DEBUG: print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
520             continue
521
522         try:
523             # DEBUG: print(f"DEBUG: Fetching request='{request}' from domain='{domain}' ...")
524             response = get_response(domain, request, api_headers, (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout")))
525
526             data = json_from_response(response)
527             # DEBUG: print(f"DEBUG: response.ok={response.ok},response.status_code={response.status_code},data[]='{type(data)}'")
528             if response.ok and isinstance(data, dict):
529                 # DEBUG: print("DEBUG: Success:", request)
530                 instances.set("detection_mode", domain, "STATIC_CHECK")
531                 instances.set("nodeinfo_url"  , domain, request)
532                 break
533             elif response.ok and isinstance(data, list):
534                 print(f"UNSUPPORTED: domain='{domain}' returned a list: '{data}'")
535                 sys.exit(255)
536             elif not response.ok or response.status_code >= 400:
537                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
538                 update_last_error(domain, response)
539                 continue
540
541         except BaseException as e:
542             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
543             update_last_error(domain, e)
544             pass
545
546     # DEBUG: print(f"DEBUG: data()={len(data)} - EXIT!")
547     return data
548
549 def fetch_wellknown_nodeinfo(domain: str) -> list:
550     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
551     if type(domain) != str:
552         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
553     elif domain == "":
554         raise ValueError(f"Parameter 'domain' is empty")
555
556     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
557     data = {}
558
559     try:
560         response = get_response(domain, "/.well-known/nodeinfo", api_headers, (config.get("nodeinfo_connection_timeout"), config.get("nodeinfo_read_timeout")))
561
562         data = json_from_response(response)
563         # DEBUG: print("DEBUG: domain,response.ok,data[]:", domain, response.ok, type(data))
564         if response.ok and isinstance(data, dict):
565             nodeinfo = data
566             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
567             if "links" in nodeinfo:
568                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
569                 for link in nodeinfo["links"]:
570                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
571                     if link["rel"] in nodeinfo_identifier:
572                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
573                         response = get_url(link["href"], api_headers, (config.get("connection_timeout"), config.get("read_timeout")))
574
575                         data = json_from_response(response)
576                         # DEBUG: print("DEBUG: href,response.ok,response.status_code:", link["href"], response.ok, response.status_code)
577                         if response.ok and isinstance(data, dict):
578                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
579                             instances.set("detection_mode", domain, "AUTO_DISCOVERY")
580                             instances.set("nodeinfo_url"  , domain, link["href"])
581                             break
582                     else:
583                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
584             else:
585                 print("WARNING: nodeinfo does not contain 'links':", domain)
586
587     except BaseException as e:
588         print("WARNING: Failed fetching .well-known info:", domain)
589         update_last_error(domain, e)
590         pass
591
592     # DEBUG: print("DEBUG: Returning data[]:", type(data))
593     return data
594
595 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
596     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
597     if type(domain) != str:
598         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
599     elif domain == "":
600         raise ValueError(f"Parameter 'domain' is empty")
601     elif type(path) != str:
602         raise ValueError(f"path[]={type(path)} is not 'str'")
603     elif path == "":
604         raise ValueError(f"Parameter 'domain' is empty")
605
606     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
607     software = None
608
609     try:
610         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
611         response = get_response(domain, path, headers, (config.get("connection_timeout"), config.get("read_timeout")))
612
613         # DEBUG: print("DEBUG: domain,response.ok,response.status_code,response.text[]:", domain, response.ok, response.status_code, type(response.text))
614         if response.ok and response.status_code < 300 and len(response.text) > 0:
615             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
616             doc = bs4.BeautifulSoup(response.text, "html.parser")
617
618             # DEBUG: print("DEBUG: doc[]:", type(doc))
619             generator = doc.find("meta", {"name": "generator"})
620             site_name = doc.find("meta", {"property": "og:site_name"})
621
622             # DEBUG: print(f"DEBUG: generator='{generator}',site_name='{site_name}'")
623             if isinstance(generator, bs4.element.Tag):
624                 # DEBUG: print("DEBUG: Found generator meta tag:", domain)
625                 software = tidyup_domain(generator.get("content"))
626                 print(f"INFO: domain='{domain}' is generated by '{software}'")
627                 instances.set("detection_mode", domain, "GENERATOR")
628                 remove_pending_error(domain)
629             elif isinstance(site_name, bs4.element.Tag):
630                 # DEBUG: print("DEBUG: Found property=og:site_name:", domain)
631                 sofware = tidyup_domain(site_name.get("content"))
632                 print(f"INFO: domain='{domain}' has og:site_name='{software}'")
633                 instances.set("detection_mode", domain, "SITE_NAME")
634                 remove_pending_error(domain)
635
636     except BaseException as e:
637         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
638         update_last_error(domain, e)
639         pass
640
641     # DEBUG: print(f"DEBUG: software[]={type(software)}")
642     if type(software) is str and software == "":
643         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
644         software = None
645     elif type(software) is str and ("." in software or " " in software):
646         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
647         software = remove_version(software)
648
649     # DEBUG: print(f"DEBUG: software[]={type(software)}")
650     if type(software) is str and " powered by " in software:
651         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
652         software = remove_version(strip_powered_by(software))
653     elif type(software) is str and " hosted on " in software:
654         # DEBUG: print(f"DEBUG: software='{software}' has 'hosted on' in it")
655         software = remove_version(strip_hosted_on(software))
656     elif type(software) is str and " by " in software:
657         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
658         software = strip_until(software, " by ")
659     elif type(software) is str and " see " in software:
660         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
661         software = strip_until(software, " see ")
662
663     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
664     return software
665
666 def determine_software(domain: str, path: str = None) -> str:
667     # DEBUG: print(f"DEBUG: domain({len(domain)})={domain},path={path} - CALLED!")
668     if type(domain) != str:
669         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
670     elif domain == "":
671         raise ValueError(f"Parameter 'domain' is empty")
672     elif type(path) != str and path != None:
673         raise ValueError(f"Parameter path[]={type(path)} is not 'str'")
674
675     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
676     software = None
677
678     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
679     data = fetch_nodeinfo(domain, path)
680
681     # DEBUG: print("DEBUG: data[]:", type(data))
682     if not isinstance(data, dict) or len(data) == 0:
683         # DEBUG: print("DEBUG: Could not determine software type:", domain)
684         return fetch_generator_from_path(domain)
685
686     # DEBUG: print("DEBUG: data():", len(data), data)
687     if "status" in data and data["status"] == "error" and "message" in data:
688         print("WARNING: JSON response is an error:", data["message"])
689         update_last_error(domain, data["message"])
690         return fetch_generator_from_path(domain)
691     elif "message" in data:
692         print("WARNING: JSON response contains only a message:", data["message"])
693         update_last_error(domain, data["message"])
694         return fetch_generator_from_path(domain)
695     elif "software" not in data or "name" not in data["software"]:
696         # DEBUG: print(f"DEBUG: JSON response from domain='{domain}' does not include [software][name], fetching / ...")
697         software = fetch_generator_from_path(domain)
698
699         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
700         return software
701
702     software = tidyup_domain(data["software"]["name"])
703
704     # DEBUG: print("DEBUG: sofware after tidyup_domain():", software)
705     if software in ["akkoma", "rebased"]:
706         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
707         software = "pleroma"
708     elif software in ["hometown", "ecko"]:
709         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
710         software = "mastodon"
711     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
712         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
713         software = "misskey"
714     elif software.find("/") > 0:
715         print("WARNING: Spliting of slash:", software)
716         software = tidup_domain(software.split("/")[-1]);
717     elif software.find("|") > 0:
718         print("WARNING: Spliting of pipe:", software)
719         software = tidyup_domain(software.split("|")[0]);
720     elif "powered by" in software:
721         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
722         software = strip_powered_by(software)
723     elif type(software) is str and " by " in software:
724         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
725         software = strip_until(software, " by ")
726     elif type(software) is str and " see " in software:
727         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
728         software = strip_until(software, " see ")
729
730     # DEBUG: print(f"DEBUG: software[]={type(software)}")
731     if software == "":
732         print("WARNING: tidyup_domain() left no software name behind:", domain)
733         software = None
734
735     # DEBUG: print(f"DEBUG: software[]={type(software)}")
736     if str(software) == "":
737         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
738         software = fetch_generator_from_path(domain)
739     elif len(str(software)) > 0 and ("." in software or " " in software):
740         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
741         software = remove_version(software)
742
743     # DEBUG: print(f"DEBUG: software[]={type(software)}")
744     if type(software) is str and "powered by" in software:
745         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
746         software = remove_version(strip_powered_by(software))
747
748     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
749     return software
750
751 def is_instance_registered(domain: str) -> bool:
752     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
753     if type(domain) != str:
754         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
755     elif domain == "":
756         raise ValueError(f"Parameter 'domain' is empty")
757
758     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
759     if not cache.key_exists("is_registered"):
760         # DEBUG: print(f"DEBUG: Cache for 'is_registered' not initialized, fetching all rows ...")
761         try:
762             cursor.execute("SELECT domain FROM instances")
763
764             # Check Set all
765             cache.set_all("is_registered", cursor.fetchall(), True)
766         except BaseException as e:
767             print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
768             sys.exit(255)
769
770     # Is cache found?
771     registered = cache.sub_key_exists("is_registered", domain)
772
773     # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
774     return registered
775
776 def add_instance(domain: str, origin: str, originator: str, path: str = None):
777     # DEBUG: print(f"DEBUG: domain='{domain}',origin='{origin}',originator='{originator}',path='{path}' - CALLED!")
778     if type(domain) != str:
779         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
780     elif domain == "":
781         raise ValueError(f"Parameter 'domain' is empty")
782     elif type(origin) != str and origin != None:
783         raise ValueError(f"origin[]={type(origin)} is not 'str'")
784     elif type(originator) != str:
785         raise ValueError(f"originator[]={type(originator)} is not 'str'")
786     elif originator == "":
787         raise ValueError(f"originator cannot be empty")
788     elif not validators.domain(domain.split("/")[0]):
789         raise ValueError(f"Bad domain name='{domain}'")
790     elif origin is not None and not validators.domain(origin.split("/")[0]):
791         raise ValueError(f"Bad origin name='{origin}'")
792     elif blacklist.is_blacklisted(domain):
793         raise Exception(f"domain='{domain}' is blacklisted, but method invoked")
794
795     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
796     software = determine_software(domain, path)
797     # DEBUG: print("DEBUG: Determined software:", software)
798
799     print(f"INFO: Adding instance domain='{domain}' (origin='{origin}',software='{software}')")
800     try:
801         cursor.execute(
802             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
803             (
804                domain,
805                origin,
806                originator,
807                get_hash(domain),
808                software,
809                time.time()
810             ),
811         )
812
813         cache.set_sub_key("is_registered", domain, True)
814
815         if instances.has_pending_instance_data(domain):
816             # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
817             instances.set("last_status_code"  , domain, None)
818             instances.set("last_error_details", domain, None)
819             instances.update_instance_data(domain)
820             remove_pending_error(domain)
821
822         if domain in pending_errors:
823             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
824             update_last_error(domain, pending_errors[domain])
825             remove_pending_error(domain)
826
827     except BaseException as e:
828         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
829         sys.exit(255)
830     else:
831         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
832         update_last_nodeinfo(domain)
833
834     # DEBUG: print("DEBUG: EXIT!")
835
836 def send_bot_post(instance: str, blocklist: dict):
837     # DEBUG: print(f"DEBUG: instance={instance},blocklist()={len(blocklist)} - CALLED!")
838     if type(domain) != str:
839         raise ValueError(f"Parameter domain[]='{type(domain)}' is not 'str'")
840     elif domain == "":
841         raise ValueError("Parameter 'domain' is empty")
842     elif type(blocklist) != dict:
843         raise ValueError(f"Parameter blocklist[]='{type(blocklist)}' is not 'dict'")
844
845     message = instance + " has blocked the following instances:\n\n"
846     truncated = False
847
848     if len(blocklist) > 20:
849         truncated = True
850         blocklist = blocklist[0 : 19]
851
852     # DEBUG: print(f"DEBUG: blocklist()={len(blocklist)}")
853     for block in blocklist:
854         # DEBUG: print(f"DEBUG: block['{type(block)}']={block}")
855         if block["reason"] == None or block["reason"] == '':
856             message = message + block["blocked"] + " with unspecified reason\n"
857         else:
858             if len(block["reason"]) > 420:
859                 block["reason"] = block["reason"][0:419] + "[…]"
860
861             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
862
863     if truncated:
864         message = message + "(the list has been truncated to the first 20 entries)"
865
866     botheaders = {**api_headers, **{"Authorization": "Bearer " + config.get("bot_token")}}
867
868     req = reqto.post(
869         f"{config.get('bot_instance')}/api/v1/statuses",
870         data={
871             "status"      : message,
872             "visibility"  : config.get('bot_visibility'),
873             "content_type": "text/plain"
874         },
875         headers=botheaders,
876         timeout=10
877     ).json()
878
879     return True
880
881 def fetch_friendica_blocks(domain: str) -> dict:
882     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
883     if type(domain) != str:
884         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
885     elif domain == "":
886         raise ValueError(f"Parameter 'domain' is empty")
887
888     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
889     blocked = list()
890
891     try:
892         doc = bs4.BeautifulSoup(
893             get_response(domain, "/friendica", headers, (config.get("connection_timeout"), config.get("read_timeout"))).text,
894             "html.parser",
895         )
896     except BaseException as e:
897         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
898         update_last_error(domain, e)
899         return {}
900
901     blocklist = doc.find(id="about_blocklist")
902
903     # Prevents exceptions:
904     if blocklist is None:
905         # DEBUG: print("DEBUG: Instance has no block list:", domain)
906         return {}
907
908     table = blocklist.find("table")
909
910     # DEBUG: print(f"DEBUG: table[]='{type(table)}'")
911     if table.find("tbody"):
912         rows = table.find("tbody").find_all("tr")
913     else:
914         rows = table.find_all("tr")
915
916     # DEBUG: print(f"DEBUG: Found rows()={len(rows)}")
917     for line in rows:
918         # DEBUG: print(f"DEBUG: line='{line}'")
919         blocked.append({
920             "domain": tidyup_domain(line.find_all("td")[0].text),
921             "reason": tidyup_reason(line.find_all("td")[1].text)
922         })
923         # DEBUG: print("DEBUG: Next!")
924
925     # DEBUG: print("DEBUG: Returning blocklist() for domain:", domain, len(blocklist))
926     return {
927         "reject": blocked
928     }
929
930 def fetch_misskey_blocks(domain: str) -> dict:
931     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
932     if type(domain) != str:
933         raise ValueError(f"Parameter domain[]={type(domain)} is not 'str'")
934     elif domain == "":
935         raise ValueError(f"Parameter 'domain' is empty")
936
937     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
938     blocklist = {
939         "suspended": [],
940         "blocked"  : []
941     }
942
943     offset = 0
944     step = config.get("misskey_limit")
945     while True:
946         # iterating through all "suspended" (follow-only in its terminology)
947         # instances page-by-page, since that troonware doesn't support
948         # sending them all at once
949         try:
950             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
951             if offset == 0:
952                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
953                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
954                     "sort"     : "+pubAt",
955                     "host"     : None,
956                     "suspended": True,
957                     "limit"    : step
958                 }), {
959                     "Origin": domain
960                 })
961             else:
962                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
963                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
964                     "sort"     : "+pubAt",
965                     "host"     : None,
966                     "suspended": True,
967                     "limit"    : step,
968                     "offset"   : offset - 1
969                 }), {
970                     "Origin": domain
971                 })
972
973             # DEBUG: print("DEBUG: fetched():", len(fetched))
974             if len(fetched) == 0:
975                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
976                 break
977             elif len(fetched) != config.get("misskey_limit"):
978                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
979                 offset = offset + (config.get("misskey_limit") - len(fetched))
980             else:
981                 # DEBUG: print("DEBUG: Raising offset by step:", step)
982                 offset = offset + step
983
984             count = 0
985             for instance in fetched:
986                 # Is it there?
987                 if instance["isSuspended"] and not has_key(blocklist["suspended"], "domain", instance):
988                     count = count + 1
989                     blocklist["suspended"].append(
990                         {
991                             "domain": tidyup_domain(instance["host"]),
992                             # no reason field, nothing
993                             "reason": None
994                         }
995                     )
996
997             # DEBUG: print(f"DEBUG: count={count}")
998             if count == 0:
999                 # DEBUG: print(f"DEBUG: API is no more returning new instances, aborting loop!")
1000                 break
1001
1002         except BaseException as e:
1003             print("WARNING: Caught error, exiting loop:", domain, e)
1004             update_last_error(domain, e)
1005             offset = 0
1006             break
1007
1008     while True:
1009         # same shit, different asshole ("blocked" aka full suspend)
1010         try:
1011             if offset == 0:
1012                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1013                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1014                     "sort"   : "+pubAt",
1015                     "host"   : None,
1016                     "blocked": True,
1017                     "limit"  : step
1018                 }), {
1019                     "Origin": domain
1020                 })
1021             else:
1022                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1023                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1024                     "sort"   : "+pubAt",
1025                     "host"   : None,
1026                     "blocked": True,
1027                     "limit"  : step,
1028                     "offset" : offset - 1
1029                 }), {
1030                     "Origin": domain
1031                 })
1032
1033             # DEBUG: print("DEBUG: fetched():", len(fetched))
1034             if len(fetched) == 0:
1035                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1036                 break
1037             elif len(fetched) != config.get("misskey_limit"):
1038                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config.get('misskey_limit')}'")
1039                 offset = offset + (config.get("misskey_limit") - len(fetched))
1040             else:
1041                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1042                 offset = offset + step
1043
1044             count = 0
1045             for instance in fetched:
1046                 # Is it there?
1047                 if instance["isBlocked"] and not has_key(blocklist["blocked"], "domain", instance):
1048                     count = count + 1
1049                     blocklist["blocked"].append({
1050                         "domain": tidyup_domain(instance["host"]),
1051                         "reason": None
1052                     })
1053
1054             # DEBUG: print(f"DEBUG: count={count}")
1055             if count == 0:
1056                 # DEBUG: print(f"DEBUG: API is no more returning new instances, aborting loop!")
1057                 break
1058
1059         except BaseException as e:
1060             print("ERROR: Exception during POST:", domain, e)
1061             update_last_error(domain, e)
1062             offset = 0
1063             break
1064
1065     # DEBUG: print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
1066     instances.update_last_instance_fetch(domain)
1067
1068     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocklist["blocked"]), len(blocklist["suspended"]))
1069     return {
1070         "reject"        : blocklist["blocked"],
1071         "followers_only": blocklist["suspended"]
1072     }
1073
1074 def tidyup_reason(reason: str) -> str:
1075     # DEBUG: print(f"DEBUG: reason='{reason}' - CALLED!")
1076     if type(reason) != str:
1077         raise ValueError(f"Parameter reason[]={type(reason)} is not expected")
1078
1079     # Strip string
1080     reason = reason.strip()
1081
1082     # Replace â with "
1083     reason = re.sub("â", "\"", reason)
1084
1085     # DEBUG: print(f"DEBUG: reason='{reason}' - EXIT!")
1086     return reason
1087
1088 def tidyup_domain(domain: str) -> str:
1089     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
1090     if type(domain) != str:
1091         raise ValueError(f"Parameter domain[]={type(domain)} is not expected")
1092
1093     # All lower-case and strip spaces out + last dot
1094     domain = domain.lower().strip().rstrip(".")
1095
1096     # No port number
1097     domain = re.sub("\:\d+$", "", domain)
1098
1099     # No protocol, sometimes without the slashes
1100     domain = re.sub("^https?\:(\/*)", "", domain)
1101
1102     # No trailing slash
1103     domain = re.sub("\/$", "", domain)
1104
1105     # No @ sign
1106     domain = re.sub("^\@", "", domain)
1107
1108     # No individual users in block lists
1109     domain = re.sub("(.+)\@", "", domain)
1110
1111     # DEBUG: print(f"DEBUG: domain='{domain}' - EXIT!")
1112     return domain
1113
1114 def json_from_response(response: requests.models.Response) -> list:
1115     # DEBUG: print(f"DEBUG: response[]={type(response)} - CALLED!")
1116     if not isinstance(response, requests.models.Response):
1117         raise ValueError(f"Parameter response[]='{type(response)}' is not type of 'Response'")
1118
1119     data = list()
1120     if response.text.strip() != "":
1121         # DEBUG: print(f"DEBUG: response.text()={len(response.text)} is not empty, invoking response.json() ...")
1122         try:
1123             data = response.json()
1124         except json.decoder.JSONDecodeError:
1125             pass
1126
1127     # DEBUG: print(f"DEBUG: data[]={type(data)} - EXIT!")
1128     return data
1129
1130 def get_response(domain: str, path: str, headers: dict, timeout: list) -> requests.models.Response:
1131     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}',headers()={len(headers)},timeout={timeout} - CALLED!")
1132     if type(domain) != str:
1133         raise ValueError(f"Parameter domain[]='{type(domain)}' is not 'str'")
1134     elif domain == "":
1135         raise ValueError("Parameter 'domain' is empty")
1136     elif type(path) != str:
1137         raise ValueError(f"Parameter path[]='{type(path)}' is not 'str'")
1138     elif path == "":
1139         raise ValueError("Parameter 'path' is empty")
1140
1141     try:
1142         # DEBUG: print(f"DEBUG: Sending request to '{domain}{path}' ...")
1143         response = reqto.get(
1144             f"https://{domain}{path}",
1145             headers=headers,
1146             timeout=timeout
1147         );
1148     except requests.exceptions.ConnectionError as e:
1149         # DEBUG: print(f"DEBUG: Fetching '{path}' from '{domain}' failed. exception[{type(e)}]='{str(e)}'")
1150         update_last_error(domain, e)
1151         raise e
1152
1153     # DEBUG: print(f"DEBUG: response[]='{type(response)}' - EXXIT!")
1154     return response
1155
1156 def has_key(keys: list, search: str, value: any) -> bool:
1157     # DEBUG: print(f"DEBUG: keys()={len(keys)},search='{search}',value[]='{type(value)}' - CALLED!")
1158     if type(keys) != list:
1159         raise ValueError(f"Parameter keys[]='{type(keys)}' is not 'list'")
1160     elif type(search) != str:
1161         raise ValueError(f"Parameter search[]='{type(search)}' is not 'str'")
1162     elif search == "":
1163         raise ValueError("Parameter 'search' is empty")
1164
1165     has = False
1166     # DEBUG: print(f"DEBUG: Checking keys()={len(keys)} ...")
1167     for key in keys:
1168         # DEBUG: print(f"DEBUG: key['{type(key)}']={key}")
1169         if type(key) != dict:
1170             raise ValueError(f"key[]='{type(key)}' is not 'dict'")
1171         elif not search in key:
1172             raise KeyError(f"Cannot find search='{search}'")
1173         elif key[search] == value:
1174             has = True
1175             break
1176
1177     # DEBUG: print(f"DEBUG: has={has} - EXIT!")
1178     return has
1179
1180 def find_domains(tag: bs4.element.Tag) -> list:
1181     # DEBUG: print(f"DEBUG: tag[]={type(tag)} - CALLED!")
1182     if not isinstance(tag, bs4.element.Tag):
1183         raise ValueError(f"Parameter tag[]={type(tag)} is not type of bs4.element.Tag")
1184     elif not isinstance(tag, bs4.element.Tag):
1185         raise KeyError("Cannot find table with instances!")
1186     elif len(tag.select("tr")) == 0:
1187         raise KeyError("No table rows found in table!")
1188
1189     domains = list()
1190     for element in tag.select("tr"):
1191         # DEBUG: print(f"DEBUG: element[]={type(element)}")
1192         if not element.find("td"):
1193             # DEBUG: print("DEBUG: Skipping element, no <td> found")
1194             continue
1195
1196         domain = tidyup_domain(element.find("td").text)
1197         reason = tidyup_reason(element.findAll("td")[1].text)
1198
1199         # DEBUG: print(f"DEBUG: domain='{domain}',reason='{reason}'")
1200
1201         if blacklist.is_blacklisted(domain):
1202             print(f"WARNING: domain='{domain}' is blacklisted - skipped!")
1203             continue
1204         elif domain == "gab.com/.ai, develop.gab.com":
1205             # DEBUG: print(f"DEBUG: Multiple domains detected in one row")
1206             domains.append({
1207                 "domain": "gab.com",
1208                 "reason": reason,
1209             })
1210             domains.append({
1211                 "domain": "gab.ai",
1212                 "reason": reason,
1213             })
1214             domains.append({
1215                 "domain": "develop.gab.com",
1216                 "reason": reason,
1217             })
1218             continue
1219         elif not validators.domain(domain):
1220             print(f"WARNING: domain='{domain}' is not a valid domain - skipped!")
1221             continue
1222
1223         # DEBUG: print(f"DEBUG: Adding domain='{domain}' ...")
1224         domains.append({
1225             "domain": domain,
1226             "reason": reason,
1227         })
1228
1229     # DEBUG: print(f"DEBUG: domains()={len(domains)} - EXIT!")
1230     return domains
1231
1232 def get_url(url: str, headers: dict, timeout: list) -> requests.models.Response:
1233     # DEBUG: print(f"DEBUG: url='{url}',headers()={len(headers)},timeout={timeout} - CALLED!")
1234     if type(url) != str:
1235         raise ValueError(f"Parameter url[]='{type(url)}' is not 'str'")
1236     elif url == "":
1237         raise ValueError("Parameter 'url' is empty")
1238
1239     # DEBUG: print(f"DEBUG: Parsing url='{url}'")
1240     components = urlparse(url)
1241
1242     # Invoke other function, avoid trailing ?
1243     # DEBUG: print(f"DEBUG: components[{type(components)}]={components}")
1244     if components.query != "":
1245         response = get_response(components.hostname, f"{components.path}?{components.query}", headers, timeout)
1246     else:
1247         response = get_response(components.hostname, f"{components.path}", headers, timeout)
1248
1249     # DEBUG: print(f"DEBUG: response[]='{type(response)}' - EXXIT!")
1250     return response