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