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