]> git.mxchange.org Git - fba.git/blob - fba.py
d4f13b161671c716bd1f31601f1d7e0221b448a8
[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     # DEBUG: print(f"DEBUG: key='{key}',rows()={len(rows)},value[]={type(value)} - CALLED!")
139     if not is_cache_initialized(key):
140         # DEBUG: print(f"DEBUG: Cache for key='{key}' not initialized.")
141         cache[key] = {}
142
143     for sub in rows:
144         # 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     # 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_until(software: str, until: str) -> str:
264     # DEBUG: print(f"DEBUG: software='{software}',until='{until}' - CALLED!")
265     if software == "":
266         print(f"ERROR: Bad method call, 'software' is empty")
267         raise Exception("Parameter 'software' is empty")
268     elif until == "":
269         print(f"ERROR: Bad method call, 'until' is empty")
270         raise Exception("Parameter 'until' is empty")
271     elif not until in software:
272         print(f"WARNING: Cannot find 'powered by' in '{software}'!")
273         return software
274
275     # Next, strip until part
276     end = software.find(until)
277
278     # DEBUG: print(f"DEBUG: end[{type(end)}]='{end}'")
279     if end > 0:
280         software = software[0:end].strip()
281
282     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
283     return software
284
285 def is_blacklisted(domain: str) -> bool:
286     blacklisted = False
287     for peer in blacklist:
288         if peer in domain:
289             blacklisted = True
290
291     return blacklisted
292
293 def remove_pending_error(domain: str):
294     try:
295         # Prevent updating any pending errors, nodeinfo was found
296         del pending_errors[domain]
297
298     except:
299         pass
300
301 def get_hash(domain: str) -> str:
302     return hashlib.sha256(domain.encode("utf-8")).hexdigest()
303
304 def update_last_blocked(domain: str):
305     # DEBUG: print("DEBUG: Updating last_blocked for domain", domain)
306     try:
307         cursor.execute("UPDATE instances SET last_blocked = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
308             time.time(),
309             time.time(),
310             domain
311         ])
312
313         if cursor.rowcount == 0:
314             print("WARNING: Did not update any rows:", domain)
315
316     except BaseException as e:
317         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
318         sys.exit(255)
319
320     # DEBUG: print("DEBUG: EXIT!")
321
322 def has_pending_nodeinfos(domain: str) -> bool:
323     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
324     has_pending = False
325     for key in nodeinfos:
326         # DEBUG: print(f"DEBUG: key='{key}',domain='{domain}',nodeinfos[key]='{nodeinfos[key]}'")
327         if domain in nodeinfos[key]:
328             has_pending = True
329             break
330
331     # DEBUG: print(f"DEBUG: has_pending='{has_pending}' - EXIT!")
332     return has_pending
333
334 def update_nodeinfos(domain: str):
335     # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
336     sql_string = ''
337     fields = list()
338     for key in nodeinfos:
339         # DEBUG: print("DEBUG: key:", key)
340         if domain in nodeinfos[key]:
341            # DEBUG: print(f"DEBUG: Adding '{nodeinfos[key][domain]}' for key='{key}' ...")
342            fields.append(nodeinfos[key][domain])
343            sql_string += f" {key} = ?,"
344
345     fields.append(domain)
346     # DEBUG: print(f"DEBUG: sql_string='{sql_string}',fields()={len(fields)}")
347
348     sql = "UPDATE instances SET" + sql_string + " last_status_code = NULL, last_error_details = NULL WHERE domain = ? LIMIT 1"
349     # DEBUG: print("DEBUG: sql:", sql)
350
351     try:
352         # DEBUG: print("DEBUG: Executing SQL:", sql)
353         cursor.execute(sql, fields)
354         # DEBUG: print(f"DEBUG: Success! (rowcount={cursor.rowcount })")
355
356         if cursor.rowcount == 0:
357             print("WARNING: Did not update any rows:", domain)
358
359     except BaseException as e:
360         print(f"ERROR: failed SQL query: domain='{domain}',sql='{sql}',exception[{type(e)}]:'{str(e)}'")
361         sys.exit(255)
362
363     # DEBUG: print("DEBUG: Deleting nodeinfos for domain:", domain)
364     for key in nodeinfos:
365         try:
366             # DEBUG: print("DEBUG: Deleting key:", key)
367             del nodeinfos[key][domain]
368         except:
369             pass
370
371     # DEBUG: print("DEBUG: EXIT!")
372
373 def log_error(domain: str, res: any):
374     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
375     try:
376         # DEBUG: print("DEBUG: BEFORE res[]:", type(res))
377         if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
378             res = str(res)
379
380         # DEBUG: print("DEBUG: AFTER res[]:", type(res))
381         if type(res) is str:
382             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, 999, ?, ?)",[
383                 domain,
384                 res,
385                 time.time()
386             ])
387         else:
388             cursor.execute("INSERT INTO error_log (domain, error_code, error_message, created) VALUES (?, ?, ?, ?)",[
389                 domain,
390                 res.status_code,
391                 res.reason,
392                 time.time()
393             ])
394
395         # Cleanup old entries
396         # DEBUG: print(f"DEBUG: Purging old records (distance: {config['error_log_cleanup'])")
397         cursor.execute("DELETE FROM error_log WHERE created < ?", [time.time() - config["error_log_cleanup"]])
398     except BaseException as e:
399         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
400         sys.exit(255)
401
402     # DEBUG: print("DEBUG: EXIT!")
403
404 def update_last_error(domain: str, res: any):
405     # DEBUG: print("DEBUG: domain,res[]:", domain, type(res))
406     try:
407         # DEBUG: print("DEBUG: BEFORE res[]:", type(res))
408         if isinstance(res, BaseException) or isinstance(res, json.JSONDecodeError):
409             res = str(res)
410
411         # DEBUG: print("DEBUG: AFTER res[]:", type(res))
412         if type(res) is str:
413             # DEBUG: print(f"DEBUG: Setting last_error_details='{res}'");
414             cursor.execute("UPDATE instances SET last_status_code = 999, last_error_details = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
415                 res,
416                 time.time(),
417                 domain
418             ])
419         else:
420             # DEBUG: print(f"DEBUG: Setting last_error_details='{res.reason}'");
421             cursor.execute("UPDATE instances SET last_status_code = ?, last_error_details = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
422                 res.status_code,
423                 res.reason,
424                 time.time(),
425                 domain
426             ])
427
428         if cursor.rowcount == 0:
429             # DEBUG: print("DEBUG: Did not update any rows:", domain)
430             pending_errors[domain] = res
431
432         log_error(domain, res)
433
434     except BaseException as e:
435         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
436         sys.exit(255)
437
438     # DEBUG: print("DEBUG: EXIT!")
439
440 def update_last_instance_fetch(domain: str):
441     #print("DEBUG: Updating last_instance_fetch for domain:", domain)
442     try:
443         cursor.execute("UPDATE instances SET last_instance_fetch = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
444             time.time(),
445             time.time(),
446             domain
447         ])
448
449         if cursor.rowcount == 0:
450             print("WARNING: Did not update any rows:", domain)
451
452     except BaseException as e:
453         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
454         sys.exit(255)
455
456     connection.commit()
457     #print("DEBUG: EXIT!")
458
459 def update_last_nodeinfo(domain: str):
460     # DEBUG: print("DEBUG: Updating last_nodeinfo for domain:", domain)
461     try:
462         cursor.execute("UPDATE instances SET last_nodeinfo = ?, last_updated = ? WHERE domain = ? LIMIT 1", [
463             time.time(),
464             time.time(),
465             domain
466         ])
467
468         if cursor.rowcount == 0:
469             print("WARNING: Did not update any rows:", domain)
470
471     except BaseException as e:
472         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
473         sys.exit(255)
474
475     connection.commit()
476     # DEBUG: print("DEBUG: EXIT!")
477
478 def get_peers(domain: str, software: str) -> list:
479     # DEBUG: print(f"DEBUG: domain='{domain}',software='{software}' - CALLED!")
480     peers = list()
481
482     if software == "misskey":
483         # DEBUG: print(f"DEBUG: domain='{domain}' is misskey, sending API POST request ...")
484         offset = 0
485         step = config["misskey_offset"]
486
487         # iterating through all "suspended" (follow-only in its terminology)
488         # instances page-by-page, since that troonware doesn't support
489         # sending them all at once
490         while True:
491             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
492             if offset == 0:
493                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
494                     "sort" : "+pubAt",
495                     "host" : None,
496                     "limit": step
497                 }), {"Origin": domain})
498             else:
499                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
500                     "sort"  : "+pubAt",
501                     "host"  : None,
502                     "limit" : step,
503                     "offset": offset - 1
504                 }), {"Origin": domain})
505
506             # DEBUG: print("DEBUG: fetched():", len(fetched))
507             if len(fetched) == 0:
508                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
509                 break
510             elif len(fetched) != config["misskey_offset"]:
511                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
512                 offset = offset + (config["misskey_offset"] - len(fetched))
513             else:
514                 # DEBUG: print("DEBUG: Raising offset by step:", step)
515                 offset = offset + step
516
517             # Check records
518             # DEBUG: print(f"DEBUG: fetched({len(fetched)})[]={type(fetched)}")
519             if isinstance(fetched, dict) and "error" in fetched and "message" in fetched["error"]:
520                 print(f"WARNING: post_json_api() returned error: {fetched['error']['message']}")
521                 update_last_error(domain, fetched["error"]["message"])
522                 break
523
524             for row in fetched:
525                 # DEBUG: print(f"DEBUG: row():{len(row)}")
526                 if not "host" in row:
527                     print(f"WARNING: row()={len(row)} does not contain element 'host': {row},domain='{domain}'")
528                     continue
529                 elif is_blacklisted(row["host"]):
530                     # DEBUG: print(f"DEBUG: row[host]='{row['host']}' is blacklisted. domain='{domain}'")
531                     continue
532
533                 # DEBUG: print(f"DEBUG: Adding peer: '{row['host']}'")
534                 peers.append(row["host"])
535
536         #print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
537         update_last_instance_fetch(domain)
538
539         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
540         return peers
541     elif software == "lemmy":
542         # DEBUG: print(f"DEBUG: domain='{domain}' is Lemmy, fetching JSON ...")
543         try:
544             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
545
546             data = res.json()
547             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
548             if not res.ok or res.status_code >= 400:
549                 print("WARNING: Could not reach any JSON API:", domain)
550                 update_last_error(domain, res)
551             elif res.ok and isinstance(data, list):
552                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
553                 sys.exit(255)
554             elif "federated_instances" in data:
555                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
556                 peers = peers + add_peers(data["federated_instances"])
557                 # DEBUG: print("DEBUG: Added instance(s) to peers")
558             else:
559                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
560                 update_last_error(domain, res)
561
562         except BaseException as e:
563             print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
564
565         #print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
566         update_last_instance_fetch(domain)
567
568         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
569         return peers
570     elif software == "peertube":
571         # DEBUG: print(f"DEBUG: domain='{domain}' is a PeerTube, fetching JSON ...")
572
573         start = 0
574         for mode in ["followers", "following"]:
575             # DEBUG: print(f"DEBUG: domain='{domain}',mode='{mode}'")
576             while True:
577                 try:
578                     res = reqto.get(f"https://{domain}/api/v1/server/{mode}?start={start}&count=100", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
579
580                     data = res.json()
581                     # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code='{res.status_code}',data[]='{type(data)}'")
582                     if res.ok and isinstance(data, dict):
583                         # DEBUG: print("DEBUG: Success, data:", len(data))
584                         if "data" in data:
585                             # DEBUG: print(f"DEBUG: Found {len(data['data'])} record(s).")
586                             for record in data["data"]:
587                                 # DEBUG: print(f"DEBUG: record()={len(record)}")
588                                 if mode in record and "host" in record[mode]:
589                                     # DEBUG: print(f"DEBUG: Found host={record[mode]['host']}, adding ...")
590                                     peers.append(record[mode]["host"])
591                                 else:
592                                     print(f"WARNING: record from '{domain}' has no '{mode}' or 'host' record: {record}")
593
594                             if len(data["data"]) < 100:
595                                 # DEBUG: print("DEBUG: Reached end of JSON response:", domain)
596                                 break
597
598                         # Continue with next row
599                         start = start + 100
600
601                 except BaseException as e:
602                     print(f"WARNING: Exception during fetching JSON: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
603
604         #print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
605         update_last_instance_fetch(domain)
606
607         # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
608         return peers
609
610     # DEBUG: print(f"DEBUG: Fetching get_peers_url='{get_peers_url}' from '{domain}' ...")
611     try:
612         res = reqto.get(f"https://{domain}{get_peers_url}", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
613
614         data = res.json()
615         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
616         if not res.ok or res.status_code >= 400:
617             # DEBUG: print(f"DEBUG: Was not able to fetch '{get_peers_url}', trying alternative ...")
618             res = reqto.get(f"https://{domain}/api/v3/site", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
619
620             data = res.json()
621             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
622             if not res.ok or res.status_code >= 400:
623                 print("WARNING: Could not reach any JSON API:", domain)
624                 update_last_error(domain, res)
625             elif res.ok and isinstance(data, list):
626                 print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
627                 sys.exit(255)
628             elif "federated_instances" in data:
629                 # DEBUG: print(f"DEBUG: Found federated_instances for domain='{domain}'")
630                 peers = peers + add_peers(data["federated_instances"])
631                 # DEBUG: print("DEBUG: Added instance(s) to peers")
632             else:
633                 print("WARNING: JSON response does not contain 'federated_instances':", domain)
634                 update_last_error(domain, res)
635         else:
636             # DEBUG: print("DEBUG: Querying API was successful:", domain, len(data))
637             peers = data
638
639     except BaseException as e:
640         print("WARNING: Some error during get():", domain, e)
641         update_last_error(domain, e)
642
643     #print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
644     update_last_instance_fetch(domain)
645
646     # DEBUG: print("DEBUG: Returning peers[]:", type(peers))
647     return peers
648
649 def post_json_api(domain: str, path: str, parameter: str, extra_headers: dict = {}) -> dict:
650     # DEBUG: print("DEBUG: Sending POST to domain,path,parameter:", domain, path, parameter, extra_headers)
651     data = {}
652     try:
653         res = reqto.post(f"https://{domain}{path}", data=parameter, headers={**api_headers, **extra_headers}, timeout=(config["connection_timeout"], config["read_timeout"]))
654
655         data = res.json()
656         # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
657         if not res.ok or res.status_code >= 400:
658             print(f"WARNING: Cannot query JSON API: domain='{domain}',path='{path}',parameter()={len(parameter)},res.status_code='{res.status_code}',data[]='{type(data)}'")
659             update_last_error(domain, res)
660
661     except BaseException as e:
662         print(f"WARNING: Some error during post(): domain='{domain}',path='{path}',parameter()={len(parameter)},exception[{type(e)}]:'{str(e)}'")
663
664     # DEBUG: print(f"DEBUG: Returning data({len(data)})=[]:{type(data)}")
665     return data
666
667 def fetch_nodeinfo(domain: str, path: str = None) -> list:
668     # DEBUG: print("DEBUG: Fetching nodeinfo from domain,path:", domain, path)
669
670     nodeinfo = fetch_wellknown_nodeinfo(domain)
671     # DEBUG: print("DEBUG: nodeinfo:", len(nodeinfo))
672
673     if len(nodeinfo) > 0:
674         # DEBUG: print("DEBUG: Returning auto-discovered nodeinfo:", len(nodeinfo))
675         return nodeinfo
676
677     requests = [
678        f"https://{domain}/nodeinfo/2.1.json",
679        f"https://{domain}/nodeinfo/2.1",
680        f"https://{domain}/nodeinfo/2.0.json",
681        f"https://{domain}/nodeinfo/2.0",
682        f"https://{domain}/nodeinfo/1.0",
683        f"https://{domain}/api/v1/instance"
684     ]
685
686     data = {}
687     for request in requests:
688         if path != None and path != "" and request != path:
689             print(f"DEBUG: path='{path}' does not match request='{request}' - SKIPPED!")
690             continue
691
692         try:
693             # DEBUG: print("DEBUG: Fetching request:", request)
694             res = reqto.get(request, headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
695
696             data = res.json()
697             # DEBUG: print(f"DEBUG: res.ok={res.ok},res.status_code={res.status_code},data[]='{type(data)}'")
698             if res.ok and isinstance(data, dict):
699                 # DEBUG: print("DEBUG: Success:", request)
700                 nodeinfos["detection_mode"][domain] = "STATIC_CHECK"
701                 nodeinfos["nodeinfo_url"][domain] = request
702                 break
703             elif res.ok and isinstance(data, list):
704                 # DEBUG: print(f"DEBUG: domain='{domain}' returned a list: '{data}'")
705                 sys.exit(255)
706             elif not res.ok or res.status_code >= 400:
707                 print("WARNING: Failed fetching nodeinfo from domain:", domain)
708                 update_last_error(domain, res)
709                 continue
710
711         except BaseException as e:
712             # DEBUG: print("DEBUG: Cannot fetch API request:", request)
713             update_last_error(domain, e)
714             pass
715
716     # DEBUG: print("DEBUG: Returning data[]:", type(data))
717     return data
718
719 def fetch_wellknown_nodeinfo(domain: str) -> list:
720     # DEBUG: print("DEBUG: Fetching .well-known info for domain:", domain)
721     data = {}
722
723     try:
724         res = reqto.get(f"https://{domain}/.well-known/nodeinfo", headers=api_headers, timeout=(config["connection_timeout"], config["read_timeout"]))
725
726         data = res.json()
727         # DEBUG: print("DEBUG: domain,res.ok,data[]:", domain, res.ok, type(data))
728         if res.ok and isinstance(data, dict):
729             nodeinfo = data
730             # DEBUG: print("DEBUG: Found entries:", len(nodeinfo), domain)
731             if "links" in nodeinfo:
732                 # DEBUG: print("DEBUG: Found links in nodeinfo():", len(nodeinfo["links"]))
733                 for link in nodeinfo["links"]:
734                     # DEBUG: print("DEBUG: rel,href:", link["rel"], link["href"])
735                     if link["rel"] in nodeinfo_identifier:
736                         # DEBUG: print("DEBUG: Fetching nodeinfo from:", link["href"])
737                         res = reqto.get(link["href"])
738
739                         data = res.json()
740                         # DEBUG: print("DEBUG: href,res.ok,res.status_code:", link["href"], res.ok, res.status_code)
741                         if res.ok and isinstance(data, dict):
742                             # DEBUG: print("DEBUG: Found JSON nodeinfo():", len(data))
743                             nodeinfos["detection_mode"][domain] = "AUTO_DISCOVERY"
744                             nodeinfos["nodeinfo_url"][domain] = link["href"]
745                             break
746                     else:
747                         print("WARNING: Unknown 'rel' value:", domain, link["rel"])
748             else:
749                 print("WARNING: nodeinfo does not contain 'links':", domain)
750
751     except BaseException as e:
752         print("WARNING: Failed fetching .well-known info:", domain)
753         update_last_error(domain, e)
754         pass
755
756     # DEBUG: print("DEBUG: Returning data[]:", type(data))
757     return data
758
759 def fetch_generator_from_path(domain: str, path: str = "/") -> str:
760     # DEBUG: print(f"DEBUG: domain='{domain}',path='{path}' - CALLED!")
761     software = None
762
763     try:
764         # DEBUG: print(f"DEBUG: Fetching path='{path}' from '{domain}' ...")
765         res = reqto.get(f"https://{domain}{path}", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"]))
766
767         # DEBUG: print("DEBUG: domain,res.ok,res.status_code,res.text[]:", domain, res.ok, res.status_code, type(res.text))
768         if res.ok and res.status_code < 300 and len(res.text) > 0:
769             # DEBUG: print("DEBUG: Search for <meta name='generator'>:", domain)
770             doc = bs4.BeautifulSoup(res.text, "html.parser")
771
772             # DEBUG: print("DEBUG: doc[]:", type(doc))
773             tag = doc.find("meta", {"name": "generator"})
774
775             # DEBUG: print(f"DEBUG: tag[{type(tag)}: {tag}")
776             if isinstance(tag, bs4.element.Tag):
777                 # DEBUG: print("DEBUG: Found generator meta tag: ", domain)
778                 software = tidyup(tag.get("content"))
779                 print(f"INFO: domain='{domain}' is generated by '{software}'")
780                 nodeinfos["detection_mode"][domain] = "GENERATOR"
781                 remove_pending_error(domain)
782
783     except BaseException as e:
784         # DEBUG: print(f"DEBUG: Cannot fetch / from '{domain}':", e)
785         update_last_error(domain, e)
786         pass
787
788     # DEBUG: print(f"DEBUG: software[]={type(software)}")
789     if type(software) is str and software == "":
790         # DEBUG: print(f"DEBUG: Corrected empty string to None for software of domain='{domain}'")
791         software = None
792     elif type(software) is str and ("." in software or " " in software):
793         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
794         software = remove_version(software)
795
796     # DEBUG: print(f"DEBUG: software[]={type(software)}")
797     if type(software) is str and "powered by" in software:
798         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
799         software = remove_version(strip_powered_by(software))
800     elif type(software) is str and " by " in software:
801         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
802         software = strip_until(software, " by ")
803     elif type(software) is str and " see " in software:
804         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
805         software = strip_until(software, " see ")
806
807     # DEBUG: print(f"DEBUG: software='{software}' - EXIT!")
808     return software
809
810 def determine_software(domain: str, path: str = None) -> str:
811     # DEBUG: print("DEBUG: Determining software for domain,path:", domain, path)
812     software = None
813
814     # DEBUG: print(f"DEBUG: Fetching nodeinfo from '{domain}' ...")
815     data = fetch_nodeinfo(domain, path)
816
817     # DEBUG: print("DEBUG: data[]:", type(data))
818     if not isinstance(data, dict) or len(data) == 0:
819         # DEBUG: print("DEBUG: Could not determine software type:", domain)
820         return fetch_generator_from_path(domain)
821
822     # DEBUG: print("DEBUG: data():", len(data), data)
823     if "status" in data and data["status"] == "error" and "message" in data:
824         print("WARNING: JSON response is an error:", data["message"])
825         update_last_error(domain, data["message"])
826         return fetch_generator_from_path(domain)
827     elif "software" not in data or "name" not in data["software"]:
828         # DEBUG: print(f"DEBUG: JSON response from {domain} does not include [software][name], fetching / ...")
829         software = fetch_generator_from_path(domain)
830
831         # DEBUG: print(f"DEBUG: Generator for domain='{domain}' is: {software}, EXIT!")
832         return software
833
834     software = tidyup(data["software"]["name"])
835
836     # DEBUG: print("DEBUG: sofware after tidyup():", software)
837     if software in ["akkoma", "rebased"]:
838         # DEBUG: print("DEBUG: Setting pleroma:", domain, software)
839         software = "pleroma"
840     elif software in ["hometown", "ecko"]:
841         # DEBUG: print("DEBUG: Setting mastodon:", domain, software)
842         software = "mastodon"
843     elif software in ["calckey", "groundpolis", "foundkey", "cherrypick", "meisskey"]:
844         # DEBUG: print("DEBUG: Setting misskey:", domain, software)
845         software = "misskey"
846     elif software.find("/") > 0:
847         print("WARNING: Spliting of slash:", software)
848         software = software.split("/")[-1];
849     elif software.find("|") > 0:
850         print("WARNING: Spliting of pipe:", software)
851         software = tidyup(software.split("|")[0]);
852     elif "powered by" in software:
853         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
854         software = strip_powered_by(software)
855     elif type(software) is str and " by " in software:
856         # DEBUG: print(f"DEBUG: software='{software}' has ' by ' in it")
857         software = strip_until(software, " by ")
858     elif type(software) is str and " see " in software:
859         # DEBUG: print(f"DEBUG: software='{software}' has ' see ' in it")
860         software = strip_until(software, " see ")
861
862     # DEBUG: print(f"DEBUG: software[]={type(software)}")
863     if software == "":
864         print("WARNING: tidyup() left no software name behind:", domain)
865         software = None
866
867     # DEBUG: print(f"DEBUG: software[]={type(software)}")
868     if str(software) == "":
869         # DEBUG: print(f"DEBUG: software for '{domain}' was not detected, trying generator ...")
870         software = fetch_generator_from_path(domain)
871     elif len(str(software)) > 0 and ("." in software or " " in software):
872         # DEBUG: print(f"DEBUG: software='{software}' may contain a version number, domain='{domain}', removing it ...")
873         software = remove_version(software)
874
875     # DEBUG: print(f"DEBUG: software[]={type(software)}")
876     if type(software) is str and "powered by" in software:
877         # DEBUG: print(f"DEBUG: software='{software}' has 'powered by' in it")
878         software = remove_version(strip_powered_by(software))
879
880     # DEBUG: print("DEBUG: Returning domain,software:", domain, software)
881     return software
882
883 def update_block_reason(reason: str, blocker: str, blocked: str, block_level: str):
884     # DEBUG: print("DEBUG: Updating block reason:", reason, blocker, blocked, block_level)
885     try:
886         cursor.execute(
887             "UPDATE blocks SET reason = ?, last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ? AND reason = ''",
888             (
889                 reason,
890                 time.time(),
891                 blocker,
892                 blocked,
893                 block_level
894             ),
895         )
896
897         # DEBUG: print(f"DEBUG: cursor.rowcount={cursor.rowcount}")
898         if cursor.rowcount == 0:
899             print("WARNING: Did not update any rows:", domain)
900
901     except BaseException as e:
902         print(f"ERROR: failed SQL query: reason='{reason}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',sql='{sql}',exception[{type(e)}]:'{str(e)}'")
903         sys.exit(255)
904
905     # DEBUG: print("DEBUG: EXIT!")
906
907 def update_last_seen(blocker: str, blocked: str, block_level: str):
908     # DEBUG: print("DEBUG: Updating last_seen for:", blocker, blocked, block_level)
909     try:
910         cursor.execute(
911             "UPDATE blocks SET last_seen = ? WHERE blocker = ? AND blocked = ? AND block_level = ?",
912             (
913                 time.time(),
914                 blocker,
915                 blocked,
916                 block_level
917             )
918         )
919
920         if cursor.rowcount == 0:
921             print("WARNING: Did not update any rows:", domain)
922
923     except BaseException as e:
924         print(f"ERROR: failed SQL query: last_seen='{last_seen}',blocker='{blocker}',blocked='{blocked}',block_level='{block_level}',exception[{type(e)}]:'{str(e)}'")
925         sys.exit(255)
926
927     # DEBUG: print("DEBUG: EXIT!")
928
929 def block_instance(blocker: str, blocked: str, reason: str, block_level: str):
930     # DEBUG: print("DEBUG: blocker,blocked,reason,block_level:", blocker, blocked, reason, block_level)
931     if not validators.domain(blocker.split("/")[0]):
932         print("WARNING: Bad blocker:", blocker)
933         raise
934     elif not validators.domain(blocked.split("/")[0]):
935         print("WARNING: Bad blocked:", blocked)
936         raise
937
938     print("INFO: New block:", blocker, blocked, reason, block_level, first_seen, last_seen)
939     try:
940         cursor.execute(
941             "INSERT INTO blocks (blocker, blocked, reason, block_level, first_seen, last_seen) VALUES(?, ?, ?, ?, ?, ?)",
942              (
943                  blocker,
944                  blocked,
945                  reason,
946                  block_level,
947                  time.time(),
948                  time.time()
949              ),
950         )
951
952     except BaseException as e:
953         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)}'")
954         sys.exit(255)
955
956     # DEBUG: print("DEBUG: EXIT!")
957
958 def is_instance_registered(domain: str) -> bool:
959     # DEBUG: print(f"DEBUG: domain='{domain}' - CALLED!")
960     if not is_cache_initialized("is_registered"):
961         # DEBUG: print(f"DEBUG: Cache for 'is_registered' not initialized, fetching all rows ...")
962         try:
963             cursor.execute("SELECT domain FROM instances")
964
965             # Check Set all
966             set_all_cache_key("is_registered", cursor.fetchall(), True)
967         except BaseException as e:
968             print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
969             sys.exit(255)
970
971     # Is cache found?
972     registered = is_cache_key_set("is_registered", domain)
973
974     # DEBUG: print(f"DEBUG: registered='{registered}' - EXIT!")
975     return registered
976
977 def add_instance(domain: str, origin: str, originator: str, path: str = None):
978     # DEBUG: print("DEBUG: domain,origin,originator,path:", domain, origin, originator, path)
979     if not validators.domain(domain.split("/")[0]):
980         print("WARNING: Bad domain name:", domain)
981         raise
982     elif origin is not None and not validators.domain(origin.split("/")[0]):
983         print("WARNING: Bad origin name:", origin)
984         raise
985
986     software = determine_software(domain, path)
987     # DEBUG: print("DEBUG: Determined software:", software)
988
989     print(f"INFO: Adding instance {domain} (origin: {origin})")
990     try:
991         cursor.execute(
992             "INSERT INTO instances (domain, origin, originator, hash, software, first_seen) VALUES (?, ?, ?, ?, ?, ?)",
993             (
994                domain,
995                origin,
996                originator,
997                get_hash(domain),
998                software,
999                time.time()
1000             ),
1001         )
1002
1003         set_cache_key("is_registered", domain, True)
1004
1005         if has_pending_nodeinfos(domain):
1006             # DEBUG: print(f"DEBUG: domain='{domain}' has pending nodeinfo being updated ...")
1007             update_nodeinfos(domain)
1008             remove_pending_error(domain)
1009
1010         if domain in pending_errors:
1011             # DEBUG: print("DEBUG: domain has pending error being updated:", domain)
1012             update_last_error(domain, pending_errors[domain])
1013             remove_pending_error(domain)
1014
1015     except BaseException as e:
1016         print(f"ERROR: failed SQL query: domain='{domain}',exception[{type(e)}]:'{str(e)}'")
1017         sys.exit(255)
1018     else:
1019         # DEBUG: print("DEBUG: Updating nodeinfo for domain:", domain)
1020         update_last_nodeinfo(domain)
1021
1022     # DEBUG: print("DEBUG: EXIT!")
1023
1024 def send_bot_post(instance: str, blocks: dict):
1025     message = instance + " has blocked the following instances:\n\n"
1026     truncated = False
1027
1028     if len(blocks) > 20:
1029         truncated = True
1030         blocks = blocks[0 : 19]
1031
1032     for block in blocks:
1033         if block["reason"] == None or block["reason"] == '':
1034             message = message + block["blocked"] + " with unspecified reason\n"
1035         else:
1036             if len(block["reason"]) > 420:
1037                 block["reason"] = block["reason"][0:419] + "[…]"
1038
1039             message = message + block["blocked"] + ' for "' + block["reason"].replace("@", "@\u200b") + '"\n'
1040
1041     if truncated:
1042         message = message + "(the list has been truncated to the first 20 entries)"
1043
1044     botheaders = {**api_headers, **{"Authorization": "Bearer " + config["bot_token"]}}
1045
1046     req = reqto.post(
1047         f"{config['bot_instance']}/api/v1/statuses",
1048         data={
1049             "status"      : message,
1050             "visibility"  : config['bot_visibility'],
1051             "content_type": "text/plain"
1052         },
1053         headers=botheaders,
1054         timeout=10
1055     ).json()
1056
1057     return True
1058
1059 def get_mastodon_blocks(domain: str) -> dict:
1060     # DEBUG: print("DEBUG: Fetching mastodon blocks from domain:", domain)
1061     blocks = {
1062         "Suspended servers": [],
1063         "Filtered media"   : [],
1064         "Limited servers"  : [],
1065         "Silenced servers" : [],
1066     }
1067
1068     try:
1069         doc = bs4.BeautifulSoup(
1070             reqto.get(f"https://{domain}/about/more", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1071             "html.parser",
1072         )
1073     except BaseException as e:
1074         print("ERROR: Cannot fetch from domain:", domain, e)
1075         update_last_error(domain, e)
1076         return {}
1077
1078     for header in doc.find_all("h3"):
1079         header_text = tidyup(header.text)
1080
1081         if header_text in language_mapping:
1082             # DEBUG: print(f"DEBUG: header_text='{header_text}'")
1083             header_text = language_mapping[header_text]
1084
1085         if header_text in blocks or header_text.lower() in blocks:
1086             # replaced find_next_siblings with find_all_next to account for instances that e.g. hide lists in dropdown menu
1087             for line in header.find_all_next("table")[0].find_all("tr")[1:]:
1088                 blocks[header_text].append(
1089                     {
1090                         "domain": tidyup(line.find("span").text),
1091                         "hash"  : tidyup(line.find("span")["title"][9:]),
1092                         "reason": tidyup(line.find_all("td")[1].text),
1093                     }
1094                 )
1095
1096     # DEBUG: print("DEBUG: Returning blocks for domain:", domain)
1097     return {
1098         "reject"        : blocks["Suspended servers"],
1099         "media_removal" : blocks["Filtered media"],
1100         "followers_only": blocks["Limited servers"] + blocks["Silenced servers"],
1101     }
1102
1103 def get_friendica_blocks(domain: str) -> dict:
1104     # DEBUG: print("DEBUG: Fetching friendica blocks from domain:", domain)
1105     blocks = []
1106
1107     try:
1108         doc = bs4.BeautifulSoup(
1109             reqto.get(f"https://{domain}/friendica", headers=headers, timeout=(config["connection_timeout"], config["read_timeout"])).text,
1110             "html.parser",
1111         )
1112     except BaseException as e:
1113         print("WARNING: Failed to fetch /friendica from domain:", domain, e)
1114         update_last_error(domain, e)
1115         return {}
1116
1117     blocklist = doc.find(id="about_blocklist")
1118
1119     # Prevents exceptions:
1120     if blocklist is None:
1121         # DEBUG: print("DEBUG:Instance has no block list:", domain)
1122         return {}
1123
1124     for line in blocklist.find("table").find_all("tr")[1:]:
1125         # DEBUG: print(f"DEBUG: line='{line}'")
1126         blocks.append({
1127             "domain": tidyup(line.find_all("td")[0].text),
1128             "reason": tidyup(line.find_all("td")[1].text)
1129         })
1130
1131     # DEBUG: print("DEBUG: Returning blocks() for domain:", domain, len(blocks))
1132     return {
1133         "reject": blocks
1134     }
1135
1136 def get_misskey_blocks(domain: str) -> dict:
1137     # DEBUG: print("DEBUG: Fetching misskey blocks from domain:", domain)
1138     blocks = {
1139         "suspended": [],
1140         "blocked"  : []
1141     }
1142
1143     offset = 0
1144     step = config["misskey_offset"]
1145     while True:
1146         # iterating through all "suspended" (follow-only in its terminology)
1147         # instances page-by-page, since that troonware doesn't support
1148         # sending them all at once
1149         try:
1150             # DEBUG: print(f"DEBUG: Fetching offset='{offset}' from '{domain}' ...")
1151             if offset == 0:
1152                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1153                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1154                     "sort"     : "+pubAt",
1155                     "host"     : None,
1156                     "suspended": True,
1157                     "limit"    : step
1158                 }), {"Origin": domain})
1159             else:
1160                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1161                 fetched = post_json_api(domain, "/api/federation/instances", json.dumps({
1162                     "sort"     : "+pubAt",
1163                     "host"     : None,
1164                     "suspended": True,
1165                     "limit"    : step,
1166                     "offset"   : offset - 1
1167                 }), {"Origin": domain})
1168
1169             # DEBUG: print("DEBUG: fetched():", len(fetched))
1170             if len(fetched) == 0:
1171                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1172                 break
1173             elif len(fetched) != config["misskey_offset"]:
1174                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1175                 offset = offset + (config["misskey_offset"] - len(fetched))
1176             else:
1177                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1178                 offset = offset + step
1179
1180             for instance in fetched:
1181                 # just in case
1182                 if instance["isSuspended"]:
1183                     blocks["suspended"].append(
1184                         {
1185                             "domain": tidyup(instance["host"]),
1186                             # no reason field, nothing
1187                             "reason": None
1188                         }
1189                     )
1190
1191         except BaseException as e:
1192             print("WARNING: Caught error, exiting loop:", domain, e)
1193             update_last_error(domain, e)
1194             offset = 0
1195             break
1196
1197     while True:
1198         # same shit, different asshole ("blocked" aka full suspend)
1199         try:
1200             if offset == 0:
1201                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1202                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1203                     "sort"   : "+pubAt",
1204                     "host"   : None,
1205                     "blocked": True,
1206                     "limit"  : step
1207                 }), {"Origin": domain})
1208             else:
1209                 # DEBUG: print("DEBUG: Sending JSON API request to domain,step,offset:", domain, step, offset)
1210                 fetched = post_json_api(domain,"/api/federation/instances", json.dumps({
1211                     "sort"   : "+pubAt",
1212                     "host"   : None,
1213                     "blocked": True,
1214                     "limit"  : step,
1215                     "offset" : offset-1
1216                 }), {"Origin": domain})
1217
1218             # DEBUG: print("DEBUG: fetched():", len(fetched))
1219             if len(fetched) == 0:
1220                 # DEBUG: print("DEBUG: Returned zero bytes, exiting loop:", domain)
1221                 break
1222             elif len(fetched) != config["misskey_offset"]:
1223                 # DEBUG: print(f"DEBUG: Fetched '{len(fetched)}' row(s) but expected: '{config['misskey_offset']}'")
1224                 offset = offset + (config["misskey_offset"] - len(fetched))
1225             else:
1226                 # DEBUG: print("DEBUG: Raising offset by step:", step)
1227                 offset = offset + step
1228
1229             for instance in fetched:
1230                 if instance["isBlocked"]:
1231                     blocks["blocked"].append({
1232                         "domain": tidyup(instance["host"]),
1233                         "reason": None
1234                     })
1235
1236         except BaseException as e:
1237             print("ERROR: Exception during POST:", domain, e)
1238             update_last_error(domain, e)
1239             offset = 0
1240             break
1241
1242     #print(f"DEBUG: Updating last_instance_fetch for domain='{domain}' ...")
1243     update_last_instance_fetch(domain)
1244
1245     # DEBUG: print("DEBUG: Returning for domain,blocked(),suspended():", domain, len(blocks["blocked"]), len(blocks["suspended"]))
1246     return {
1247         "reject"        : blocks["blocked"],
1248         "followers_only": blocks["suspended"]
1249     }
1250
1251 def tidyup(string: str) -> str:
1252     # some retards put their blocks in variable case
1253     string = string.lower().strip()
1254
1255     # other retards put the port
1256     string = re.sub("\:\d+$", "", string)
1257
1258     # bigger retards put the schema in their blocklist, sometimes even without slashes
1259     string = re.sub("^https?\:(\/*)", "", string)
1260
1261     # and trailing slash
1262     string = re.sub("\/$", "", string)
1263
1264     # and the @
1265     string = re.sub("^\@", "", string)
1266
1267     # the biggest retards of them all try to block individual users
1268     string = re.sub("(.+)\@", "", string)
1269
1270     return string