diff --git a/pyproject.toml b/pyproject.toml index f8fa999..ace8d58 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.11" -s3p-sdk = "^0.2.7" +s3p-sdk = "0.2.10" pytz = "^2024.2" dateparser = "^1.2.0" diff --git a/readme.md b/readme.md index 9842b36..73a590f 100644 --- a/readme.md +++ b/readme.md @@ -78,7 +78,15 @@ git fetch --all ```shell git merge template/main --allow-unrelated-histories ``` -3. Обновить код плагина и тестов при необходимости. +3. При возникновении конфликтов, нужно принять все изменения из template, а затем подстраивать свой код. +> [!NOTE] +> После синхронизации с шаблоном, нужно обновить зависимости. +```shell +poetry install +# или, при ошибке установки можно обновить записимость вручную. +poetry add s3p-sdk@[relevant version] +``` +4. Обновить код плагина и тестов при необходимости. ## Требования к разработке плагина @@ -211,28 +219,30 @@ pytest -v Ниже приведен пример парсера с подробным описанием. ```python from s3p_sdk.plugin.payloads.parsers import S3PParserBase -from s3p_sdk.types import S3PRefer, S3PDocument, S3PPlugin +from s3p_sdk.types import S3PRefer, S3PPlugin, S3PPluginRestrictions +from s3p_sdk.exceptions.parser import S3PPluginParserOutOfRestrictionException, S3PPluginParserFinish +from selenium.webdriver.support.wait import WebDriverWait +from selenium.webdriver.remote.webdriver import WebDriver class MyTemplateParser(S3PParserBase): """ - Парсер плагина, который использует `S3PParserBase` + Parser plugin that uses `S3PParserBase` """ - def __init__(self, refer: S3PRefer, plugin: S3PPlugin, web_driver: WebDriver, max_count_documents: int = None, last_document: S3PDocument = None): + def __init__(self, refer: S3PRefer, plugin: S3PPlugin, restrictions: S3PPluginRestrictions, web_driver: WebDriver): """ - Конструктор парсера плагина. + Constructor for the parser plugin. - Обязательные параметры (передаются платформой): - :param:refer S3PRefer - источник, который обрабатывает плагин. - :param:plugin S3PPlugin - метаданные плагина. + Required parameters (passed by the platform): + :param refer: S3PRefer - the source processed by the plugin. + :param plugin: S3PPlugin - plugin metadata. + :param restrictions: S3PPluginRestrictions - restrictions for parsing (maximum_materials, to_last_material, from_date, to_date). - Вариативные параметры (Требуюется указать эти параметры в src//config.py): - :param:max_count_documents int - максимальное число документов, которые должен собирать парсер. - - Остальные параметры могут не передаваться в конструктор класса. Они могут быть добавлены по желанию разработчика парсера. (Требуюется указать эти параметры в src//config.py). - Но, стоит учитывать правило "все, что может быть параметризовано - должно быть параметризовано". + Other parameters can be added at the discretion of the parser developer. + (These parameters should be specified in src//config.py). + However, it's worth considering the rule "everything that can be parameterized should be parameterized". """ - super().__init__(refer, plugin, max_count_documents, last_document) + super().__init__(refer, plugin, restrictions) # Тут должны быть инициализированы свойства, характерные для этого парсера. Например: WebDriver self._driver = web_driver @@ -240,16 +250,27 @@ class MyTemplateParser(S3PParserBase): def _parse(self) -> None: """ - Главные метод класса парсера, перегружающий метод класса `S3PParserBase`. + The main method of the parser class, overriding the method of the `S3PParserBase` class. - Этот метод будет вызван платформой при запуске парсера. - Это обязывает разработчика парсить источник в этом методе (безусловно, разработчик может создавать дополнительные методы внутри этого класса). + This method will be called by the platform when the parser is launched. + This obliges the developer to parse the source in this method + (of course, the developer can create additional methods within this class). """ for article in self.test_data(): - - # Метод self._find(:S3PDocument) вызывается при парсинге для того, чтобы отдать найденный документ платформе. - # Разработчик обязан использовать только этот метод при парсинге. - # Разработчику не нужно думать над тем, что происходит дальше. Платформа сама остановит работу парсера при выполнении ряда условий: собрано нужное число документов. - self._find(article) + try: + # The self._find(:S3PDocument) method is called during parsing to give the found document to the platform. + # The developer must use only this method when parsing. + # The developer doesn't need to think about what happens next. + # The platform itself will stop the parser's work when certain conditions are met: + # the required number of documents has been collected, date restrictions are met, or the last document is found. + self._find(article) + except S3PPluginParserFinish as e: + # Parsing is finished due to restrictions + raise e + except S3PPluginParserOutOfRestrictionException: + # Document is out of date range, continue to next material. + # You can also use a restriction exception to skip irrelevant materials later on. + continue + ``` diff --git a/src/s3p_plugin_parser_techcrunch/config.py b/src/s3p_plugin_parser_techcrunch/config.py index 2d01b6a..4cfb3df 100644 --- a/src/s3p_plugin_parser_techcrunch/config.py +++ b/src/s3p_plugin_parser_techcrunch/config.py @@ -7,7 +7,7 @@ trigger, MiddlewareConfig, modules, - payload + payload, RestrictionsConfig ) from s3p_sdk.plugin.types import SOURCE from s3p_sdk.module import ( @@ -19,7 +19,13 @@ reference='techcrunch', # уникальное имя источника type=SOURCE, # Тип источника (SOURCE, ML, PIPELINE) files=['techcrunch.py', ], # Список файлов, которые будут использоваться в плагине (эти файлы будут сохраняться в платформе) - is_localstorage=False + is_localstorage=False, + restrictions=RestrictionsConfig( + maximum_materials=None, + to_last_material=None, + from_date=datetime.datetime(2024, 8, 1), + to_date=None, + ) ), task=TaskConfig( trigger=trigger.TriggerConfig( @@ -30,8 +36,7 @@ middleware=MiddlewareConfig( modules=[ modules.TimezoneSafeControlConfig(order=1, is_critical=True), - modules.FilterOnlyNewDocumentWithDB(order=2, is_critical=True), - modules.SaveDocument(order=3, is_critical=True), + modules.SaveOnlyNewDocuments(order=2, is_critical=True), ], bus=None, ), @@ -41,10 +46,7 @@ entry=payload.entry.EntryConfig( method='content', params=[ - payload.entry.ModuleParamConfig(key='driver', module_name=WebDriver, bus=True), - payload.entry.ConstParamConfig(key='max_count_documents', value=50), - # payload.entry.ConstParamConfig(key='url', - # value='url to the source page'), + payload.entry.ModuleParamConfig(key='web_driver', module_name=WebDriver, bus=True), ] ) ) diff --git a/src/s3p_plugin_parser_techcrunch/techcrunch.py b/src/s3p_plugin_parser_techcrunch/techcrunch.py index bc23b39..304284f 100644 --- a/src/s3p_plugin_parser_techcrunch/techcrunch.py +++ b/src/s3p_plugin_parser_techcrunch/techcrunch.py @@ -2,8 +2,10 @@ import dateparser import time +from s3p_sdk.exceptions.parser import S3PPluginParserFinish, S3PPluginParserOutOfRestrictionException from s3p_sdk.plugin.payloads.parsers import S3PParserBase -from s3p_sdk.types import S3PRefer, S3PDocument, S3PPlugin +from s3p_sdk.types import S3PRefer, S3PDocument, S3PPlugin, S3PPluginRestrictions +from s3p_sdk.types.plugin_restrictions import FROM_DATE from selenium.common import NoSuchElementException from selenium.webdriver.chrome.webdriver import WebDriver from selenium.webdriver.common.by import By @@ -18,9 +20,8 @@ class Techcrunch(S3PParserBase): HOST = "https://techcrunch.com/category/fintech/" utc = pytz.UTC - def __init__(self, refer: S3PRefer, plugin: S3PPlugin, web_driver: WebDriver, max_count_documents: int = None, - last_document: S3PDocument = None): - super().__init__(refer, plugin, max_count_documents, last_document) + def __init__(self, refer: S3PRefer, plugin: S3PPlugin, web_driver: WebDriver, restrictions: S3PPluginRestrictions): + super().__init__(refer, plugin, restrictions) # Тут должны быть инициализированы свойства, характерные для этого парсера. Например: WebDriver self._driver = web_driver @@ -106,7 +107,13 @@ def _parse(self): published=pub_date, loaded=None, ) - self._find(document) + try: + self._find(document) + except S3PPluginParserOutOfRestrictionException as e: + if e.restriction == FROM_DATE: + self.logger.debug(f'Document is out of date range `{self._restriction.from_date}`') + raise S3PPluginParserFinish(self._plugin, + f'Document is out of date range `{self._restriction.from_date}`', e) self._driver.close() self._driver.switch_to.window(self._driver.window_handles[0]) diff --git a/src/s3p_plugin_parser_techcrunch/template_payload.py b/src/s3p_plugin_parser_techcrunch/template_payload.py deleted file mode 100644 index d607f5d..0000000 --- a/src/s3p_plugin_parser_techcrunch/template_payload.py +++ /dev/null @@ -1,88 +0,0 @@ -import datetime -import time - -from s3p_sdk.plugin.payloads.parsers import S3PParserBase -from s3p_sdk.types import S3PRefer, S3PDocument, S3PPlugin -from selenium.common import NoSuchElementException -from selenium.webdriver.chrome.webdriver import WebDriver -from selenium.webdriver.common.by import By -from selenium.webdriver.support import expected_conditions as ec -from selenium.webdriver.support.ui import WebDriverWait - - -class MyTemplateParser(S3PParserBase): - """ - A Parser payload that uses S3P Parser base class. - """ - - def __init__(self, refer: S3PRefer, plugin: S3PPlugin, web_driver: WebDriver, max_count_documents: int = None, - last_document: S3PDocument = None): - super().__init__(refer, plugin, max_count_documents, last_document) - - # Тут должны быть инициализированы свойства, характерные для этого парсера. Например: WebDriver - self._driver = web_driver - self._wait = WebDriverWait(self._driver, timeout=20) - - def _parse(self) -> None: - for article in self._test_data(): - self._find(article) - - def _test_data(self) -> list[S3PDocument]: - out = [ - S3PDocument(None, "title-test-1", None, None, 'web-link-test-1', None, None, datetime.datetime.now(), None), - S3PDocument(None, "title-test-2", None, None, 'web-link-test-2', None, None, datetime.datetime.now(), None), - S3PDocument(None, "title-test-3", None, None, 'web-link-test-3', None, None, datetime.datetime.now(), None), - S3PDocument(None, "title-test-4", None, None, 'web-link-test-4', None, None, datetime.datetime.now(), None) - ] - return out - - def _example_parse_page(self, url: str) -> S3PDocument: - doc = self._example_page_init(url) - return doc - - def _example_page_init(self, url: str) -> S3PDocument: - self._example_initial_access_source(url) - return S3PDocument(None, None, None, None, None, None, None, None, None) - - def _example_encounter_pages(self) -> str: - """ - Формирование ссылки для обхода всех страниц - """ - _base = 'self.URL' - _param = f'&page=' - page = 0 - while True: - url = str(_base) + _param + str(page) - page += 1 - yield url - - def _example_collect_doc_links(self, _url: str) -> list[str]: - """ - Формирование списка ссылок на материалы страницы - """ - try: - self._example_initial_access_source(_url) - self._wait.until(ec.presence_of_all_elements_located((By.CLASS_NAME, ''))) - except Exception as e: - raise NoSuchElementException() from e - links = [] - - try: - articles = self._driver.find_elements(By.CLASS_NAME, '') - except Exception as e: - raise NoSuchElementException('list is empty') from e - else: - for article in articles: - try: - doc_link = article.find_element(By.TAG_NAME, 'a').get_attribute('href') - except Exception as e: - raise NoSuchElementException( - 'Страница не открывается или ошибка получения обязательных полей') from e - else: - links.append(doc_link) - return links - - def _example_initial_access_source(self, url: str, delay: int = 2): - self._driver.get(url) - self.logger.debug('Entered on web page ' + url) - time.sleep(delay) diff --git a/tests/config/test_config.py b/tests/config/test_config.py index 60d44b1..e495480 100644 --- a/tests/config/test_config.py +++ b/tests/config/test_config.py @@ -8,8 +8,9 @@ from tests.config.fixtures import fix_plugin_config, project_config from s3p_sdk.plugin.config import ( - PluginConfig, CoreConfig, TaskConfig, MiddlewareConfig, PayloadConfig, + PluginConfig, CoreConfig, TaskConfig, MiddlewareConfig, PayloadConfig, RestrictionsConfig ) +import s3p_sdk.module as s3p_module class PluginStructure: @@ -46,9 +47,12 @@ def test_config_plugin_structure(self, fix_plugin_config): _cplugin = fix_plugin_config.__dict__.get(PluginStructure.PLUGIN) assert isinstance(_cplugin.__dict__.get('reference'), str) - assert isinstance(_cplugin.__dict__.get('type'), str) and str(_cplugin.__dict__.get('type')) in (SOURCE, ML, PIPELINE) - assert isinstance(_cplugin.__dict__.get('files'), list) and all([isinstance(it, str) for it in _cplugin.__dict__.get('files')]) + assert isinstance(_cplugin.__dict__.get('type'), str) and str(_cplugin.__dict__.get('type')) in ( + SOURCE, ML, PIPELINE) + assert isinstance(_cplugin.__dict__.get('files'), list) and all( + [isinstance(it, str) for it in _cplugin.__dict__.get('files')]) assert isinstance(_cplugin.__dict__.get('is_localstorage'), bool) + assert isinstance(_cplugin.__dict__.get('restrictions'), RestrictionsConfig) def test_config_plugin_files(self, fix_plugin_config, project_config): """Проверка наличия файлов плагина""" @@ -76,8 +80,10 @@ def test_config_payload_entry_structure(self, fix_plugin_config, project_config) _pentry = fix_plugin_config.__dict__.get(PluginStructure.PAYLOAD).__dict__.get('entry') assert isinstance(_pentry.__dict__.get('method'), str) - assert _pentry.__dict__.get('method') == 'content', f"Метод запуска плагина {_pentry.__dict__.get('method')} не соответствуе значению по умолчанию `content`" - assert isinstance(_pentry.__dict__.get('params'), list) and all([isinstance(it, AbcParamConfig) for it in _pentry.__dict__.get('params')]) + assert _pentry.__dict__.get( + 'method') == 'content', f"Метод запуска плагина {_pentry.__dict__.get('method')} не соответствуе значению по умолчанию `content`" + assert isinstance(_pentry.__dict__.get('params'), list) and all( + [isinstance(it, AbcParamConfig) for it in _pentry.__dict__.get('params')]) def test_config_plugin_files(self, fix_plugin_config, project_config): """Проверка наличия файлов плагина""" @@ -111,3 +117,17 @@ def test_compare_entry_file_and_plugin_files(self, fix_plugin_config): _cplugin = fix_plugin_config.__dict__.get(PluginStructure.PLUGIN) assert _cpayload.__dict__.get('file') in _cplugin.__dict__.get('files') + + +@pytest.mark.pre_set +class TestConfigMiddleware: + + def test_modules_order(self, fix_plugin_config): + for i, module in enumerate(fix_plugin_config.middleware.modules): + assert module.order == i + 1, f"Module {module.name} should have order {i + 1}" + + def test_modules_key_params(self, fix_plugin_config): + for i, module in enumerate(fix_plugin_config.middleware.modules): + assert isinstance(module.order, int) + assert isinstance(module.name, str) + assert isinstance(module.is_critical, bool) diff --git a/tests/payload/test_plugin_run.py b/tests/payload/test_plugin_run.py index 2fc42e1..ae094c4 100644 --- a/tests/payload/test_plugin_run.py +++ b/tests/payload/test_plugin_run.py @@ -14,7 +14,7 @@ from tests.config.fixtures import fix_plugin_config, project_config # from tests.payload.fixtures import execute_timeout -from s3p_sdk.types import S3PRefer, S3PDocument, S3PPlugin +from s3p_sdk.types import S3PRefer, S3PDocument, S3PPlugin, S3PPluginRestrictions from s3p_sdk.plugin.types import SOURCE @@ -44,7 +44,7 @@ def fix_s3pPlugin(self) -> S3PPlugin: @pytest.fixture(scope="module", autouse=True) def fix_payload(self, project_config, fix_plugin_config) -> Type[S3PParserBase]: - MODULE_NAME: str = 's3p_plugin_parser_techcrunch' + MODULE_NAME: str = 's3p_test_plugin_payload' """Загружает конфигурацию из config.py файла по динамическому пути на основании конфигурации""" payload_path = Path(project_config.root) / 'src' / project_config.name / fix_plugin_config.payload.file assert os.path.exists(payload_path) @@ -60,12 +60,12 @@ def fix_payload(self, project_config, fix_plugin_config) -> Type[S3PParserBase]: assert issubclass(parser_class, S3PParserBase), f"{class_name} is not a subclass of S3PParserBase." return parser_class - def run_payload(self, payload: Type[S3PParserBase], _plugin: S3PPlugin, driver: WebDriver, refer: S3PRefer, max_document: int, + def run_payload(self, payload: Type[S3PParserBase], _plugin: S3PPlugin, driver: WebDriver, refer: S3PRefer, restrictions: S3PPluginRestrictions, timeout: int = 2): # !WARNING Требуется изменить путь до актуального парсера плагина from src.s3p_plugin_parser_techcrunch.techcrunch import Techcrunch if isinstance(payload, type(Techcrunch)): - _payload = payload(refer=refer, plugin=_plugin, web_driver=driver, max_count_documents=max_document, last_document=None) + _payload = payload(refer=refer, plugin=_plugin, web_driver=driver, restrictions=restrictions) # @execute_timeout(timeout) def execute() -> tuple[S3PDocument, ...]: @@ -88,7 +88,7 @@ def test_all_cases_with_once_executing_parser(self, chrome_driver, fix_s3pRefer, """ max_docs = 4 - docs = self.run_payload(fix_payload, fix_s3pPlugin, chrome_driver, fix_s3pRefer, max_docs, 100) + docs = self.run_payload(fix_payload, fix_s3pPlugin, chrome_driver, fix_s3pRefer, S3PPluginRestrictions(max_docs, None, None, None), 100) # 1. Количество материалов должно быть не меньше параметра максимального числа материалов. assert len(docs) == max_docs, f"Payload вернул {len(docs)} материалов. А должен был {max_docs}"