Usuari:SignaBot/scripts/cawiki/signbot.py

De la Viquipèdia, l'enciclopèdia lliure
"""
(!C) 2023 Coet.

Copyleft: Feu el que vos convinga en sóc l'autor però  decline tota responsabilitat i no m'importa el que feu amb
la integralitat o alguna part del codi. Si el conjunt o part d'eines, classes i mètodes, vos resulten útils i
podeu tornar a gastar-los no us perseguiré demanant-ne res i no m'importa si heu de canviar la llicència o autoria :)

OBJECTIU: obtenir darrera data a una secció sense signatures per a que [[Usuari:ArxivaBot|ArxivaBot]] puga arxivar la
secció.

Primera execució: [[Special:Diff/31301378]]
"""
import locale
import pytz
import re
import time
from datetime import datetime
from typing import Dict, List, Optional, Tuple, NoReturn

from pywikibot import Site, Page, Timestamp

# globals
site = Site('ca', 'wikipedia', 'SignaBot')
locale.setlocale(locale.LC_ALL, 'Catalan_Andorra.1250')
local_tz = pytz.timezone('Europe/Andorra')


class Thread:
    """
    Classe per a una secció.
    Obtenim títol, contingut i primera signatura. S'instància només amb el títol i el contingut.
    """
    def __init__(self, title, body):
        self.title: str = title
        self.body: str = body
        self.first_date: Optional[datetime] = None


class Unsigned:
    """
    Classe per a una pàgina contenint alguna secció sense signar.
    Retenim la pàgina on es troba el fil que no conté cap signatura i dos atributs més: el fil en qüestió i l'anterior.
    El fet d'agafar l'anterior és sols orientatiu, per reduir les cerques, d'este fil ens interessa la darrera data,
    l'utilitzarem per no eternitzar la recerca.
    """
    def __init__(self, page, prev, cur):
        self.page: Optional[Page] = page
        self.prev_thread: Optional[Thread] = prev
        self.cur_thread: Optional[Thread] = cur


class Months:
    """
    Classe per a relacionar els mesos de la VP amb els del Python (del sitema operatiu en ús).
    Tenim febrer que és `feb´ a VP i `febr.´ al Windows, també: `ago´ és `ag.´. I en este mòdul necesitem la
    correspondència tant en un sentit com en l'altre.
    """
    wiki_months = [short for long, short in site.months_names]
    system_months = [time.strftime("%b", time.strptime(f"01-{_:>02}-2023", "%d-%m-%Y")) for _ in range(1, 13)]

    @classmethod
    def to_wiki(cls, sys_month: str) -> str:
        index = cls.system_months.index(sys_month)
        return cls.wiki_months[index]

    @classmethod
    def to_system(cls, wiki_month: str):
        index = cls.wiki_months.index(wiki_month)
        return cls.system_months[index]


class SignBot:
    """
    L'objectiu d'esta classe és comprovar que els fils tinguen almenys una data en la qual el bot es puga basar per a
    arxivar el fil.

    Quan ens trobem amb un fil que no conté cap data, agafem l'historial de la pàgina i cerquem als resums d'edició
    els que continguen el títol de la secció.

    Coses que negligem:
    - que un usuari esborre el títol en el qual ha fet un comentari.
    - que la darrera data del comentari siga posterior, això faria que el bot no puguera accedir a la informació
    """
    def __init__(self):
        self.page: Optional[Page] = None
        self.target_pages: List[Page] = []
        self.undated: List[Unsigned] = []

    @staticmethod
    def get_datetime(dt: Tuple[str, str, str, str]) -> datetime:
        """
        D'una tupla, resultat d'una expressió regular, obtenim la data de la signatura i hem de
        processar el mes que no correspon al mes que Python té. Amb això retornem un objecte
        de tipus datetime, que ens servirà per a calcular, ordenar, etc.
        :param dt:
        :return:
        """
        time_str, day, month, year = dt
        dt_str = f'{time_str}, {day} {Months.to_system(month)} {year}'
        return datetime.strptime(dt_str, '%H:%M, %d %b %Y')

    def get_first_date(self, body):
        """
        Extraem la darrera data de les signatures que hi ha al fil.
        :param body:
        :return:
        """
        dates = re.findall(r'(?P<time>\d{2}:\d{2}), (?P<day>\d{1,2}) (?P<month>\w{3,4}) (?P<year>\d{4})', body)
        dates = [self.get_datetime(dt) for dt in dates]
        dates.sort()
        return dates[0] if dates else None

    def get_dates(self, threads: Dict[str, Thread]) -> NoReturn:
        """
        Retenim la darrera data del fil. Si el fil no té cap data, l'afegim a undated que serà una llista d'objectes
        de la classe Unsigned.
        :param threads:
        :return:
        """
        previous_thread = None
        for thread in threads:
            thread = threads[thread]
            thread.first_date = self.get_first_date(thread.body)
            if not thread.first_date:
                self.undated.append(Unsigned(self.page, prev=previous_thread, cur=thread))
            previous_thread = thread

    @staticmethod
    def get_threads(page: Page) -> Dict[str, Thread]:
        """
        Convertim els fils de discussió en un diccionari de Threads
        :param page: Page
        :return: Dict[str, Thread]
        """
        content = page.get(force=True)
        split_text = re.split(r"\n== (.*?) ==\n*", content)
        threads = {title: Thread(title, body) for title, body in zip(split_text[1::2], split_text[2::2])}
        return threads

    def survey_pages(self) -> NoReturn:
        for page in self.target_pages:
            self.page = page
            threads = self.get_threads(page)
            if threads:
                self.get_dates(threads)
            print(page.title(), len(threads))

    @staticmethod
    def get_local_timestamp(timestamp: Timestamp) -> str:
        """
        El Timestamp que ens retorna el pywikibot és amb l'hora del servidor (UTC), li hem de sumar una o dues
        hores segons el nostre horari.
        :param timestamp: pywikibot.Timestamp
        :return: str
        """
        tz_name = local_tz.tzname(timestamp)
        timestamp = timestamp + local_tz.utcoffset(timestamp)
        dt_str = timestamp.strftime(f"%H:%M, %d %b %Y ({tz_name})")
        syst_month = timestamp.strftime(f"%b")
        wiki_month = Months.to_wiki(syst_month)
        return dt_str.replace(syst_month, wiki_month)

    def sign(self) -> NoReturn:
        """
        Hem trobat un fil sense signatures, cerquem el primer resum d'edició que continga el títol de la secció
        i inserim la plantilla {{sense signar}}
        * Assumim que el primer resum d'edició trobat és el darrer comentari deixat (que deuria ser el de la plantilla
        {{tancat}}, {{fet}} o {{no fet}}
        """
        for undated in self.undated:
            page = undated.page
            title = undated.cur_thread.title
            first_date = undated.prev_thread.first_date
            for loops, rev in enumerate(page.revisions(endtime=first_date), 1):
                if title in rev.comment:
                    old_content = undated.cur_thread.body
                    dt_str = self.get_local_timestamp(rev.timestamp)
                    new_content = f'{old_content}{{{{subst:sense signar|{rev.user}|{dt_str}}}}}\n'
                    old_text = page.get(force=True)
                    new_text = old_text.replace(old_content, new_content)
                    page.put(
                        new_text,
                        summary=f"Bot: afegint signatura de l'usuari {rev.user} a secció /* {title} */ - "
                                f"[[Special:Diff/{rev.revid}]]. S'ha hagut de remenejar {loops} revisions :P"
                    )
                    break

    def run(self):
        titles = (
            "Canvi de nom d'usuari", "Bots/Sol·licituds", "Petició de marca de bot", "Petició als administradors",
            "Sala dels administradors"
        )
        self.target_pages = [Page(site, f'Viquipèdia:{title}') for title in titles]
        self.survey_pages()
        self.sign()


if __name__ == '__main__':
    signabot = SignBot()
    signabot.run()