├── .gitignore ├── account_config.py ├── accounts └── account_base_class.py ├── db_engine ├── engine.py ├── engine_base.py └── switch_db.py ├── demo.py ├── dp ├── dp_base.py ├── dp_content.py └── tools.py ├── engine.py ├── img.png ├── img_1.png ├── img_2.png ├── lib ├── ck_poll.py ├── ck_poll_base.py ├── ck_poll_init_config.py ├── ck_poll_sql_helper.py ├── config.py └── notify_tools.py ├── logger.py ├── logins ├── config.py ├── error_map.py ├── login │ └── demo.py ├── login_base.py └── tools.py ├── notify ├── feishu │ └── feishu_api.py ├── notify.py └── notify_feishu.py ├── readme.md ├── settings.py └── utils ├── cookie_tools.py ├── date_tools.py ├── exception.py ├── path_tools.py ├── text_parse.py ├── url_parse_tools.py ├── verify_imgas_tools.py └── wrapper.py /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | .idea/ 3 | */__pycache__/ 4 | __pycache__/ 5 | _logs/ 6 | 7 | # cookie pool 缓存文件,禁止 同步 8 | .login_archive 9 | 10 | # cookie pool 缓存文件,禁止 同步 11 | */login_archive/ 12 | -------------------------------------------------------------------------------- /account_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: account_config 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | from typing import Dict 9 | 10 | # F_PATH = os.path.dirname(__file__) 11 | # sys.path.append(os.path.join(F_PATH, '..')) 12 | # sys.path.append(os.path.join(F_PATH, '../..')) 13 | 14 | 15 | ALL_ACCOUNT_INFO: Dict[str, Dict[str, dict]] = { 16 | '平台': { 17 | '账号唯一编码': {'account': '账号', 'password': '密码', 'phone': '手机号[int类型]'}, 18 | }, 19 | } 20 | 21 | if __name__ == '__main__': 22 | print(ALL_ACCOUNT_INFO) 23 | -------------------------------------------------------------------------------- /accounts/account_base_class.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: account 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | # import os 8 | from dataclasses import dataclass, field 9 | from typing import Dict, Optional 10 | 11 | 12 | # F_PATH = os.path.dirname(__file__) 13 | # sys.path.append(os.path.join(F_PATH, '..')) 14 | # sys.path.append(os.path.join(F_PATH, '../..')) 15 | 16 | 17 | @dataclass 18 | class AccountConfigTemp: 19 | platform: str # 平台 20 | account: str # 账 21 | password: str # 密 22 | store_code: str # 账号编码/子店铺编码 23 | sub_shop: str = field(default=None) # 子店铺/抖音 24 | phone: Optional[int] = field(default=None) # 手机号 25 | remarks: str = field(default='') # 备注 26 | 27 | 28 | class AccountBaseClass: 29 | 30 | def __init__(self, platform, data: Dict[str, Dict[str, str]]): 31 | self._account_data: Dict[str, AccountConfigTemp] = self._init_config(platform, data) 32 | 33 | @staticmethod 34 | def _init_config(platform, data: Dict[str, Dict[str, str]]) -> Dict[str, AccountConfigTemp]: 35 | new_data: Dict[str, AccountConfigTemp] = {} 36 | for store_code, account_info in data.items(): 37 | new_data[store_code] = AccountConfigTemp( 38 | platform=platform, 39 | account=account_info['account'], 40 | password=account_info['password'], 41 | store_code=store_code, 42 | sub_shop=account_info.get('sub_shop'), 43 | phone=account_info.get('phone'), 44 | remarks=account_info.get('remarks'), 45 | ) 46 | return new_data 47 | 48 | def account_config(self) -> Dict[str, AccountConfigTemp]: 49 | """动态加载账号配置/暴露一个口子出去/子店铺经常改名的问题可以在这里进行处理(子类重写, 默认不处理)""" 50 | return self._account_data 51 | 52 | 53 | if __name__ == '__main__': 54 | pass 55 | -------------------------------------------------------------------------------- /db_engine/engine.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: engine 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | import pandas as pd 9 | from typing import Type, List, Optional, Union 10 | from sqlalchemy.sql import text as sqlalchemy_text 11 | from sqlalchemy.engine import Connection 12 | from sqlalchemy.exc import IntegrityError 13 | 14 | # F_PATH = os.path.dirname(__file__) 15 | # sys.path.append(os.path.join(F_PATH, '..')) 16 | # sys.path.append(os.path.join(F_PATH, '../..')) 17 | from db_engine.engine_base import EngineBase 18 | from settings import MysqlConfig 19 | 20 | 21 | class Engine(EngineBase): 22 | def __init__(self, mysql_config: Type[MysqlConfig]): 23 | self.engine = self.engine_create(mysql_config) 24 | 25 | @staticmethod 26 | def tool_insert_dict2sql(data: dict, table: str) -> str: 27 | keys = list(data.keys()) 28 | col = ','.join(keys) 29 | placeholder = ', '.join(['%s'] * len(keys)) 30 | return f"""insert into {table} ({col}) values ({placeholder});""" 31 | 32 | @staticmethod 33 | def tool_replace_dict2sql(data: dict, table: str) -> str: 34 | keys = list(data.keys()) 35 | col = ','.join(keys) 36 | placeholder = ', '.join(['%s'] * len(keys)) 37 | return f"""replace into {table} ({col}) values ({placeholder});""" 38 | 39 | @staticmethod 40 | def __c_execute(sql: str, values: Optional[tuple] = None, *, conn: Connection, safe_ignorePK: bool = False) -> bool: 41 | """自定义 execute""" 42 | try: 43 | conn.execute(sql, values) if values else conn.execute(sqlalchemy_text(sql)) 44 | except IntegrityError as e: 45 | if safe_ignorePK is True: 46 | return False 47 | raise e 48 | return True 49 | 50 | def fetch_data(self, sql: str, fetch_number: Optional[int] = None) -> List[dict]: 51 | _fetch_number = fetch_number if isinstance(fetch_number, int) and fetch_number > 0 else -1 52 | with self.connect_get() as conn: 53 | result = conn.execute(sqlalchemy_text(sql)) 54 | data_row = result.fetchmany(_fetch_number) if _fetch_number > 0 else result.fetchall() 55 | return [dict(row) for row in data_row] 56 | 57 | def fetch_data2df(self, sql) -> pd.DataFrame: 58 | with self.connect_get() as conn: 59 | return pd.read_sql(sql, conn) 60 | 61 | def insert_execute(self, item: Union[dict, List[dict]], *, table: str, safe_ignorePK=False, conn: Connection): 62 | data_list = [item] if isinstance(item, dict) else item 63 | if not isinstance(data_list, list): 64 | raise ValueError("Invalid input type, expected dict, list of dicts") 65 | 66 | for idx, data in enumerate(data_list): 67 | if not isinstance(data, dict): 68 | raise ValueError(f"Invalid data at index {idx}: Expected a dictionary, but got {type(data).__name__}.") 69 | 70 | sql = self.tool_insert_dict2sql(data, table) 71 | self.__c_execute(sql, tuple(data.values()), conn=conn, safe_ignorePK=safe_ignorePK) 72 | 73 | def insert_data2table(self, item: Union[dict, List[dict]], *, table: str, safe_ignorePK=False): 74 | """执行【insert】操作""" 75 | with self.connect_get() as conn: 76 | self.insert_execute(item, table=table, safe_ignorePK=safe_ignorePK, conn=conn) 77 | 78 | 79 | if __name__ == '__main__': 80 | def demo(): 81 | engine = Engine(MysqlConfig) 82 | engine.engine_close() 83 | 84 | 85 | demo() 86 | -------------------------------------------------------------------------------- /db_engine/engine_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: engine_base 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | from urllib import parse 9 | from functools import wraps 10 | from typing import Type, TypeVar, cast, Callable, ContextManager, Optional, Any 11 | 12 | from sqlalchemy import create_engine 13 | from sqlalchemy import __version__ as sqlalchemy_version 14 | from sqlalchemy.engine.base import Engine 15 | from sqlalchemy.engine import Connection 16 | 17 | # F_PATH = os.path.dirname(__file__) 18 | # sys.path.append(os.path.join(F_PATH, '..')) 19 | # sys.path.append(os.path.join(F_PATH, '../..')) 20 | from db_engine.switch_db import SwitchDB 21 | from settings import MysqlConfig 22 | 23 | 24 | class EngineBase: 25 | CONN_MODE = "mysql+pymysql://{user}:{pwd}@{host}:{port}/{db}?charset={charset}" 26 | engine: Optional[Engine] = None 27 | __F = TypeVar('__F', bound=Callable[..., Optional[Any]]) 28 | 29 | def __new__(cls, *args, **kwargs): 30 | if cls is EngineBase: 31 | raise TypeError("Cannot instantiate EngineBase directly. It must be subclassed.") 32 | return super().__new__(cls) 33 | 34 | def engine_create(self, mysql_config: Type[MysqlConfig]) -> Engine: 35 | return create_engine(self.CONN_MODE.format( 36 | user=mysql_config.user.value, 37 | pwd=parse.quote(mysql_config.password.value), 38 | host=mysql_config.host.value, 39 | port=mysql_config.port.value, 40 | charset=mysql_config.charset.value, 41 | db=mysql_config.db.value, 42 | ), pool_recycle=3600, pool_pre_ping=True) 43 | 44 | def engine_close(self): 45 | try: 46 | self.engine.dispose() 47 | except: 48 | pass 49 | 50 | def connect_get(self, close_with_result=False) -> Connection: 51 | """ 52 | 获取数据库连接,并根据不同版本和配置来处理事务。 53 | :param close_with_result: 是否在操作后关闭连接(默认为 False)。 54 | :return: 数据库连接对象,可能会自动管理事务。 55 | """ 56 | if sqlalchemy_version.startswith('2.'): 57 | return self.engine.begin(close_with_result=close_with_result) 58 | return self.engine.connect(close_with_result=close_with_result) 59 | 60 | def with_switch_db(self, dbname: str, current_dbname: Optional[str] = None) -> ContextManager: 61 | return SwitchDB(self.engine, dbname, current_dbname=current_dbname) 62 | 63 | def with_txn_wrapper(self, func): 64 | """事务装饰器, 自动处理事务的开始, 提交和回滚""" 65 | 66 | @wraps(func) 67 | def wrapper(*args, **kwargs): 68 | # 获取数据库连接 69 | with self.connect_get() as conn: 70 | with conn.begin(): 71 | return func(*args, **kwargs, conn=conn) 72 | 73 | return cast(self.__F, wrapper) 74 | 75 | 76 | if __name__ == '__main__': 77 | pass 78 | -------------------------------------------------------------------------------- /db_engine/switch_db.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: switch_db 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | from typing import Optional 9 | from sqlalchemy.engine.base import Engine 10 | 11 | 12 | # F_PATH = os.path.dirname(__file__) 13 | # sys.path.append(os.path.join(F_PATH, '..')) 14 | # sys.path.append(os.path.join(F_PATH, '../..')) 15 | 16 | 17 | class SwitchDB: 18 | def __init__(self, engine: Engine, dbname: str, current_dbname: Optional[str] = None): 19 | self.__engine: Engine = engine 20 | self.__dbname: str = dbname 21 | self.__current_dbname: Optional[str] = current_dbname 22 | self.__status_name: str = 'switched' # 默认为不同 【switched or same】 23 | 24 | def __enter__(self): 25 | self.__current_dbname: str = self.__current_dbname or self.__engine.execute("SELECT DATABASE();").fetchone()[0] 26 | if self.__current_dbname.lower() == self.__dbname.lower(): 27 | self.__status_name = 'same' 28 | return self.__engine 29 | 30 | self.__engine.execute(f"USE {self.__dbname};") # 切换 31 | return self.__engine # 返回原本 32 | 33 | def __exit__(self, exc_type, exc_value, traceback): 34 | if self.__status_name == 'same': 35 | return 36 | self.__engine.execute(f"USE {self.__current_dbname};") # 还原 37 | 38 | 39 | if __name__ == '__main__': 40 | pass 41 | -------------------------------------------------------------------------------- /demo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: demo 4 | # @time: 2025-01-12 5 | # @desc: 6 | # import sys 7 | # import os 8 | 9 | # F_PATH = os.path.dirname(__file__) 10 | # sys.path.append(os.path.join(F_PATH, '..')) 11 | # sys.path.append(os.path.join(F_PATH, '../..')) 12 | from engine import engine 13 | from lib.ck_poll import CookiePoll 14 | from lib.ck_poll_init_config import CookiePollInitConfig 15 | 16 | from accounts.account_base_class import AccountBaseClass 17 | from account_config import ALL_ACCOUNT_INFO 18 | 19 | from logins.login.demo import DemoLogin 20 | from notify.notify_feishu import NotifyFeishu, FeishuKey 21 | from settings import FETSHU_GROUP_CONFIG 22 | 23 | 24 | def demo(): 25 | cp = CookiePoll(engine, CookiePollInitConfig( 26 | account_config=AccountBaseClass('平台', ALL_ACCOUNT_INFO['平台']), 27 | platform='平台-平台', 28 | maintainer=[12345678901], # 消息通知人手机号 29 | timer=(5, 23, 10), # 每天 5点 到 23点, 每十分钟主动检测一次cookie任务是否过期 30 | open_init_task=False, # 初始化下发任务 31 | open_check=False # 主动检测cookie是否过期 32 | )) 33 | cp.register_login_instance(DemoLogin) # 注册登录类 34 | cp.register_notify_tools(NotifyFeishu, FeishuKey, FETSHU_GROUP_CONFIG) # 注册通知类 35 | 36 | cp.start() 37 | 38 | 39 | demo() 40 | 41 | if __name__ == '__main__': 42 | pass 43 | -------------------------------------------------------------------------------- /dp/dp_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: dp_baes 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | # import os 8 | from typing import Callable, Union, Optional, ContextManager, List, Dict 9 | from DrissionPage import WebPage, ChromiumOptions 10 | 11 | # F_PATH = os.path.dirname(__file__) 12 | # sys.path.append(os.path.join(F_PATH, '..')) 13 | # sys.path.append(os.path.join(F_PATH, '../..')) 14 | from dp.dp_content import DpTabContex, DpContextSync 15 | from dp.tools import calculate_number, path_join 16 | 17 | from logger import logger 18 | 19 | 20 | class DpBaes: 21 | browser: ChromiumOptions = None 22 | page: WebPage = None 23 | 24 | port: int = None 25 | user_data_path: str = None 26 | 27 | @staticmethod 28 | def new_tab_contex(page: WebPage, url) -> ContextManager: 29 | """封装获得新的 tab窗口, 上下文写法""" 30 | return DpTabContex(page, url) 31 | 32 | def get_page_cookie(self, page: Optional[WebPage] = None, *, all_domain=True, as_dict=False) -> Union[str, dict]: 33 | """返回当前页面下的cookie""" 34 | _page = page or self.page 35 | if not isinstance(_page, WebPage): 36 | return {} if as_dict else '' 37 | return ''.join([f"{ck['name']}={ck['value']};" for ck in _page.cookies(all_domains=all_domain, as_dict=False)]) 38 | 39 | def get_all_domains_cookie(self, page: Optional[WebPage] = None) -> List[Dict[str, str]]: 40 | """返回当前页面的所有cookie,并且包含 `domain`""" 41 | _page = page or self.page 42 | return _page.cookies(all_domains=True, as_dict=False) 43 | 44 | def _set_path_port(self, port, account_tag, archive_data_path: str): 45 | port = port or calculate_number(account_tag) # 根据账号名称计算出来浏览器所使用的端口 46 | self.browser.set_paths(local_port=port) 47 | self.port = port 48 | logger.info(f"计算端口: {account_tag} -> {port}") 49 | 50 | if not archive_data_path: 51 | return 52 | user_data_path = path_join(archive_data_path, account_tag, True) 53 | self.browser.set_paths(user_data_path=user_data_path) 54 | self.user_data_path = user_data_path 55 | logger.info(f"设置浏览器缓存: {account_tag} -> {user_data_path}") 56 | 57 | def register_browser(self, func: Callable, before_func: Optional[Callable] = None, last_url='close', **kwargs): 58 | """注册自动化浏览器页面""" 59 | self.browser: ChromiumOptions = ChromiumOptions().auto_port() 60 | if before_func: 61 | before_func() 62 | self.page: WebPage = WebPage(chromium_options=self.browser) 63 | 64 | with DpContextSync(self.browser, self.page, last_url=last_url) as (browser, page): 65 | result = func(page, **kwargs) 66 | 67 | return result 68 | 69 | 70 | if __name__ == '__main__': 71 | pass 72 | -------------------------------------------------------------------------------- /dp/dp_content.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: dp_content 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | # import os 8 | from DrissionPage import WebPage, WebPageTab, ChromiumOptions 9 | from DrissionPage.errors import PageDisconnectedError 10 | 11 | 12 | # F_PATH = os.path.dirname(__file__) 13 | # sys.path.append(os.path.join(F_PATH, '..')) 14 | # sys.path.append(os.path.join(F_PATH, '../..')) 15 | 16 | 17 | class DpTabContex: 18 | def __init__(self, page: WebPage, url: str): 19 | self._tab: WebPageTab = page.new_tab(url) 20 | 21 | def __enter__(self) -> WebPageTab: 22 | return self._tab 23 | 24 | def __exit__(self, exc_type, exc_val, exc_tb): 25 | self._tab.close() 26 | 27 | 28 | class DpContextSync: 29 | __last_url = "chrome://new-tab-page-third-party/" 30 | 31 | def __init__(self, browser, page, last_url=None): 32 | self.__browser: ChromiumOptions = browser 33 | self._page: WebPage = page 34 | self._last_url = last_url or self.__last_url 35 | 36 | def __enter__(self): 37 | return self.__browser, self._page 38 | 39 | def __exit__(self, exc_type, exc_val, exc_tb): 40 | tabas_id = self._page.tab_ids 41 | if self._last_url == 'close': 42 | self._page.quit(timeout=1, force=True) 43 | return 44 | 45 | this_page_id = self._page.tab_id 46 | default_tab = self._page.new_tab(self._last_url).tab_id 47 | for t_id in tabas_id: 48 | if t_id == default_tab or t_id == this_page_id: 49 | continue 50 | try: 51 | self._page.close_tabs(t_id) 52 | except PageDisconnectedError: 53 | pass 54 | 55 | self._page.set.window.mini() 56 | self._page.close_tabs(this_page_id) 57 | 58 | 59 | if __name__ == '__main__': 60 | pass 61 | -------------------------------------------------------------------------------- /dp/tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: tools 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | import os 8 | import hashlib 9 | 10 | 11 | # F_PATH = os.path.dirname(__file__) 12 | # sys.path.append(os.path.join(F_PATH, '..')) 13 | # sys.path.append(os.path.join(F_PATH, '../..')) 14 | 15 | def path_join(l, r, create_path=False) -> str: 16 | """路径合并""" 17 | p = os.path.join(l, r).replace('\\', '/') 18 | if create_path: 19 | if not os.path.isdir(p): 20 | os.makedirs(p) 21 | 22 | return p 23 | 24 | 25 | def calculate_number(text) -> int: 26 | """根据文本计算出在 9600, 19800 之间的数字""" 27 | sha256_hash = hashlib.sha256(text.encode()).hexdigest() 28 | decimal_hash = int(sha256_hash, 16) # 将16进制的hash转换为10进制 29 | number = 9600 + (decimal_hash % 10201) 30 | return number 31 | 32 | 33 | if __name__ == '__main__': 34 | pass 35 | -------------------------------------------------------------------------------- /engine.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: engine 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | 9 | # F_PATH = os.path.dirname(__file__) 10 | # sys.path.append(os.path.join(F_PATH, '..')) 11 | # sys.path.append(os.path.join(F_PATH, '../..')) 12 | from db_engine.engine import Engine 13 | from settings import MysqlConfig 14 | 15 | __all__ = [ 16 | 'engine' 17 | ] 18 | 19 | engine = Engine(MysqlConfig) 20 | 21 | if __name__ == '__main__': 22 | pass 23 | -------------------------------------------------------------------------------- /img.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ylw00/cookies_pool_v2/132db1b28208492bf7bdc33ef9ccc06030229095/img.png -------------------------------------------------------------------------------- /img_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ylw00/cookies_pool_v2/132db1b28208492bf7bdc33ef9ccc06030229095/img_1.png -------------------------------------------------------------------------------- /img_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ylw00/cookies_pool_v2/132db1b28208492bf7bdc33ef9ccc06030229095/img_2.png -------------------------------------------------------------------------------- /lib/ck_poll.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: cookie_poll 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | import traceback 9 | from typing import cast, Dict, Optional 10 | from functools import wraps 11 | 12 | # F_PATH = os.path.dirname(__file__) 13 | # sys.path.append(os.path.join(F_PATH, '..')) 14 | # sys.path.append(os.path.join(F_PATH, '../..')) 15 | from lib.ck_poll_base import CookiePollBase, LoginBase, NotifyBase, Engine 16 | from lib.ck_poll_sql_helper import CookiePollSQLHelper 17 | from lib.ck_poll_init_config import CookiePollInitConfig 18 | from accounts.account_base_class import AccountBaseClass, AccountConfigTemp 19 | 20 | from utils.wrapper import Wrapper 21 | 22 | from logger import logger 23 | 24 | FUNC_LOCK_MAP = {} 25 | 26 | 27 | def synchronized_method(func): 28 | """ 29 | 装饰器:加锁限制同步运行 30 | :param func: 需要加锁的目标函数 31 | :return: 包装后的函数 32 | """ 33 | 34 | @wraps(func) 35 | def wrapper(*args, **kwargs): 36 | func_id = id(func) 37 | if FUNC_LOCK_MAP.get(func_id, True) is False: 38 | return 39 | 40 | try: 41 | FUNC_LOCK_MAP[func_id] = False 42 | return func(*args, **kwargs) 43 | except Exception as e: 44 | logger.info(traceback.format_exc()) 45 | raise e 46 | finally: 47 | FUNC_LOCK_MAP[func_id] = True 48 | 49 | return cast(Wrapper.F, wrapper) 50 | 51 | 52 | class CookiePoll(CookiePollBase): 53 | 54 | def __init__(self, engine: Engine, config: CookiePollInitConfig, debugger: bool = False): 55 | if not isinstance(config, CookiePollInitConfig): 56 | return 57 | # 工具 58 | self.engine: Engine = engine 59 | self.ck_poll_sql_helper = CookiePollSQLHelper(self.engine) 60 | 61 | # 任务配置相关 62 | self.account_config: AccountBaseClass = config.account_config 63 | self.platform: str = config.platform # 平台 64 | self.maintainer: list = config.maintainer # 通知负责人 65 | self.timer: tuple = config.timer # 定时运行时间 tuple:[每天的开始时间(h), 结束时间(h), 检测间隔(单位分钟)] 66 | self.open_init_task = config.open_init_task # 主动下发任务 67 | self.open_check = config.open_check # 主动检测 68 | self.debugger = debugger # debugger模式 69 | 70 | # 类 71 | self.login_instance: Optional[LoginBase] = None 72 | self.notify_tools: Optional[NotifyBase] = None 73 | 74 | if config.open_init_task: 75 | self.initiative_push_task() 76 | 77 | def initiative_push_task(self): 78 | """主动推送任务""" 79 | account_config: Dict[str, AccountConfigTemp] = self.account_config.account_config() 80 | for store_code in account_config.keys(): 81 | self.ck_poll_sql_helper.send_task(self.platform, store_code) 82 | 83 | def logout_push_task(self): 84 | """登录失效,推送任务(需要进行检测)""" 85 | cookie_data_s = self.ck_poll_sql_helper.fetch_task(self.platform) 86 | if not cookie_data_s: 87 | logger.info("暂时无账号需要检测") 88 | return 89 | for cookie_data in cookie_data_s: 90 | account = cookie_data['account'] 91 | store_code = cookie_data['store_code'] 92 | sub_shop = cookie_data['sub_shop'] 93 | cookie = cookie_data['cookie'] 94 | notify_shop = sub_shop or account # 根据不同的平台, 选择 店铺或者子店铺 进行通知 95 | 96 | status = self.login_instance.login_status(self.platform, account, sub_shop, cookie) 97 | if status is True: 98 | logger.info(f"{self.platform} {notify_shop} cookie未过期") 99 | continue 100 | 101 | logger.info(f"{self.platform} {notify_shop} cookie过期, 下发到任务数据库") 102 | self.ck_poll_sql_helper.send_task(self.platform, store_code) 103 | 104 | @synchronized_method 105 | def gen_cookie(self): 106 | """根据任务队列生成cookie""" 107 | 108 | # 注意, 抽象根据业务自己去实现 109 | tasks = self.ck_poll_sql_helper.get_task_queue(self.platform) 110 | for task in tasks: 111 | cookie = self.login_instance.login(**task) 112 | self.ck_poll_sql_helper.save_cookie(self.platform, cookie) 113 | 114 | 115 | if __name__ == '__main__': 116 | def demo(): 117 | from settings import MysqlConfig 118 | from accounts.account_base_class import AccountBaseClass 119 | from account_config import ALL_ACCOUNT_INFO 120 | 121 | from logins.login.demo import DemoLogin 122 | from notify.notify_feishu import NotifyFeishu, FeishuKey 123 | from settings import FETSHU_GROUP_CONFIG 124 | 125 | cp = CookiePoll(Engine(MysqlConfig), CookiePollInitConfig( 126 | account_config=AccountBaseClass('平台', ALL_ACCOUNT_INFO['平台']), 127 | platform='平台-平台', 128 | maintainer=[12345678901], # 消息通知人手机号 129 | timer=(5, 23, 10), 130 | open_init_task=False, 131 | open_check=False 132 | )) 133 | cp.register_login_instance(DemoLogin) # 注册登录类 134 | cp.register_notify_tools(NotifyFeishu, FeishuKey, FETSHU_GROUP_CONFIG) # 注册通知类 135 | 136 | cp.start() 137 | 138 | 139 | demo() 140 | -------------------------------------------------------------------------------- /lib/ck_poll_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: cookie_poll_base 4 | # @time: 2025/1/8 5 | # @desc: 6 | import sys 7 | import os 8 | from abc import ABC, abstractmethod 9 | from typing import Dict, Type, Optional 10 | from apscheduler.triggers.cron import CronTrigger 11 | from apscheduler.schedulers.blocking import BlockingScheduler 12 | 13 | # F_PATH = os.path.dirname(__file__) 14 | # sys.path.append(os.path.join(F_PATH, '..')) 15 | # sys.path.append(os.path.join(F_PATH, '../..')) 16 | from accounts.account_base_class import AccountBaseClass 17 | from db_engine.engine import Engine 18 | from logins.login_base import LoginBase 19 | from notify.notify import NotifyBase 20 | 21 | 22 | # from account_config import AccountConfigTemp 23 | 24 | 25 | class CookiePollBase(ABC): 26 | engine: Engine 27 | account_config: AccountBaseClass 28 | platform: str # 平台 29 | maintainer: list # 通知负责人 30 | timer: tuple # 定时运行时间 tuple:[每天的开始时间(h), 结束时间(h), 检测间隔(单位分钟)] 31 | open_init_task: bool # 主动下发任务 32 | open_check: bool # 主动检测 33 | debugger: bool # debugger模式 34 | 35 | login_instance: Optional[LoginBase] = None 36 | notify_tools: Optional[NotifyBase] = None 37 | 38 | # def __init__(self, config: CookiePollInitConfig, debugger: bool = False): 39 | # if not isinstance(config, CookiePollInitConfig): 40 | # return 41 | # 42 | # self.account_config: Dict[str, Dict[str, AccountConfigTemp]] = config.account_config 43 | # self.platform: str = config.platform # 平台 44 | # self.maintainer: list = config.maintainer # 通知负责人 45 | # self.timer: tuple = config.timer # 定时运行时间 tuple:[每天的开始时间(h), 结束时间(h), 检测间隔(单位分钟)] 46 | # self.open_init_task = config.open_init_task # 主动下发任务 47 | # self.open_check = config.open_check # 主动检测 48 | # self.debugger = debugger # debugger模式 49 | # 50 | # self.login_instance: Optional[LoginBase] = None 51 | # self.notify_tools: Optional[NotifyBase] = None 52 | 53 | @abstractmethod 54 | def initiative_push_task(self): 55 | """主动推送任务""" 56 | 57 | @abstractmethod 58 | def logout_push_task(self): 59 | """登录失效,推送任务""" 60 | 61 | @abstractmethod 62 | def gen_cookie(self): 63 | """根据任务队列生成cookie""" 64 | 65 | def register_login_instance(self, login_class: Type[LoginBase], *args, **kwargs): 66 | """注册登录类实例""" 67 | if not issubclass(login_class, LoginBase): 68 | raise TypeError('注册类型错误, 必须是 `LoginBase` 类的继承') 69 | 70 | self.login_instance = login_class(*args, **kwargs) 71 | self.login_instance.set_engine(self.engine) 72 | return self 73 | 74 | def register_notify_tools(self, notify_class: Type[NotifyBase], *args, **kwargs): 75 | """注册消息通知工具""" 76 | if not issubclass(notify_class, NotifyBase): 77 | raise TypeError('注册类型错误, 必须是 `NotifyBase` 类的继承') 78 | 79 | self.notify_tools = notify_class(*args, **kwargs) 80 | return self 81 | 82 | def start(self): 83 | scheduler = BlockingScheduler(timezone='Asia/Shanghai') 84 | 85 | # 每天定时下发任务 86 | trigger = CronTrigger.from_crontab(f'0 {self.timer[0]} * * *') 87 | scheduler.add_job(self.initiative_push_task, trigger) 88 | 89 | if self.open_check: # 主动检测cookie是否过期, 并下发到任务队列 90 | trigger = CronTrigger(hour=f'{self.timer[0]}-{self.timer[1]}', minute=f'*/{self.timer[2]}') 91 | scheduler.add_job(self.logout_push_task, trigger=trigger, max_instances=1) 92 | 93 | # 主动检测cookie任务队列, 并生成cookie 94 | trigger = CronTrigger(hour=f'{self.timer[0]}-{self.timer[1]}', minute='*', second='*/5') # 5秒钟运行一次 95 | scheduler.add_job(self.gen_cookie, trigger=trigger, max_instances=2) 96 | 97 | scheduler.start() 98 | 99 | 100 | if __name__ == '__main__': 101 | def demo(): 102 | ... 103 | 104 | 105 | demo() 106 | -------------------------------------------------------------------------------- /lib/ck_poll_init_config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: poll_init_config 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | from dataclasses import dataclass, field 9 | from typing import List, Optional 10 | from accounts.account_base_class import AccountBaseClass 11 | 12 | # F_PATH = os.path.dirname(__file__) 13 | # sys.path.append(os.path.join(F_PATH, '..')) 14 | # sys.path.append(os.path.join(F_PATH, '../..')) 15 | 16 | __all__ = [ 17 | 'CookiePollInitConfig' 18 | ] 19 | 20 | 21 | @dataclass 22 | class CookiePollInitConfig: 23 | account_config: AccountBaseClass # 账号配置信息类 24 | platform: str # 登录平台 格式:【平台-子平台 | 平台】 25 | maintainer: Optional[List[int]] # 登录程序报错, 通知给负责人 类型: list 26 | timer: tuple = field(default=(5, 23, 30)) # 定时运行时间 tuple:[每天的开始时间(h), 结束时间(h), 检测间隔(单位分钟)] 27 | open_init_task: bool = field(default=True) # 程序初始化下发cookie任务 28 | open_check: bool = field(default=True) # 需要检测cookie是否过期 29 | 30 | def __post_init__(self): 31 | # 检查 account_config 是否为字典类型 32 | if not isinstance(self.account_config, AccountBaseClass): 33 | raise ValueError('All account_config values must be AccountConfig.') 34 | 35 | if not self.maintainer: 36 | self.maintainer = None 37 | 38 | # 检查 timer 参数 39 | if not isinstance(self.timer, tuple) or len(self.timer) != 3: 40 | raise ValueError('timer must be a tuple of length 3.') 41 | 42 | start, end, interval = self.timer 43 | if not (0 <= start < 24) or not (0 <= end < 24) or not (0 < interval < 60): 44 | raise ValueError('Timer parameters must be: start(0-23), end(0-23), interval(1-59).') 45 | 46 | # 检查 open_init_task 和 open_check 是否为布尔值 47 | if not isinstance(self.open_init_task, bool): 48 | raise ValueError('open_init_task must be a boolean value.') 49 | 50 | if not isinstance(self.open_check, bool): 51 | raise ValueError('open_check must be a boolean value.') 52 | 53 | 54 | if __name__ == '__main__': 55 | pass 56 | -------------------------------------------------------------------------------- /lib/ck_poll_sql_helper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: ck_poll_sql_helper 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | # import os 8 | from typing import Optional, List 9 | 10 | # F_PATH = os.path.dirname(__file__) 11 | # sys.path.append(os.path.join(F_PATH, '..')) 12 | # sys.path.append(os.path.join(F_PATH, '../..')) 13 | from db_engine.engine import Engine 14 | from utils.wrapper import Wrapper 15 | 16 | from settings import TableConfig 17 | 18 | from logger import logger 19 | 20 | 21 | class CookiePollSQLHelper: 22 | """数据库交互类, 需要自己根据业务自己设计""" 23 | 24 | task_table = TableConfig.tasks.value # cookie任务表(队列) 25 | cookie_table = TableConfig.cookies.value # cookie表 26 | 27 | PLAT_RENAME_MAP = {} 28 | 29 | def __init__(self, engine: Engine): 30 | self.engine = engine 31 | 32 | def fetch_task(self, platform): 33 | """获取task任务""" 34 | print(self.cookie_table) 35 | return [] 36 | 37 | def send_task(self, platform: str, store_code): 38 | """下发task任务""" 39 | print(self.task_table) 40 | 41 | def get_task_queue(self, platform: str) -> List[dict]: 42 | """从队列中获取task""" 43 | print(self.task_table) 44 | logger.info("获取到了任务...") 45 | return [] 46 | 47 | def save_cookie(self, platform: str, *args, **kwargs): 48 | """储存cookie""" 49 | logger.info("储存cookie成功") 50 | print(self.cookie_table) 51 | 52 | @Wrapper.retry_until_done(10, 6, desc="获取验证码失败") 53 | def sms_verify_code(self, phone: int, platform: str, send_time: Optional[str] = None) -> Optional[str]: 54 | """获取短信验证码""" 55 | return "123456" 56 | 57 | 58 | if __name__ == '__main__': 59 | pass 60 | -------------------------------------------------------------------------------- /lib/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: config 4 | # @time: 2023/11/21 5 | # @desc: 6 | # import sys 7 | # import os 8 | 9 | 10 | -------------------------------------------------------------------------------- /lib/notify_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: handling_tools 4 | # @time: 2023/11/21 5 | # @desc: 6 | import traceback 7 | 8 | # F_PATH = os.path.dirname(__file__) 9 | # sys.path.append(os.path.join(F_PATH, '..')) 10 | # sys.path.append(os.path.join(F_PATH, '../..')) 11 | from notify.notify import NotifyBase 12 | 13 | from logger import logger 14 | 15 | __all__ = [ 16 | 'NotifyTools', 17 | ] 18 | 19 | # 失败或者提示等信息字符串模板 20 | ERROR_NOTIFY_STR_MAP = { 21 | "登录": "登录失败,检查具体原因并修复\n平台:{platform}\n账号:{account}", # 登陆失败邮件通知模板 22 | "检查cookie": "检测是否登录失败,检查具体原因并修复\n平台:{platform}\n账号:{account}", # 检查cookie是否过期模块报错 通知模板 23 | "密码错误": "登录失败\n平台:{platform}\n账号:{account}\n提示:{help}", # 密码错误别的导致的登录失败的字符(有提示) 24 | "店铺未找到": "店铺未找到\n平台:{platform}\n账号:{account}\n店铺:{shop}\n提示:请检测该店铺是否下架或者更改名称", # 针对抖店这种子店铺未找到的情况 25 | } 26 | 27 | 28 | 29 | 30 | 31 | class NotifyTools: 32 | def __init__(self, config: NotifyToolsInitConfig, debugger: bool = False): 33 | self.platform = config.platform 34 | self.group_name = config.group_name 35 | self.maintainer = config.maintainer 36 | self.debugger = debugger 37 | self.feishu_notify = FeishuNotify(config.feishu_key, config.feishu_group_info) 38 | 39 | def _send_message(self, notify_str): 40 | if self.debugger is False: 41 | self.feishu_notify.send_message(self.group_name, self.maintainer, notify_str) 42 | 43 | def error_notify(self, error_type: str, account: str): 44 | """捕获错误, 并作相应的处理""" 45 | if error_type == '登录': 46 | notify_str = ERROR_NOTIFY_STR_MAP['登录'].format(platform=self.platform, account=account) 47 | elif error_type == '登录': 48 | notify_str = ERROR_NOTIFY_STR_MAP['检查cookie'].format(platform=self.platform, account=account) 49 | else: 50 | notify_str = '' 51 | 52 | logger.info(f'\n{notify_str}\n{traceback.format_exc()}') 53 | self._send_message(notify_str) 54 | 55 | def help_notify(self, engin, table, account, sub_shop, help_text: str): 56 | """ 57 | 根据运行提示,做出相应的动作 58 | :return: 59 | """ 60 | if '密码错误' in help_text or '密码不正确' in help_text: 61 | notify_str = ERROR_NOTIFY_STR_MAP['密码错误'].format(platform=self.platform, account=account, help=help_text) 62 | if self.debugger is False: 63 | sql = f'update {table} set account_status="0" where account="{account}"' 64 | engin.commit_task_sql(sql) 65 | elif "店铺未找到" in help_text: 66 | notify_str = ERROR_NOTIFY_STR_MAP['店铺未找到'].format(platform=self.platform, account=account, shop=sub_shop) 67 | else: 68 | notify_str = '' 69 | 70 | logger.info(notify_str) 71 | self._send_message(notify_str) 72 | 73 | 74 | if __name__ == '__main__': 75 | pass 76 | -------------------------------------------------------------------------------- /logger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: logger_func 4 | # @time: 2024/1/15 5 | # @desc: 6 | import os 7 | import sys 8 | from loguru import logger as __logger 9 | 10 | __all__ = [ 11 | 'logger' 12 | ] 13 | 14 | logger = __logger 15 | __LOG_DIR_NAME = '_logs' # 日志输出目录 16 | 17 | 18 | def __init_logger(): 19 | # 设置保存路径为项目路径下的 _logs 目录 20 | logs_dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), __LOG_DIR_NAME) 21 | if not os.path.exists(logs_dir): 22 | os.makedirs(logs_dir) 23 | program = os.path.basename(sys.argv[0]).split(".")[0] 24 | log_file = os.path.join(logs_dir, f"{program}.log") 25 | 26 | # 移除默认格式 27 | logger.remove() 28 | 29 | # 添加输出到文件的处理器,保留最近的 1 个日志文件,每个文件最大 10MB 30 | logger.add( 31 | log_file, rotation="10 MB", retention="1 week", level="DEBUG", encoding="utf-8", 32 | format="{time:YYYY-MM-DD HH:mm:ss} | {level} | {file} : {function} : {line} - {message}", 33 | ) 34 | 35 | # 添加输出到控制台的处理器,输出级别设置为 INFO 36 | logger.level("INFO", color="") 37 | logger.add( 38 | sys.stdout, level="INFO", 39 | format="{level} : {file} : {function} : {line} - {message}" 40 | ) 41 | 42 | 43 | __init_logger() 44 | 45 | if __name__ == '__main__': 46 | logger.info("测试一下") 47 | -------------------------------------------------------------------------------- /logins/config.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: config 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | import os 8 | 9 | F_PATH = os.path.dirname(__file__) 10 | # sys.path.append(os.path.join(F_PATH, '..')) 11 | # sys.path.append(os.path.join(F_PATH, '../..')) 12 | 13 | 14 | __all__ = [ 15 | 'LOGIN_ARCHIVE_PATH' 16 | ] 17 | 18 | # 登录缓存文件路径 19 | LOGIN_ARCHIVE_PATH = os.path.join(F_PATH, '.login_archive').replace('\\', '/') 20 | 21 | if not os.path.isdir(LOGIN_ARCHIVE_PATH): 22 | os.makedirs(LOGIN_ARCHIVE_PATH) 23 | 24 | if __name__ == '__main__': 25 | print(LOGIN_ARCHIVE_PATH) 26 | -------------------------------------------------------------------------------- /logins/error_map.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: error_map 4 | # @time: 2025/1/10 5 | # @desc: 6 | # import sys 7 | # import os 8 | 9 | # F_PATH = os.path.dirname(__file__) 10 | # sys.path.append(os.path.join(F_PATH, '..')) 11 | # sys.path.append(os.path.join(F_PATH, '../..')) 12 | 13 | 14 | ERROR_MAP = { 15 | 200: "登录成功", 16 | 201: "密码错误", 17 | 404: "登录失败", 18 | } 19 | 20 | 21 | if __name__ == '__main__': 22 | pass 23 | -------------------------------------------------------------------------------- /logins/login/demo.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: demo 4 | # @time: 2025-01-12 5 | # @desc: 6 | # import sys 7 | # import os 8 | from typing import Any 9 | 10 | # F_PATH = os.path.dirname(__file__) 11 | # sys.path.append(os.path.join(F_PATH, '..')) 12 | # sys.path.append(os.path.join(F_PATH, '../..')) 13 | 14 | from logins.login_base import LoginBase 15 | 16 | 17 | class DemoLogin(LoginBase): 18 | def login_status(self, platform, account, sub_shop, cookie: str, *args, **kwargs) -> (bool, Any): 19 | print('登录未失效') 20 | return True 21 | 22 | def login(self, platform, account, pwd, phone=None, store_code=None, *args, **kwargs) -> dict: 23 | print('登录成功') 24 | return {} 25 | 26 | 27 | if __name__ == '__main__': 28 | pass 29 | -------------------------------------------------------------------------------- /logins/login_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: login_base 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | from typing import Any 9 | from abc import ABC, abstractmethod 10 | from db_engine.engine import Engine 11 | 12 | 13 | # F_PATH = os.path.dirname(__file__) 14 | # sys.path.append(os.path.join(F_PATH, '..')) 15 | # sys.path.append(os.path.join(F_PATH, '../..')) 16 | 17 | 18 | class LoginBase(ABC): 19 | engine: Engine = None 20 | 21 | def __init__(self, *args, **kwargs): 22 | ... 23 | 24 | def set_engine(self, engine: Engine): 25 | self.engine = engine 26 | 27 | @abstractmethod 28 | def login_status(self, platform, account, sub_shop, cookie: str, *args, **kwargs) -> (bool, Any): 29 | """ 30 | 登录未过期 31 | :return: 有效 True, 无效 False 32 | """ 33 | 34 | @abstractmethod 35 | def login(self, platform, account, pwd, phone=None, store_code=None, *args, **kwargs) -> dict: 36 | """ 37 | 登录入口 38 | :return: 39 | """ 40 | 41 | 42 | if __name__ == '__main__': 43 | pass 44 | -------------------------------------------------------------------------------- /logins/tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: g_tools 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | import os 8 | 9 | 10 | # F_PATH = os.path.dirname(__file__) 11 | # sys.path.append(os.path.join(F_PATH, '..')) 12 | # sys.path.append(os.path.join(F_PATH, '../..')) 13 | from logins.config import LOGIN_ARCHIVE_PATH 14 | 15 | 16 | def path_parent_name(file_name: str) -> str: 17 | """父路径文件名""" 18 | return os.path.dirname(file_name).replace('\\', '/').rsplit('/', 1)[-1] 19 | 20 | 21 | def archive_data_path(file_name: str): 22 | parent_name = path_parent_name(file_name) 23 | return os.path.join(LOGIN_ARCHIVE_PATH, parent_name).replace('\\', '/') 24 | 25 | 26 | if __name__ == '__main__': 27 | print(archive_data_path(r'D:\cookies_pool_v2\logins\tb\almm\login_tb_base.py')) 28 | -------------------------------------------------------------------------------- /notify/feishu/feishu_api.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: feishu_api 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | import json 9 | import base64 10 | import hashlib 11 | import hmac 12 | import time 13 | import requests 14 | from typing import Type, Optional 15 | 16 | # F_PATH = os.path.dirname(__file__) 17 | # sys.path.append(os.path.join(F_PATH, '..')) 18 | # sys.path.append(os.path.join(F_PATH, '../..')) 19 | from utils.wrapper import Wrapper 20 | 21 | from settings import FeishuKey 22 | from logger import logger 23 | 24 | 25 | class FeishuApi: 26 | _feishu_key: Type[FeishuKey] = None 27 | 28 | def __init__(self): 29 | self._headers = {'Content-Type': 'application/json', 'Authorization': ''} 30 | 31 | @staticmethod 32 | def api_sign(timestamp, key): 33 | string_to_sign = '{}\n{}'.format(timestamp, key) 34 | hmac_code = hmac.new(string_to_sign.encode("utf-8"), digestmod=hashlib.sha256).digest() 35 | return base64.b64encode(hmac_code).decode('utf-8') 36 | 37 | def api_app_access_token(self) -> str: 38 | """ 39 | 自建应用获取 tenant_access_token 40 | tenant_access_token 的最大有效期是 2 小时。如果在有效期小于 30 分钟的情况下,调用本接口,会返回一个新的 tenant_access_token 41 | 这会同时存在两个有效的 tenant_access_token。 42 | """ 43 | url = 'https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal' 44 | params = json.dumps({'app_id': self._feishu_key.app_id.value, 'app_secret': self._feishu_key.secret_key.value}) 45 | response = requests.post(url, data=params, headers={'Content-Type': "application/json; charset=utf-8"}).json() 46 | 47 | tenant_access_token = response['tenant_access_token'] 48 | logger.info(f'当前-tenant_access_token:{tenant_access_token}-有效时间为:{response["expire"] / 60}分钟') 49 | return tenant_access_token 50 | 51 | @Wrapper.retry(retries=3, delay=5) 52 | def _requests(self, method, url, **kwargs) -> requests.Response: 53 | headers = kwargs.pop('headers') if kwargs.get('headers') else self._headers 54 | 55 | response: requests.Response = requests.request(method, url, headers=headers, timeout=5, **kwargs) 56 | response_text = response.text 57 | if 'Missing access token' in response_text or 'Invalid access token' in response_text or 'access token' in response_text: 58 | tenant_access_token = self.api_app_access_token() 59 | headers['Authorization'] = f'Bearer {tenant_access_token}' 60 | response: requests.Response = requests.request(method, url, headers=headers, timeout=5, **kwargs) 61 | 62 | return response 63 | 64 | @Wrapper.cache_with_expiration(60 * 1) 65 | def api_user_id(self, mobiles: str) -> Optional[str]: 66 | if not isinstance(mobiles, str): 67 | return None 68 | 69 | url = 'https://open.feishu.cn/open-apis/contact/v3/users/batch_get_id' 70 | payload = json.dumps({"mobiles": [mobiles]}) 71 | response = self._requests("POST", url, headers=self._headers, data=payload).json() 72 | user_list = response['data']['user_list'] 73 | if not user_list: 74 | return None 75 | return user_list[0]['user_id'] 76 | 77 | @Wrapper.cache_with_expiration(60 * 1) 78 | def api_user_info(self, mobiles: str) -> dict: 79 | user_id = self.api_user_id(mobiles) 80 | print(mobiles) 81 | 82 | url = "https://open.feishu.cn/open-apis/contact/v3/users/batch?department_id_type=open_department_id&user_id_type=open_id&" 83 | url += f'user_ids={user_id}' 84 | respons = self._requests("GET", url, headers=self._headers).json() 85 | user_list = respons['data']['items'] 86 | if not user_list: 87 | return {} 88 | 89 | item = user_list[0] 90 | return { 91 | 'mobiles': mobiles, 92 | 'name': item['name'], 93 | 'job_title': item['job_title'], 94 | 'user_id': item['open_id'], 95 | } 96 | 97 | def api_send_message(self, url, key, content_text): 98 | timestamp = int(time.time()) 99 | data = { 100 | "timestamp": timestamp, 101 | "sign": self.api_sign(timestamp, key), 102 | "msg_type": "text", 103 | "content": json.dumps({"text": content_text}) 104 | } 105 | data = json.dumps(data, separators=(',', ':')) 106 | response = requests.post(url, headers={ 107 | "Content-Type": "application/json" 108 | }, data=data) 109 | print(response.text) 110 | 111 | 112 | if __name__ == '__main__': 113 | pass 114 | -------------------------------------------------------------------------------- /notify/notify.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: notify_base 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | from abc import ABC, abstractmethod 9 | from typing import Union 10 | 11 | 12 | # F_PATH = os.path.dirname(__file__) 13 | # sys.path.append(os.path.join(F_PATH, '..')) 14 | # sys.path.append(os.path.join(F_PATH, '../..')) 15 | 16 | class NotifyBase(ABC): 17 | def __init__(self, *args, **kwargs): 18 | ... 19 | 20 | @abstractmethod 21 | def send_message(self, group_name: str, maintainer: Union[str, list], content: str): 22 | ... 23 | 24 | 25 | if __name__ == '__main__': 26 | pass 27 | -------------------------------------------------------------------------------- /notify/notify_feishu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: feishu_notify 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | from dataclasses import dataclass 9 | from typing import Union, Type 10 | 11 | # F_PATH = os.path.dirname(__file__) 12 | # sys.path.append(os.path.join(F_PATH, '..')) 13 | # sys.path.append(os.path.join(F_PATH, '../..')) 14 | from notify.feishu.feishu_api import FeishuApi, FeishuKey 15 | from notify.notify import NotifyBase 16 | 17 | 18 | @dataclass 19 | class NotifyToolsInitConfig: 20 | platform: str 21 | group_name: str 22 | maintainer: Union[str, list] 23 | feishu_key: Type[FeishuKey] 24 | feishu_group_info: dict 25 | 26 | 27 | class NotifyFeishu(NotifyBase, FeishuApi): 28 | def __init__(self, _feishu_key: Type[FeishuKey], feishu_group_info: dict): 29 | super().__init__() 30 | 31 | self._feishu_key = _feishu_key 32 | self._feishu_group_info = feishu_group_info 33 | 34 | def send_message(self, group_name: str, mobiles: list = 'all', content: str = ''): 35 | if isinstance(mobiles, list): 36 | mobiles = [str(mobile) for mobile in mobiles] 37 | elif isinstance(mobiles, str): 38 | mobiles = [mobiles] 39 | users_info = {m: self.api_user_info(m) for m in mobiles} 40 | 41 | base_content_text = '{name}' 42 | content_text = '' 43 | for mobile in mobiles: 44 | user_info = users_info[mobile] 45 | content_text += base_content_text.format(user_id=user_info['user_id'], name=user_info['name']) 46 | content_text += f" {content}" 47 | 48 | group_info = self._feishu_group_info[group_name] 49 | self.api_send_message(group_info['url'], group_info['key'], content_text) 50 | 51 | 52 | if __name__ == '__main__': 53 | def demo(): 54 | from settings import FETSHU_GROUP_CONFIG 55 | 56 | aa = NotifyFeishu(FeishuKey, FETSHU_GROUP_CONFIG) 57 | aa.send_message('数据采集监控', ['************'], content="这是一段测试文本") 58 | aa.send_message('数据采集监控', ['************'], content="这是一段测试文本") 59 | 60 | 61 | demo() 62 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # CookeisPoll 2 | 3 | cookie维护 4 | 5 | 目的: `登录业务解耦` `减少代码维护成本` 6 | 7 | github: `https://github.com/ylw00/cookies_pool_v2` 8 | 9 | vx: y278369368(需要定制私信) 10 | 11 | ## 依赖 12 | ``` 13 | python==3.9.9 14 | 15 | DrissionPage==4.0.4.25 16 | 17 | SQLAlchemy==1.4.7 18 | pandas==1.3.5 19 | 20 | loguru==0.7.2 21 | ``` 22 | 23 | ## 1- 背景 24 | - 业务和登录解耦, 减少业务代码的维护成本 25 | - 增加cookie的利用率和代码的稳定性 26 | 27 | ## 2- 目录 28 | ![img.png](img.png) 29 | 30 | ## 3- 实现模块 31 | - 维护模块 32 | - 登录类 33 | - 消息通知类 34 | ```python 35 | # 先看demo可能不会理解起来那么抽象 36 | from engine import engine 37 | from lib.ck_poll import CookiePoll 38 | from lib.ck_poll_init_config import CookiePollInitConfig 39 | 40 | from accounts.account_base_class import AccountBaseClass 41 | from account_config import ALL_ACCOUNT_INFO 42 | 43 | from logins.login.demo import DemoLogin 44 | from notify.notify_feishu import NotifyFeishu, FeishuKey 45 | from settings import FETSHU_GROUP_CONFIG 46 | 47 | 48 | def demo(): 49 | cp = CookiePoll(engine, CookiePollInitConfig( 50 | account_config=AccountBaseClass('平台', ALL_ACCOUNT_INFO['平台']), 51 | platform='平台-平台', 52 | maintainer=[12345678901], # 消息通知人手机号 53 | timer=(5, 23, 10), # 每天 5点 到 23点, 每十分钟主动检测一次cookie任务是否过期 54 | open_init_task=False, # 初始化下发任务 55 | open_check=False # 主动检测cookie是否过期 56 | )) 57 | cp.register_login_instance(DemoLogin) # 注册登录类 58 | cp.register_notify_tools(NotifyFeishu, FeishuKey, FETSHU_GROUP_CONFIG) # 注册通知类 59 | 60 | cp.start() 61 | 62 | 63 | demo() 64 | ``` 65 | 66 | ## 4- 备注 67 | - `DemoLogin` 继承 `LoginBase` 并实现两个抽象方法 68 | - `NotifyFeishu` 继承 `NotifyBase` 并实现一个抽象方法 69 | - `CookiePollSQLHelper` 这个类主要用来和数据库交互, 其实是可以单独的抽象出来, 可以实现不同平台使用不用的维护方式 70 | - `AccountBaseClass` 类暴露一个口子出去便于无感更新账号配置 71 | - `register_login_instance` 注册登录类 72 | - `register_notify_tools` 注册通知类 73 | - 其余应用看Demo.py 74 | - 具体的登录需要自己去实现, 这里只是给一个维护的主逻辑(逻辑已抽象) 75 | 76 | ## 5- 效果 77 | ![img_1.png](img_1.png) 78 | 79 | ## 啰嗦一下 80 | - 欢迎点赞支持, 后续有能力会继续会开源新的好用的小框架 81 | - ![img_2.png](img_2.png) -------------------------------------------------------------------------------- /settings.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: config 4 | # @time: 2025/1/8 5 | # @desc: 6 | # import sys 7 | # import os 8 | from enum import Enum 9 | from typing import Dict 10 | 11 | # F_PATH = os.path.dirname(__file__) 12 | # sys.path.append(os.path.join(F_PATH, '..')) 13 | # sys.path.append(os.path.join(F_PATH, '../..')) 14 | 15 | COOKIE_POLL_MAINTAINER = '开始' 16 | 17 | ERROR_CODE_MAP: Dict[int, str] = { 18 | 200: "成功", 19 | 401: "密码错误", 20 | 402: "店铺下架", 21 | } 22 | 23 | 24 | class TableConfig(Enum): 25 | all_account = 'work_all_account' 26 | cookies = 'work_all_account_cookie' 27 | tasks = 'work_all_account_cookie_tasks' 28 | verifycode = 'work_verification_code' 29 | 30 | 31 | class MysqlConfig(Enum): 32 | host = '****************' 33 | port = 3306 34 | db = '****************' 35 | user = '****************' 36 | password = '****************' 37 | charset = 'utf8mb4' 38 | 39 | 40 | class FeishuKey(Enum): 41 | secret_key = '****************' 42 | app_id = '****************' 43 | 44 | 45 | FETSHU_GROUP_CONFIG = { 46 | "****************": { 47 | "url": 'https://open.feishu.cn/open-apis/bot/v2/hook/****************', 48 | "key": '****************' 49 | } 50 | } 51 | 52 | if __name__ == '__main__': 53 | pass 54 | -------------------------------------------------------------------------------- /utils/cookie_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: cookie_tools 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | from http.cookiejar import Cookie 9 | 10 | 11 | def cookie_dict2str(dict_cookie: dict) -> str: 12 | return ';'.join([f'{k}={v}' for k, v in dict_cookie.items()]) 13 | 14 | 15 | def cookie_str2dict(str_cookie: str) -> dict: 16 | ck_dict = {} 17 | str_cookie_split = [] 18 | for i in str_cookie.split('; '): 19 | for j in i.split(', '): 20 | str_cookie_split.append(j) 21 | for ck in str_cookie_split: 22 | if 'Max-Age=' in ck or 'Path=/' in ck or 'HttpOnly' in ck: 23 | continue 24 | 25 | resulut = ck.split('=', 1) 26 | if len(resulut) != 2: 27 | continue 28 | key, value = resulut 29 | ck_dict[key] = value 30 | return ck_dict 31 | 32 | 33 | def cookie_to_dict(cookie): 34 | """把Cookie对象转为dict格式 35 | :param cookie: Cookie对象、字符串或字典 36 | :return: cookie字典 37 | """ 38 | if isinstance(cookie, Cookie): 39 | cookie_dict = cookie.__dict__.copy() 40 | cookie_dict.pop('rfc2109', None) 41 | cookie_dict.pop('_rest', None) 42 | return cookie_dict 43 | 44 | elif isinstance(cookie, dict): 45 | cookie_dict = cookie 46 | 47 | elif isinstance(cookie, str): 48 | cookie = cookie.rstrip(';,').split(',' if ',' in cookie else ';') 49 | cookie_dict = {} 50 | 51 | for key, attr in enumerate(cookie): 52 | attr_val = attr.lstrip().split('=', 1) 53 | 54 | if key == 0: 55 | cookie_dict['name'] = attr_val[0] 56 | cookie_dict['value'] = attr_val[1] if len(attr_val) == 2 else '' 57 | else: 58 | cookie_dict[attr_val[0]] = attr_val[1] if len(attr_val) == 2 else '' 59 | 60 | return cookie_dict 61 | 62 | else: 63 | raise TypeError('cookie参数必须为Cookie、str或dict类型。') 64 | 65 | return cookie_dict 66 | 67 | 68 | if __name__ == '__main__': 69 | pass 70 | -------------------------------------------------------------------------------- /utils/date_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: DateTools 4 | # @time: 20243/10/01 5 | # @desc: 6 | import re 7 | import time 8 | import pandas as pd 9 | from datetime import datetime, timedelta 10 | from datetime import date as datetime_date 11 | from typing import List, Tuple, Union, Optional 12 | from dateutil import parser as dateutil_parser 13 | from dateutil.relativedelta import relativedelta 14 | 15 | 16 | def get_oneday_seconds() -> int: 17 | """ 18 | 获得一天的秒数 19 | """ 20 | return 60 * 60 * 24 21 | 22 | 23 | def get_now_timestamp(unit='ms', as_int=False) -> Union[str, int]: 24 | rate = 1000 if unit == 'ms' else 1 25 | ts = int(time.time() * rate) 26 | if as_int: 27 | return ts 28 | return str(ts) 29 | 30 | 31 | def get_now_time(mode='%Y-%m-%d %H:%M:%S') -> str: 32 | return datetime.now().strftime(mode) 33 | 34 | 35 | def get_now_timestamp13(as_int=False) -> Union[str, int]: 36 | t = int(time.time() * 1000) 37 | if as_int: 38 | return t 39 | return str(t) 40 | 41 | 42 | def timestr_to_timestamp(str_time: str, mode="%Y-%m-%d %H:%M:%S") -> int: 43 | try: 44 | timeArray = time.strptime(str_time, mode) 45 | except ValueError as e: 46 | timeArray = time.strptime(str_time, "%Y-%m-%d") 47 | # 转换成时间戳 48 | return int(time.mktime(timeArray)) 49 | 50 | 51 | def timestamp_to_timestr(time_stamp: Union[str, int], mode="%Y-%m-%d %H:%M:%S") -> str: 52 | """ 53 | 仅支持10位时间戳 54 | """ 55 | if isinstance(time_stamp, int): 56 | time_stamp = str(time_stamp) 57 | if len(time_stamp) >= 10: 58 | time_stamp = time_stamp[:10] 59 | return str(time.strftime(mode, time.localtime(int(time_stamp)))) 60 | 61 | 62 | def time_expr2s(time_str: str, time_format: Optional[str] = None) -> int: 63 | """ 64 | 时间表达式转为秒 65 | :param time_str: 01:16 66 | :param time_format: "%H:%M:%S" 67 | :return: int 68 | """ 69 | if not time_str: 70 | return 0 71 | time_str = str(time_str) 72 | symbol_map = {1: "%M:%S", 2: "%H:%M:%S"} 73 | time_format = symbol_map.get(time_str.count(":"), "%H:%M:%S") if time_format is None else time_format 74 | 75 | # 解析时间字符串为 datetime 对象,基准日期为任意日期,这里选择同一天 76 | time_obj = datetime.strptime(time_str, time_format) 77 | # 计算秒数 78 | return time_obj.hour * 3600 + time_obj.minute * 60 + time_obj.second 79 | 80 | 81 | def time_text2s(time_text: str) -> int: 82 | """ 83 | 时间文本转为秒 1分16秒 84 | :param time_text: 85 | :return: 86 | """ 87 | pattern = re.compile(r'(?:(\d+)年)?(?:(\d+)月)?(?:(\d+)周)?(?:(\d+)天)?(?:(\d+)(?:时|小时))?(?:(\d+)分)?(?:(\d+)秒)?') 88 | match = pattern.match(time_text) 89 | 90 | if not match: 91 | raise ValueError("时间文本格式不正确") 92 | 93 | # 提取时间各部分,并将其转为秒 94 | years = int(match.group(1) or 0) 95 | months = int(match.group(2) or 0) 96 | weeks = int(match.group(3) or 0) 97 | days = int(match.group(4) or 0) 98 | hours = int(match.group(5) or 0) 99 | minutes = int(match.group(6) or 0) 100 | seconds = int(match.group(7) or 0) 101 | 102 | # 计算总秒数 103 | total_seconds = ( 104 | years * 365 * 24 * 60 * 60 + 105 | months * 30 * 24 * 60 * 60 + 106 | weeks * 7 * 24 * 60 * 60 + 107 | days * 24 * 60 * 60 + 108 | hours * 60 * 60 + 109 | minutes * 60 + 110 | seconds 111 | ) 112 | 113 | return total_seconds 114 | 115 | 116 | def get_today(mode='%Y-%m-%d') -> str: 117 | return datetime.now().strftime(mode) 118 | 119 | 120 | def get_now_hour() -> int: 121 | return datetime.now().hour 122 | 123 | 124 | def get_yesterday(mode="%Y-%m-%d") -> str: 125 | yesterday = (datetime_date.today() + timedelta(-1)).strftime(mode) 126 | return str(yesterday) 127 | 128 | 129 | def get_year_first_day(mode="%Y-%m-%d") -> str: 130 | """ 131 | 今年第一天 132 | """ 133 | return str(datetime(datetime.now().year, 1, 1).strftime(mode)) 134 | 135 | 136 | def get_last_year() -> int: 137 | """ 138 | 获得去年 年份 139 | """ 140 | current_year = datetime.now().year 141 | return current_year - 1 142 | 143 | 144 | def get_monday_date(beginDate: str, endDate: str) -> List[str]: 145 | """ 146 | 获得时间区间内的所有 周一的日期 147 | """ 148 | start_date = datetime.strptime(beginDate, "%Y-%m-%d") 149 | end_date = datetime.strptime(endDate, "%Y-%m-%d") 150 | 151 | # 确保 start_date 是周一 152 | while start_date.weekday() != 0: 153 | start_date += timedelta(days=1) 154 | 155 | mondays = [] 156 | current_date = start_date 157 | while current_date <= end_date: 158 | mondays.append(current_date.strftime("%Y-%m-%d")) 159 | current_date += timedelta(weeks=1) 160 | return mondays 161 | 162 | 163 | def get_current_monday_date(base_time: str = None, *, mode="%Y-%m-%d") -> str: 164 | """ 165 | 获取本周周一的日期 166 | """ 167 | base_time = base_time or get_today(mode) 168 | if isinstance(base_time, str): 169 | base_time = datetime.strptime(base_time, mode) 170 | 171 | offset = (base_time.weekday() - 0) % 7 172 | last_monday = base_time - timedelta(days=offset) 173 | return last_monday.date().strftime(mode) 174 | 175 | 176 | def get_last_week_start_and_end(base_time=None, *, mode="%Y-%m-%d") -> Tuple[str, str]: 177 | """ 178 | 获取上个周一的开始和结束日期 179 | """ 180 | current_monday_str = get_current_monday_date(base_time, mode=mode) 181 | last_week_start = day_sub(current_monday_str, offset=7, mode=mode) 182 | last_week_end = day_sub(current_monday_str, offset=1) 183 | return last_week_start, last_week_end 184 | 185 | 186 | def get_last_monday_date(base_time=None, *, mode="%Y-%m-%d") -> str: 187 | """ 188 | 获取上周一的日期 189 | """ 190 | return get_last_week_start_and_end(base_time, mode=mode)[0] 191 | 192 | 193 | def get_recent_weeks_start_and_end(n: int, *, base_time=None, reverse: bool = True) -> List[tuple]: 194 | """ 195 | 获取从上周开始 最近 n 周的开始和结束日期 196 | :param n: 需要获取的周数 197 | :param base_time: 基准时间,如果为 None,则默认为当前时间 198 | :param reverse: 排序 默认倒叙 199 | :return: 最近 n 周的开始和结束日期列表 200 | """ 201 | weeks = [] 202 | current_week_start = get_last_monday_date(base_time) 203 | 204 | for _ in range(n): 205 | week_end = day_sub(current_week_start, offset=-6) 206 | weeks.append((current_week_start, week_end)) 207 | current_week_start = day_sub(current_week_start, offset=7) 208 | 209 | if reverse: 210 | return weeks 211 | return weeks[::-1] 212 | 213 | 214 | def get_current_first_day_of_month(base_time: str = None, *, mode="%Y-%m-%d", as_str=True) -> Union[str, datetime]: 215 | """ 216 | 获得本月第一天 217 | """ 218 | if base_time is None: 219 | base_time = datetime.today() 220 | elif isinstance(base_time, str): 221 | base_time = datetime.strptime(base_time, "%Y-%m-%d") 222 | else: 223 | raise TypeError('base_time 参数类型不匹配') 224 | 225 | current_first_day_of_month = base_time.replace(day=1) 226 | if as_str: 227 | return current_first_day_of_month.strftime(mode) 228 | return current_first_day_of_month 229 | 230 | 231 | def last_month_start_and_end(base_time: str = None, *, mode: str = "%Y-%m-%d") -> Tuple[str, str]: 232 | """ 233 | 获取上月第一天和最后一天 234 | """ 235 | if base_time is None: 236 | base_time = datetime.today() 237 | elif isinstance(base_time, str): 238 | base_time = datetime.strptime(base_time, "%Y-%m-%d") 239 | else: 240 | raise TypeError('base_time 参数类型不匹配') 241 | 242 | last_day_of_last_month = datetime_date(base_time.year, base_time.month, 1) - timedelta(1) 243 | first_day_of_last_month = datetime_date(last_day_of_last_month.year, last_day_of_last_month.month, 1) 244 | 245 | return first_day_of_last_month.strftime(mode), last_day_of_last_month.strftime(mode) 246 | 247 | 248 | def get_first_day_of_last_month(base_time: str = None, *, mode: str = "%Y-%m-%d") -> str: 249 | """ 250 | 获取上月第一天 251 | """ 252 | return last_month_start_and_end(base_time, mode=mode)[0] 253 | 254 | 255 | def auto_format_str_time(str_date, mode="%Y-%m-%d", safe=False): 256 | """ 257 | 自动识别字符串时间, 并格式化为 目标格式, 会有误差 258 | """ 259 | if safe and not str_date: 260 | return str_date 261 | date_obj = dateutil_parser.parse(str_date) 262 | formatted_date = date_obj.strftime(mode) 263 | return formatted_date 264 | 265 | 266 | def manually_format_str_time(str_time: str, *, current_mode, mode='%Y-%m-%d %H:%M:%S') -> str: 267 | """ 268 | 手动识别字符串时间, 并格式化为 目标格式 269 | """ 270 | if not isinstance(str_time, str): 271 | str_time = str(str_time) 272 | 273 | date = datetime.strptime(str_time, current_mode).strftime(mode) 274 | return date 275 | 276 | 277 | def split_date_range(begin, end, *, dt='d', mode="%Y-%m-%d", split_d: int = 1, reverse: bool = False) -> List[str]: 278 | """ 279 | 把目标时间区间 分割为目标格式 280 | """ 281 | if dt == 'm': 282 | results = [pd.Timestamp(x).strftime(mode) for x in pd.date_range(begin, end, freq='MS')] 283 | elif dt == 'd': 284 | results = [x.strftime(mode) for x in list(pd.date_range(start=begin, end=end))] 285 | else: 286 | results = [] 287 | 288 | if reverse is True: 289 | results = results[::-1] 290 | if split_d > 1: 291 | l_result = len(results) 292 | results = [results[index] for index in range(0, l_result, split_d)] 293 | 294 | return results 295 | 296 | 297 | def sort_dates(date_list: list, *, date_format="%Y-%m-%d", reverse=False) -> list: 298 | """ 299 | 对包含日期字符串(格式为 "%Y-%m-%d")的列表进行排序。 300 | :param date_list: 包含日期字符串的列表,例如 ["2024-08-22", "2023-12-01", "2024-01-15"] 301 | :param date_format: 格式化格式 302 | :param reverse: 默认升序, False:升序, True: 降序 303 | :return: 排序后的日期字符串列表(正序) 304 | """ 305 | return sorted(date_list, key=lambda date_str: datetime.strptime(date_str, date_format), reverse=reverse) 306 | 307 | 308 | def days_difference(t1: str, t2: str) -> int: 309 | """ 310 | 获得两个时间的差值 mode="%Y-%m-%d" 311 | """ 312 | t1_t = datetime.strptime(t1, "%Y-%m-%d") 313 | t2_t = datetime.strptime(t2, "%Y-%m-%d") 314 | difference = int((t2_t - t1_t).total_seconds() / (24 * 3600)) 315 | return difference 316 | 317 | 318 | def day_sub(base_t: str, offset: int, mode="%Y-%m-%d") -> str: 319 | """ 320 | base_t 偏移 offset 天之后的日期 321 | """ 322 | if not isinstance(base_t, str): 323 | base_t = str(base_t) 324 | return (datetime.strptime(base_t, mode) - relativedelta(days=offset)).strftime(mode) 325 | 326 | 327 | def add_seconds(base_t: str = None, seconds: int = 0, mode="%Y-%m-%d %H:%M:%S") -> str: 328 | """ 329 | seconds 偏移 offset 秒之后的时间 330 | """ 331 | if base_t is None: 332 | base_t = get_now_time(mode=mode) 333 | return (datetime.strptime(base_t, mode) + timedelta(seconds=seconds)).strftime(mode) 334 | 335 | 336 | def year_calculation(target_str: str, today: str = get_today()) -> str: 337 | """ 338 | 用来计算丢失的年份 339 | :param target_str: %m-%d 340 | :param today: "%Y-%m-%d" 341 | :return: 342 | """ 343 | now_time_t = datetime.strptime(today, "%Y-%m-%d") 344 | 345 | # 这里给它一个假的年份 346 | target_t = datetime.strptime(f"{now_time_t.year}-{target_str}", "%Y-%m-%d") 347 | 348 | if target_t.month > now_time_t.month: 349 | year = str(get_last_year()) 350 | else: 351 | year = get_year_first_day("%Y") 352 | return f"{year}-{target_str}" 353 | 354 | 355 | def group_dates(date_list: List[str], mode="%Y-%m-%d", split_day=0) -> List[Tuple[str, str]]: 356 | """ 357 | 将日期按连续性分组,并且每组的日期范围超过 split_day 天进行分割。 358 | """ 359 | date_list = list(set(date_list)) 360 | if not date_list: 361 | return [] 362 | 363 | date_objects = [datetime.strptime(date, mode) for date in date_list] 364 | date_objects.sort() # 排序日期 365 | grouped_dates = [] # 用于存储结果 366 | 367 | start_date = date_objects[0] 368 | end_date = start_date 369 | 370 | def add_group(start, end): 371 | if split_day <= 1: 372 | grouped_dates.append((start_date.strftime(mode), end_date.strftime(mode))) 373 | return 374 | while start <= end: 375 | split_end = min(start + timedelta(days=split_day), end) 376 | grouped_dates.append((start.strftime(mode), split_end.strftime(mode))) 377 | start = split_end + timedelta(days=1) 378 | 379 | for index in range(1, len(date_objects)): 380 | if date_objects[index] == end_date + timedelta(days=1): 381 | end_date = date_objects[index] 382 | continue 383 | 384 | add_group(start_date, end_date) 385 | start_date = date_objects[index] 386 | end_date = start_date 387 | 388 | # 添加最后一组 389 | add_group(start_date, end_date) 390 | return grouped_dates 391 | 392 | 393 | # def utc_time_to_standard_time(utc_time: str): 394 | # """ 395 | # 将格林尼治时间 转为 标准得东八区时间 396 | # :param utc_time: '2022-11-03T02:44:48.000+0000' 397 | # :return:东八区时间 398 | # """ 399 | # t = utc_time[:-9] 400 | # utc_date2 = datetime.datetime.strptime(t, "%Y-%m-%dT%H:%M:%S") 401 | # local_date = utc_date2 + datetime.timedelta(hours=8) 402 | # return datetime.datetime.strftime(local_date, "%Y-%m-%d %H:%M:%S") 403 | 404 | 405 | if __name__ == '__main__': 406 | print(timestamp_to_timestr(1730786470)) 407 | print(last_month_start_and_end()) 408 | # print(day_sub(get_today(), 2)) 409 | # print(get_now_time('%Y%m%d_%H%M%S')) 410 | # print(time_text2s('1天16秒')) 411 | # print(get_now_timestamp(as_int=True)) 412 | # print(get_current_monday_date()) 413 | # print(last_month_start_and_end('2024-01-15')) 414 | # aa, cc = get_last_week_start_and_end() 415 | # print(aa, cc) 416 | # print(get_current_monday_date()) 417 | # 418 | # print(get_recent_weeks_start_and_end(12, reverse=False)) 419 | # 420 | # start_time = day_sub(get_yesterday(), 2, '%Y-%m-%d') 421 | # end_time = get_yesterday('%Y-%m-%d') 422 | # 423 | # get_date_list = split_date_range(start_time, end_time)[::-1] 424 | print((timestr_to_timestamp('2024-10-08'))) 425 | 426 | print(time_expr2s('01:45')) 427 | print( day_sub(get_today(), 2)) 428 | # print(sort_dates(['2024-08-11', '2024-08-13', '2024-08-12'], reverse=True)) 429 | -------------------------------------------------------------------------------- /utils/exception.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: exception 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | 9 | # F_PATH = os.path.dirname(__file__) 10 | # sys.path.append(os.path.join(F_PATH, '..')) 11 | # sys.path.append(os.path.join(F_PATH, '../..')) 12 | 13 | 14 | class SlideBlockError(Exception): 15 | def __init__(self, message=''): 16 | self.message = f"{message} 滑块滑动失败" 17 | 18 | def __str__(self): 19 | return self.message 20 | 21 | 22 | if __name__ == '__main__': 23 | pass 24 | -------------------------------------------------------------------------------- /utils/path_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: path 4 | # @time: 2023/11/30 5 | # @desc: 6 | 7 | import os 8 | 9 | 10 | def get_file_name(file): 11 | """ 12 | 获得文件名 13 | :param file: __file__ 14 | :return: 15 | """ 16 | return os.path.dirname(file).replace('\\', '/').rsplit('/', 1)[-1] 17 | 18 | 19 | def path_join(l, r, create_path=False): 20 | """ 21 | 路径合并 22 | :param l: 23 | :param r: 24 | :param create_path: 是否创建新的路径 25 | :return: 26 | """ 27 | p = os.path.join(l, r).replace('\\', '/') 28 | if create_path: 29 | if not os.path.isdir(p): 30 | os.makedirs(p) 31 | 32 | return p 33 | 34 | 35 | if __name__ == '__main__': 36 | pass 37 | -------------------------------------------------------------------------------- /utils/text_parse.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: text_parse 4 | # @time: 2023/11/14 5 | # @desc: 6 | # import sys 7 | # import os 8 | import hashlib 9 | from pypinyin import pinyin, Style 10 | 11 | 12 | def chinese_to_pinyin(chinese: str, join=False): 13 | result = pinyin(chinese, style=Style.NORMAL) 14 | if join: 15 | return ''.join([i[0] for i in result]) 16 | return result 17 | 18 | 19 | def calculate_number(text): 20 | """ 21 | 根据文本计算出在 9600, 19800 之间的数字 22 | :param text: 23 | :return: 24 | """ 25 | sha256_hash = hashlib.sha256(text.encode()).hexdigest() 26 | decimal_hash = int(sha256_hash, 16) # 将16进制的hash转换为10进制 27 | number = 9600 + (decimal_hash % 10201) 28 | return number 29 | 30 | 31 | if __name__ == '__main__': 32 | print(calculate_number( 33 | 'isg=BImJ6d')) 34 | -------------------------------------------------------------------------------- /utils/url_parse_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: url_parse 4 | # @time: 2023/11/3 5 | # @desc: 6 | # import sys 7 | # import os 8 | from urllib.parse import urlparse 9 | 10 | 11 | def extract_http_host(url): 12 | parsed_url = urlparse(url) 13 | scheme = parsed_url.scheme 14 | host = parsed_url.netloc 15 | return f"{scheme}://{host}/" 16 | 17 | 18 | if __name__ == '__main__': 19 | print(extract_http_host( 20 | 'http' 21 | )) 22 | -------------------------------------------------------------------------------- /utils/verify_imgas_tools.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: imgas 4 | # @time: 2023/11/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | import numpy as np 9 | import cv2 10 | from base64 import b64decode 11 | from io import BytesIO 12 | from PIL import Image 13 | from ddddocr import DdddOcr 14 | 15 | 16 | def base64_to_image(base64_string: str, output_file: str): 17 | """ 18 | base64 转为png图片 19 | :param base64_string: 20 | :param output_file: 21 | :return: 22 | """ 23 | header, encoded = base64_string.split(",", 1) 24 | decoded = b64decode(encoded) 25 | image_data = BytesIO(decoded) 26 | image = Image.open(image_data) 27 | image.save(output_file, "png") 28 | 29 | 30 | def get_distance(templateJpg: str, blockJpg:str) -> int: 31 | # 读取灰度图 32 | block = cv2.imread(blockJpg, 0) 33 | template = cv2.imread(templateJpg, 0) 34 | # 保存图像 35 | cv2.imwrite(templateJpg, template) 36 | cv2.imwrite(blockJpg, block) 37 | 38 | block = cv2.imread(blockJpg) 39 | block = cv2.cvtColor(block, cv2.COLOR_BGR2GRAY) 40 | block = abs(255 - block) 41 | cv2.imwrite(blockJpg, block) 42 | block = cv2.imread(blockJpg) 43 | template = cv2.imread(templateJpg) 44 | 45 | result = cv2.matchTemplate(block, template, cv2.TM_CCOEFF_NORMED) 46 | mn_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result) 47 | x, y = np.unravel_index(result.argmax(), result.shape) 48 | # # 这里就是下图中的绿色框框 49 | # cv2.rectangle(template, (y + 20, x + 20), (y + 136 - 25, x + 136 - 25), (7, 249, 151), 2) 50 | return y 51 | 52 | 53 | def identify_gap(fg: bytes, bg: bytes): 54 | """ 55 | 多缺口识别 56 | """ 57 | bg_img = cv2.imdecode(np.asarray(bytearray(bg), dtype=np.uint8), 0) # 背景图片 58 | bg_img2 = bg_img.copy() # 背景图片 59 | bg_pic2 = cv2.cvtColor(bg_img2, cv2.COLOR_GRAY2RGB) 60 | 61 | tp_img = cv2.imdecode(np.asarray(bytearray(fg), dtype=np.uint8), 0) # 缺口图片 62 | # 识别图片边缘 63 | bg_img[bg_img < 60] = 0 64 | bg_img[bg_img >= 60] = 255 65 | bg_edge = cv2.Canny(bg_img, 0, 20) 66 | 67 | tp_edge = cv2.Canny(tp_img, 100, 200) 68 | # 转换图片格式 69 | bg_pic = cv2.cvtColor(bg_edge, cv2.COLOR_GRAY2RGB) 70 | tp_pic = cv2.cvtColor(tp_edge, cv2.COLOR_GRAY2RGB) 71 | # 缺口匹配 72 | s = cv2.matchTemplate(bg_pic, tp_pic, cv2.TM_CCOEFF_NORMED) 73 | min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(s) # 寻找最优匹配 74 | # 绘制方框 75 | th, tw = tp_pic.shape[:2] 76 | tl = max_loc # 左上角点的坐标 77 | br = (tl[0] + tw, tl[1] + th) # 右下角点的坐标 78 | 79 | cv2.rectangle(bg_pic2, tl, br, (0, 255, 255), 2) # 绘制矩形 80 | 81 | # 显示图像 82 | # cv2.namedWindow("Image", cv2.WINDOW_NORMAL) 83 | # cv2.imwrite("3.png", bg_img2) 84 | # cv2.imshow('Image', bg_img2) 85 | # cv2.waitKey(0) 86 | # cv2.destroyAllWindows() 87 | 88 | distance = tl[0] 89 | # 返回缺口的X坐标 90 | return distance 91 | 92 | 93 | def get_tracks(dis, distance): 94 | v = 130 95 | t = 0.3 96 | # 保存0.3内的位移 97 | tracks = [] 98 | current = 0 99 | mid = distance * 4 / 5 100 | while current <= dis: 101 | if current < mid: 102 | a = 8 103 | else: 104 | a = -12 105 | v0 = v 106 | s = v0 * t + 0.5 * a * (t ** 2) 107 | current += s 108 | tracks.append(round(s)) 109 | v = v0 + a * t 110 | return tracks 111 | 112 | 113 | def show_distance(background_png, slide_png): 114 | """ 115 | :param background_png: 116 | :param slide_png: 117 | :return: 118 | """ 119 | with open(background_png, 'rb') as f: 120 | background_bytes = f.read() 121 | 122 | with open(slide_png, 'rb') as f: 123 | target_bytes = f.read() 124 | 125 | Oorc = DdddOcr(det=False, ocr=False, show_ad=False) 126 | res = Oorc.slide_match(target_bytes, background_bytes) 127 | return res['target'][0] 128 | 129 | 130 | if __name__ == '__main__': 131 | print(show_distance( 132 | )) 133 | -------------------------------------------------------------------------------- /utils/wrapper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: UTF-8 -*- 2 | # @author: ylw 3 | # @file: wrapper 4 | # @time: 2025/1/9 5 | # @desc: 6 | # import sys 7 | # import os 8 | import time 9 | import traceback 10 | import hashlib 11 | from functools import wraps 12 | from collections import OrderedDict 13 | from typing import TypeVar, Callable, Optional, Any, cast 14 | 15 | # F_PATH = os.path.dirname(__file__) 16 | # sys.path.append(os.path.join(F_PATH, '..')) 17 | # sys.path.append(os.path.join(F_PATH, '../..')) 18 | from logger import logger 19 | 20 | 21 | def dict2hash(d: dict): 22 | """将字典转换为哈希值""" 23 | d_str = str(sorted(d.items())) 24 | return hashlib.md5(d_str.encode()).hexdigest() 25 | 26 | 27 | class WrapperKeyCache(OrderedDict): 28 | def __init__(self, max_size): 29 | super().__init__() 30 | self.max_size = max_size 31 | 32 | def get(self, key): 33 | if key in self: 34 | self.move_to_end(key) 35 | return self[key] 36 | return None 37 | 38 | def set(self, key, value): 39 | if key in self: 40 | self.move_to_end(key) 41 | return 42 | if len(self) >= self.max_size: 43 | self.popitem(last=False) 44 | self[key] = value 45 | 46 | 47 | class Wrapper: 48 | F = TypeVar('F', bound=Callable[..., Optional[Any]]) 49 | __cache = WrapperKeyCache(100) 50 | 51 | @staticmethod 52 | def no_error(default_value=None): 53 | """不异常""" 54 | 55 | def decorator(func): 56 | @wraps(func) 57 | def wrapper(*args, **kwargs): 58 | try: 59 | return func(*args, **kwargs) 60 | except: 61 | return default_value 62 | 63 | return cast(Wrapper.F, wrapper) 64 | 65 | return decorator 66 | 67 | @staticmethod 68 | def save_error_log(func): 69 | @wraps(func) 70 | def wrap_func(*args, **kwargs): 71 | try: 72 | result = func(*args, **kwargs) 73 | except Exception as e: 74 | logger.info(traceback.format_exc()) 75 | raise e 76 | else: 77 | return result 78 | 79 | return cast(Wrapper.F, wrap_func) 80 | 81 | @staticmethod 82 | def retry(retries=3, delay=0, *, save_error_log: bool = True): 83 | def decorator(func): 84 | @wraps(func) 85 | def wrapper(*args, **kwargs): 86 | error = None 87 | for attempt in range(retries): 88 | try: 89 | return func(*args, **kwargs) 90 | except Exception as e: 91 | if save_error_log: 92 | logger.info(traceback.format_exc()) 93 | if delay > 0: 94 | time.sleep(delay) 95 | error = e 96 | raise error 97 | 98 | return cast(Wrapper.F, wrapper) 99 | 100 | return decorator 101 | 102 | @staticmethod 103 | def cache_with_expiration(cache_time: int = 5): 104 | """缓存装饰器,缓存的过期时间以分钟为单位""" 105 | 106 | def decorator(func): 107 | cache = {} 108 | 109 | @wraps(func) 110 | def wrapper(*args, **kwargs): 111 | key = (args, dict2hash(kwargs)) 112 | if key in cache: 113 | result, timestamp = cache[key] 114 | if time.time() - timestamp < cache_time * 60: 115 | return result 116 | result = func(*args, **kwargs) 117 | cache[key] = (result, time.time()) 118 | return result 119 | 120 | return cast(Wrapper.F, wrapper) 121 | 122 | return decorator 123 | 124 | @staticmethod 125 | def retry_until_done(retries=3, delay=10, *, desc: str = None): 126 | """ 127 | 装饰器:在指定次数内尝试调用被装饰的函数,每次尝试之间有指定的停留时间。 128 | :param retries: 尝试次数 129 | :param delay: 每次尝试后的停留时间(秒) 130 | :param desc: 没有正确返回的返回值的简述提示 131 | """ 132 | _desc = desc or 'None' 133 | 134 | def decorator(func): 135 | cache_key = hashlib.md5(f"{id(func)}-{retries}-{delay}".encode('utf-8')).hexdigest() 136 | if cache_key in Wrapper.__cache: 137 | return Wrapper.__cache.get(cache_key) 138 | 139 | @wraps(func) 140 | def wrapper(*args, **kwargs): 141 | for attempt in range(retries): 142 | result: Optional[Any] = func(*args, **kwargs) 143 | if result is None: 144 | time.sleep(delay) 145 | continue 146 | return result 147 | logger.info(f"简述: {_desc};") 148 | raise ValueError(f"函数没有返回有效值; 简述: {_desc};") 149 | 150 | ww = cast(Wrapper.F, wrapper) 151 | Wrapper.__cache.set(cache_key, ww) 152 | return ww 153 | 154 | return decorator 155 | 156 | 157 | if __name__ == '__main__': 158 | pass 159 | --------------------------------------------------------------------------------