Advertisement
Guest User

hemuhi.py

a guest
Aug 20th, 2021
203
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 12.73 KB | None | 0 0
  1. # -*- coding: utf-8 -*-
  2. # Version alfa 2.0
  3. # Creado en python 3.9.1
  4. # Requiere requests 2.26.0
  5. from io import FileIO
  6. import re
  7. import string
  8. import os.path as patos
  9. import pickle
  10. from typing import BinaryIO
  11. import requests
  12. import random
  13. import time
  14.  
  15. from requests.models import Response
  16.  
  17. class BoardData:
  18.     """
  19.    Clase que almacena los datos asociados a un tablon
  20.    """
  21.     # Tamaño maximo del archivo en bytes
  22.     MAX_FILE_SIZE = 25165824
  23.     # Tamaño maximo del mensaje
  24.     MAX_MESSAGE_LENGTH = 8000
  25.    
  26.     def __init__(self, name:str, fname:str='', files:list[str]=None, spoiler=False,
  27.         sfw=True, **kwargs):
  28.         """
  29.        Constructor
  30.        name: El nombre del tablon mostrado en la url
  31.        fname: El nombre completo del tablon, opcional
  32.        files: Una lista con las extenciones de los archivos permitidos, opcional
  33.        spoiler: Indica si es posible utilizar una imagen con spoiler, defecto False
  34.        sfw: Indica si el tablon es SFW, defecto True
  35.  
  36.        kwargs
  37.        fsize: Indica el tamaño maximo de archivo para este tablon, defecto MAX_FILE_SIZE
  38.        """
  39.         if name is None:
  40.             raise ValueError('Argument "name" is None')
  41.         if name.isspace() or len(name) == 0:
  42.             raise ValueError('Argument "name" is an empty or blank string')
  43.        
  44.         self.name = name
  45.         self.full_name = fname
  46.         self.can_spoiler = spoiler
  47.         self.allowed_files = files
  48.         self.is_sfw = sfw
  49.         self.max_file_size = kwargs.get('fsize', self.MAX_FILE_SIZE)
  50.    
  51.     def accepts_file(self, p):
  52.         """Determina si es posible postear un archivo en el tablon dada su ruta"""
  53.         if not patos.isfile(p) or patos.islink(p) or patos.getsize(p) > self.max_file_size:
  54.             return False
  55.  
  56.         f, ext = patos.splitext(p)
  57.         return ext in self.allowed_files
  58.  
  59.     def get_url(self):
  60.         return 'https://www.hispachan.org/' + self.name
  61.    
  62. class FileExts:
  63.     """
  64.    Almacena las extensiones de archivo permitidas para varios tablones
  65.    """
  66.     IMAGE =         ['.gif', '.jpg', '.png']
  67.     VIDEO =         ['.mp4', '.webm']
  68.     AUDIO =         ['.mp3', '.ogg']
  69.     DOCUMENT =      ['.pdf']
  70.     COMIC =         ['.cbr', '.cbz']
  71.     OTHER =         ['.swf']
  72.     AUDIOVISUAL =   IMAGE + VIDEO + AUDIO
  73.     STANDARD =      AUDIOVISUAL + DOCUMENT + OTHER
  74.     ALL =           STANDARD + COMIC
  75.  
  76. BOARDS = {
  77.     'ac':   BoardData('ac', 'Animacion y comics', FileExts.ALL, True, True),
  78.     'art':  BoardData('art', 'Arte', FileExts.STANDARD, False, False),
  79.     'a':    BoardData('a', 'Anime, manga y cultura japonesa', FileExts.STANDARD, True),
  80.     'b':    BoardData('b', 'Balcón', FileExts.STANDARD, False, False),
  81.     'i':    BoardData('i', 'Adictos a internet', FileExts.STANDARD, False, True),
  82.     'pl':   BoardData('pl', 'Plaza', FileExts.STANDARD, False, False),
  83.     't':    BoardData('t', 'Tecnología', FileExts.STANDARD, False, True),
  84.     'v':    BoardData('v', 'Videojuegos', FileExts.STANDARD, True, True),
  85. }
  86.  
  87. class PostPath:
  88.     def __init__(self) -> None:
  89.         pass
  90.  
  91. class PostData:
  92.     __thread_format = 'https://www.hispachan.org/{0}/res/{1}.html'
  93.  
  94.     OPTION_SAGE = 'sage'
  95.     OPTION_BUMP = ''
  96.  
  97.     def __init__(self, pid:int=0, board:BoardData=None, msg='', opt='', fname='', **kw):
  98.         """
  99.        Crea un objeto que almacena la informacion de un post
  100.  
  101.        pid:    El id del hilo al que este post pertenece
  102.        board:  El tablon al cual pertenece el hilo
  103.        msg:    El mensaje del post
  104.        opt:    Sage o bump
  105.        fname:  La ruta del archivo adjuntado al post        
  106.        
  107.        kwargs
  108.        spoiler:    Indica si postear el archivo con la opcion de spoiler
  109.        id:         Indica el numero de este post, defecto -1
  110.        """
  111.         self.parent_id = pid
  112.         self.board = board
  113.         self.post_id = kw.get('id', -1)
  114.         self.message = msg
  115.         self.option = opt
  116.         self.file = fname
  117.         self.spoiler = False
  118.  
  119.     def is_thread(self):
  120.         """
  121.        Indica si este post es un hilo
  122.        """
  123.         return self.parent_id == 0
  124.    
  125.     def set_parent_path(self, p: str):
  126.         """
  127.        Fija el valor de board y thread basandose en una cadena con formato /board/thread
  128.        Ejemplos: /a/12345 /b/3333 /c/1212 /d/1144
  129.        """
  130.         splited = p.split('/')
  131.         try:
  132.             tn = int(splited[2])
  133.         except:
  134.             raise ValueError('Could not get the post number from the given path')
  135.  
  136.         if tn <= 0:
  137.             raise ValueError('Invalid value for post number')
  138.  
  139.         self.parent_id = tn
  140.         # El primer elemento del array despues de invocar a split con el formato
  141.         # /board/thread es una cadena vacia
  142.         if splited[1] in BOARDS:
  143.             self.board = BOARDS[splited[1]]
  144.         else:
  145.             raise ValueError('Board does not exist')
  146.  
  147.     def get_parent_path(self) -> str:
  148.         return str.format('/{0}/{1}', self.board.name, self.parent_id)
  149.  
  150.     parent_path = property(get_parent_path, set_parent_path)
  151.  
  152.     def get_thread_url(self):
  153.         return self.__thread_format.format(self.board.name, self.parent_id)
  154.  
  155.     def get_url(self):
  156.         return self.__thread_format.format(self.board.name, self.post_id)
  157.  
  158. class Poster:
  159.     __pwdchars = string.ascii_letters + string.digits
  160.     __pwdlen = 8
  161.  
  162.     __fail_urls = frozenset()
  163.     __board_php = 'https://www.hispachan.org/board.php'
  164.     __lpost_re = re.compile('(?P<board>[a-zA-Z]+)ZETA(?P<id>\d+)')
  165.  
  166.     def __init__(self, abort=True, tries=2, try_dealy=8, cookiesfile='./hemuhi.cookies'):
  167.         self.__session = None
  168.         self.on_posted = None
  169.         self.on_post_fail = None
  170.         self.on_try_fail = None
  171.         self.abort_on_fail = abort
  172.         self.cookies_file = cookiesfile
  173.         self.tries = tries
  174.         self.try_delay = try_dealy
  175.    
  176.     def begin_postintg(self):
  177.         """
  178.        Carga las cookies desde el archivo e inicia la session de requests
  179.        """
  180.         self.__session = requests.Session()
  181.         # Carga las cookies desde el archivo, si no existen __check_cookies las crea
  182.         if patos.exists(self.cookies_file):
  183.             with open(self.cookies_file, mode='rb') as file:
  184.                 self.__session.cookies = pickle.load(file, encoding='utf-8')
  185.         self.__check_cookies()
  186.  
  187.     def end_posting(self):
  188.         """
  189.        Guarda las cookies en el archivo y cierra la session de requests
  190.        """
  191.         open_mod = 'wb' if patos.exists(self.cookies_file) else 'xb'
  192.         with open(self.cookies_file, mode=open_mod) as file:
  193.             pickle.dump(self.__session.cookies, protocol=0, file=file)
  194.         self.__session.close()
  195.  
  196.     def __enter__(self):
  197.         self.begin_postintg()
  198.         return self
  199.  
  200.     def __exit__(self, exc_type, exc_value, traceback):
  201.         self.end_posting()
  202.  
  203.     @classmethod
  204.     def __generate_password(cls):
  205.         """Genera un password para el post, es la funcion get_password de kusaba.js"""
  206.         return ''.join(random.sample(cls.__pwdchars, cls.__pwdlen))
  207.  
  208.     @staticmethod
  209.     def __form_data_from_post(post: PostData) -> tuple[dict[str], dict[str, BinaryIO]]:
  210.         """
  211.        Obtiene la representacion de un post en forma de un diccionario con la form data
  212.        para ser enviado
  213.        """
  214.         fdata = {
  215.             'board': post.board.name, # El tablon al cual pertenece el hilo
  216.             'replythread': post.parent_id, # El hilo al cual se responde
  217.             'MAX_FILE_SIZE': post.board.max_file_size, # Tamaño maximo de archivo (24MB)
  218.             'em': post.option, # La oocuib del post (bump, noko, sage, ...)?
  219.             'message': post.message, # El mensaje del post
  220.             'email': '' # Campo de email, posiblemete legado de futabas
  221.         }
  222.         files = {}
  223.  
  224.         # Agrega la opcion de spoiler si la activo y el tablon lo permite
  225.         if post.spoiler and post.board.can_spoiler:
  226.             fdata['spoiler'] = 'on'
  227.  
  228.         # Si no se desea postear un archivo
  229.         if post.file is None or post.file == '':
  230.             return (fdata, files)
  231.  
  232.         try:
  233.             files['imagefile'] = open(post.file, 'rb')
  234.             # Aun se desconoce si otro tipo de archivos utilizan otro nombre de campo
  235.         except:
  236.             raise ValueError('Could not open file')
  237.  
  238.         return (fdata, files)
  239.  
  240.     def __check_cookies(self):
  241.         """
  242.        Verifica si las contraseñas y 'PHPSESSID' estan almacenadas en la session.
  243.        Si no existen, genera las contraseñas y obtiene PHPSESSID
  244.        """
  245.         if 'PHPSESSID' not in self.__session.cookies:
  246.             response = self.__session.get(self.__board_php)
  247.             # Visita requerida para obtener la cookie 'PHPSESSID'
  248.             # Solo fui capaz de obtenerla a traves de esta url
  249.             if not (response.ok and 'PHPSESSID' in response.cookies):
  250.                 raise Exception('Could not get the PHPSESSID cookie!')
  251.  
  252.         if 'undefined' not in self.__session.cookies:
  253.             self.__session.cookies['undefined'] = self.__generate_password()
  254.  
  255.         if 'postpassword' not in self.__session.cookies:
  256.             self.__session.cookies['postpassword'] = self.__generate_password()
  257.  
  258.     def _posted(self, post, response):
  259.         """
  260.        Funcion invocada cuando un post es posteado exitosamente
  261.        """
  262.         if self.on_posted is not None:
  263.             self.on_posted(post, response)
  264.  
  265.     def _post_failed(self, post, response):
  266.         """
  267.        Funcion invocada cuando no fue posible postear un post
  268.        """
  269.         if self.on_post_fail is not None:
  270.             self.on_post_fail(post, response)
  271.    
  272.     def _try_failed(self, post, response, number):
  273.         """
  274.        Funcion invocada cuando falla uno de los intentos para postear un post
  275.        """
  276.         if self.on_try_fail is not None:
  277.             self.on_try_fail(post, response, number)
  278.  
  279.     def __succesfull_post(self, response: Response, post: PostData) -> bool:
  280.         """
  281.        Determina si un post fue posteado correctamente
  282.        """
  283.         if not response.ok or response.url in self.__fail_urls:
  284.             return False
  285.        
  286.         board_url = post.board.get_url()
  287.         # Las respuestas exitosas redirigen a https://www.hispachan.org/abc/ (no noko)
  288.         # o a https://www.hispachan.org/abc/res/12345.html (noko)
  289.         return board_url == response.url or board_url in response.url
  290.  
  291.     def __last_post_from_cookies(self):
  292.         """
  293.        Intenta obtener el id del ultimo post almacenado en la cookie last_post
  294.        """
  295.         m = self.__lpost_re.match(self.__session.cookies['last_post'])
  296.         post_id = int(m['id'])
  297.         board_name = m['board']
  298.         return (board_name, post_id)
  299.  
  300.     def __post(self, post: PostData):
  301.         """
  302.        Envia el post y verifica que haya sido posteado correctamente
  303.        Regresa el numero del post si fue exitoso o -1 si fallo
  304.        """
  305.         current_try = 1
  306.         post_id = -1
  307.         response = None
  308.         data, files = self.__form_data_from_post(post)
  309.         # minetras haya intentos y el post no haya sido posteado
  310.         while current_try <= self.tries and post_id == -1:
  311.             response = self.__session.post(self.__board_php, data=data, files=files)
  312.             if self.__succesfull_post(response, post):
  313.                 board, post_id = self.__last_post_from_cookies()
  314.                 post.post_id = post_id
  315.                 self._posted(post, response)
  316.             else:
  317.                 # Si el itento fallo aumentar el valor del intento e invocar _try_failed
  318.                 self._try_failed(post, response, current_try)
  319.                 current_try += 1            
  320.                 time.sleep(self.try_delay)
  321.  
  322.         # Cerrar el archivo abierto
  323.         for fname in files:
  324.             if not files[fname].closed:
  325.                 files[fname].close()
  326.  
  327.         # Invocar _post_failed si fallo
  328.         if post_id == -1:
  329.             self._post_failed(post, response)
  330.  
  331.         return post_id      
  332.  
  333.     def post(self, post: PostData) -> int:
  334.         """
  335.        Crea un post en un hilo y regresa su id o -1 si no fue enviado,
  336.        actualmente no se puede crear un hilo con este metodo
  337.        """
  338.         return self.__post(post)        
  339.  
  340.     def multi_post(self, posts: PostData, delay: float=1.0) -> list[int]:
  341.         """
  342.        Crea multiples posts en un hilo,
  343.        regresa una lista con los ids de los posts creados
  344.        """
  345.         ids = []
  346.         for post in posts:
  347.             post_id = self.__post(post)
  348.             if self.abort_on_fail and post_id == -1:
  349.                 return posts
  350.             ids.append(post_id)
  351.             time.sleep(delay)
  352.         return posts
  353.  
Advertisement
Add Comment
Please, Sign In to add comment
Advertisement