commit 20a5434508a5e5a420f3a025e54fe21ad78f28cd Author: diorama Date: Wed Mar 3 23:50:17 2021 +0100 commit iniziale diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..fbe5c2f --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,14 @@ +0BSD + +No rights reserved 2021 diorama + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b652e3b --- /dev/null +++ b/README.md @@ -0,0 +1,47 @@ +# A che serve? +_nopastspam.py_ è uno script che serve a sospendere account [Mastodon](https://mastodon.help/) a zero toot, considerati spam o inattivi, e creati prima di una certa data. Lo script si basa su un [wrapper python](https://github.com/halcy/Mastodon.py) della [API di Mastodon](https://docs.joinmastodon.org/api/). + +## Come si installa? +`cd [QUESTA_CARTELLA]` +`pip3 install -r requirements.txt` + +## Come si usa? +Prima di tutto bisogna cambiare i parametri dello script: +1. `baseurl = 'https://mastodon.domain.tld'` +2. `adminmail = 'admin@mail.tld'` +3. `adminpz = 'passwordveramentebuona'` +inserendo fra gli apicini dopo il segno di uguale l'indirizzo dell'istanza, la mail di un account e la password, rispettivamente per ogni linea. +Fatto questo, lo script va lanciato con `python3` (testato per `python3.6`). +`python3 [QUESTO_SCRIPT]` +Va lanciato una prima volta per registrare lo script sul server. Le volte successive a seconda per lanciarlo effettivamente. +Dopo il primo lancio, basta lanciarlo solo una volta per ogni volta che si vuol + + +## Quali versioni esistono? +Per la versione admin di test, serve un account admin e va tolto il cancelletto davanti la riga che inizia per `#user_flag`. In questo modo si ottengono tutti gli account dell'istanza e si selezionano quelli rilevanti. +Per la versione admin operativa, serve un account admin e va tolto il cancelletto sia davanti la riga che inizia per `#user_flag`, sia davanti la riga che inizia per `#test_flag`. +La versione admin di test è consigliata rispetto alla versione utente di test perché la prima fa una sola richiesta tramite la API di Mastodon mentre la seconda ne fa troppe e [ha un limite](https://docs.joinmastodon.org/api/rate-limits/). Si consiglia di lanciare almeno una volta la versione admin di test e di testare per sé la versione admin operativa. _N.B.: la sospensione degli account non è stata testata._ + +## Quali account sono considerati spam o inattivi? +La versione di default seleziona tutti quegli account creati prima di luglio 2020 (data di default), con zero toot e che soddisfano uno dei due criteri seguenti: +a- account inattivi a zero toot: non hanno metadati, biografia, profilo, intestazione, e hanno un numero di follow maggiore del numero di account in autofollow (numero di default: 7); +b- account spam a zero toot: hanno un link nei metadati o nella biografia. + +### Ma sono criteri discrezionali! +Certo. Come tutti i criteri. + +## Ci sono delle avvertenze? +Sul caso a: non sono selezionati gli account spam a zero toot con meno seguaci del numero predefinito di autofollow. +Sul caso b: non si fa differenza tra link fuori policy o link dentro policy. Un link a un'iniziativa antirepressione sta al livello di un casinò online. (falsi positivi) + +### Come si risolve follow spambot? +Col numero di soglia per l'autofollow non è possibile selezionare gli spambot che rimuovono tutti i follow e ne mettono pochi in altre istanze. Questa è una strategia comune per finire in timeline federata. Il criterio di autofollow è inteso a tutela dee lurker. Altri criteri sono possibili, ad es. raffrontare i follow dell'account con gli autofollow attuali o tutti quelli che ci sono stati nel tempo. Se gli spambot con follow ad altre istanze vi creano particolari problemi, al momento potete cambiare direttamente il codice o forkare. + +## Licenza? +0BSD. + +## Garanzie? +Mai date. Usare lo script a proprio rischio e pericolo. + +## Mano? +Mail qui: _mionomeutente a riseup punto net_. Più facile che legga per mail che altrove. \ No newline at end of file diff --git a/nopastspam.py b/nopastspam.py new file mode 100644 index 0000000..57ea2c4 --- /dev/null +++ b/nopastspam.py @@ -0,0 +1,252 @@ +#!/usr/bin/python3 + +# inizio +print("Inizio...\n") + +def exitp(): + # funzione uscita + print("\nPasso e chiudo...\n") + exit() + +################################################################################ +# moduli +################################################################################ +from os.path import dirname, realpath, isfile, sep +import json +from mastodon import Mastodon +from mastodon.Mastodon import MastodonAPIError +from datetime import datetime +import dateutil.parser +from lxml import html + +################################################################################ +# modalità +################################################################################ +user_flag = True +#user_flag = False # togliere cancelletto inizio riga per versione admin +test_flag = True +#test_flag = False # togliere cancelletto inizio riga per versione operativa (solo admin) + +if user_flag: + print("Versione utente! Solo versione di test disponibile...\n") + test_flag = True +else: + print("Versione admin! Maneggiare con cura se non è una versione di test...\n") + +if test_flag: + print("Versione di TEST!\n") +else: + print("Versione operativa!\n") + checkline = "Sì, so quel che faccio." + try: + rawline = input("Continuare?... Scrivere \"" + checkline + "\"\n") + except EOFError: + exitp() + if rawline != checkline: + print("Non vuoi continuare. Va bene!") + # esci + exitp() + +################################################################################ +# ausiliari +################################################################################ +appname = "mannaggiapp" +baseurl = 'https://mastodon.domain.tld' +clientcredfname = 'clientcred.secret' +usercredfname = 'usercred.secret' +scriptdir = dirname(realpath(__file__)) +usercredfpath = scriptdir + sep + usercredfname + +adminmail = 'admin@mail.tld' +adminpz = 'passwordveramentebuona' + +default_following_count = 7 +avatar_missing = baseurl + "/avatars/original/missing.png" +header_missing = baseurl + "/headers/original/missing.png" + +def find_anchor(element): + if element.text: + anchor = element.find("a") + if anchor is not None: + return True + return False + +################################################################################ +# registro app e token +################################################################################ +print("Registro app e token, oppure uso token...") +if not isfile(usercredfpath): + ############################################################################ + # token non registrato + ############################################################################ + print(" Creo app e token...") + Mastodon.create_app( + appname, + api_base_url = baseurl, + to_file = clientcredfname + ) + mastodon = Mastodon( + client_id = clientcredfname, + api_base_url = baseurl + ) + mastodon.log_in( + adminmail, + adminpz, + to_file = usercredfname + ) + print("Buone notizie! La app \"{}\" è stata registrata!\nOra preoccupati di ***TOGLIERE*** la password dallo script!\nVa bene cambiare il valore di \'adminpz\' con qualsiasi altro valore valido!\nAnche una stringa vuota: letteralmente \"\"".format(appname)) + print("Rilancia lo script dopo aver tolto la password.\n") + exitp() +else: + ############################################################################ + # token registrato + ############################################################################ + print(" Uso token...") + if adminpz: + print("Avviso! Hai tolto la password dallo script?\nSe no, ***TOGLILA***: va bene cambiare il valore di \'adminpz\' con qualsiasi altro valore valido!\nAnche una stringa vuota: letteralmente \"\"\n") + mastodon = Mastodon( + access_token = usercredfname, + api_base_url = baseurl + ) + +################################################################################ +# ottenimento degli accounts +################################################################################ +if test_flag: + ############################################################################ + # ottenimento di account di prova + ############################################################################ + if isfile("accounts-json.txt"): + ######################################################################## + # ottenimento di account da file json + ######################################################################## + print("Ottieni accounts salvati su file json...") + with open("accounts-json.txt", "r") as f: + lines = f.read() + accounts = json.loads(lines) + else: + ######################################################################## + # ottenimento di account da istanza + ######################################################################## + print("Ottieni accounts nella directory dei profili pubblica...") + print(" Seleziona accounts nella directory dei profili (n.b. id salvati su file)") + with open("directory-profili.txt","r") as f: + lines = f.read() + splits = lines.split() + accounts = [] + print(" Ottieni ogni account tramite la API di Mastodon") + print(" N.B.: default di 300 richieste ogni 5 minuti... per 12k accounts sono 3+ ore! Usare interfaccia admin.\nLimite di 270 qui per test.") + nsplits = len(splits) + fsplits = len(str(nsplits)) + for isplit, split in enumerate(splits): + fstr = " {:" + str(fsplits) +"d}/{} {}" + print(fstr.format(isplit+1, nsplits, split)) + try: + account = mastodon.account(split) + accounts.append(account) + except MastodonAPIError as e: + pass # account non trovato (sospeso oppure altro) + if isplit == 271: + break + with open("accounts-json.txt", "w") as f: + json_str = json.dumps(accounts, default=str) + f.write(json_str) + print(" Accounts scritti in formato JSON") +else: + ############################################################################ + # ottenimento di tutti gli account di istanza + ############################################################################ + print("Ottieni tutti gli accounts di istanza...") + try: + accounts = mastodon.admin_accounts() # tutti gli account locali + except MastodonAPIError: + print("L'account \"\" non ha l'autorizzazione necessaria per ottenere tutti gli account di istanza") + exitp() +print("Accounts ottenuti!\n") + +################################################################################ +# selezione (+ sospensione se non è un test) accounts +################################################################################ +for account in accounts: + ############################################################################ + # aggiusta interfaccia + ############################################################################ + # stessa interfaccia admin account dict e user account dict + if "account" in account: + # admin account dict that wraps account dict + # https://mastodonpy.readthedocs.io/en/stable/#admin-account-dicts + account = account["account"] + date_ = account["created_at"] + # controllo su data + if type(date_) == datetime: + date = date_ + elif type(date_) == str: + date = dateutil.parser.parse(date_) + else: + raise TypeError( + "{} è di tipo {} e non è di un tipo appropriato (stringa o datetime)".format(date, type(date)) + ) + ############################################################################ + # condizioni su account + ############################################################################ + # casi 0: quando non sospendere + # salta: se l'account è creato dopo fine giugno 2020 + if date > datetime(2020, 6, 30, 23, 59, 59).astimezone(): # luglio o poi + continue + # salta: se l'account ha almeno uno status + if account["statuses_count"] > 0: # almeno uno status + continue + # variabili di servizio + note = html.fromstring(account["note"]) # biografia del profilo + fields = account["fields"] # metadati del profilo + avatar = account["avatar"] + header = account["header"] + ############################################################################ + # casi 1+: quando sospendere + # caso 1: account "vuoto" + # zero metadati, biografia vuota, zero toot, account vecchio + if note.text is None and len(fields) == 0: # biografia vuota e zero metadati + if account["following_count"] <= default_following_count: + continue # salta: si assume no spam tramite seguiti che vanno in federata) + #TODO: check contro i seguiti di default (n.b. questi cambiano di volta in volta) + if avatar != avatar_missing or header != header_missing: + continue # salta: account personalizzato in profilo o intestazione + #nei casi restanti: sospendi + print(account["url"] + " caso 1") # da sospendere + if not test_flag: + mastodon.admin_account_moderate(account["id"], action="suspend", send_email_notification=False) + continue # salta loop sempre: altri casi sono esclusi (anche in caso di test) + ############################################################################ + # caso 2: account spam + # link nei metadati o biografia, zero toot, account vecchio + #TODO: check email, numeri di telefono + # link nella biografia + if find_anchor(note): + print(account["url"] + " caso 2a") # da sospendere + if not test_flag: + mastodon.admin_account_moderate(account["id"], action="suspend", send_email_notification=False) + continue # possono esserci altri valori di metadati con ancore (vs. caso 1) + # link nei metadati + for field in fields: + namestr = field["name"] + if namestr: + name = html.fromstring(namestr) + # link + if find_anchor(name): + print(account["url"] + " caso 2a1") # da sospendere + if not test_flag: + mastodon.admin_account_moderate(account["id"], action="suspend", send_email_notification=False) + continue # possono esserci altri nomi di metadati con ancore (vs. caso 1) + valuestr = field["value"] + if valuestr: + value = html.fromstring(valuestr) + if find_anchor(value): + print(account["url"] + " caso 2a2") # da sospendere + if not test_flag: + mastodon.admin_account_moderate(account["id"], action="suspend", send_email_notification=False) + continue # possono esserci altri valori di metadati con ancore (vs. caso 1) + ############################################################################ + +# fine +exitp() + diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..f00b35a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +Mastodon.py==1.5.1 +lxml==4.6.2