├── README.md ├── images ├── 复制cookie.png ├── 运行截图1.png └── 运行截图2.png ├── pip_install.txt ├── run.py └── src ├── __init__.py ├── backup ├── __init__.py ├── items.py └── recorder.py ├── config ├── __init__.py ├── constant.py ├── cookie.py └── settings.py ├── download ├── __init__.py ├── acquire.py ├── download.py └── parse.py ├── encrypt_params ├── __init__.py ├── a_bogus.js ├── a_bogus_RPC │ ├── RPC.py │ ├── bdms.js │ └── env.js ├── general.py ├── js_port.py ├── msToken.py ├── ttWid.py ├── verifyfp.py └── webid.py ├── scheduler.py └── tool ├── __init__.py ├── cleaner.py └── function.py /README.md: -------------------------------------------------------------------------------- 1 | # DouYinDownload 2 | 3 | ## 项目功能 4 | 5 | 1. 使用协程下载视频与图集(协程数为 5) 6 | 2. 配置文件可设置是否下载视频、是否下载图集。 7 | 3. 使用配置文件连续下载多个帐号视频。 8 | 4. 项目非正常退出时,再次运行后可接着下载。 9 | 10 | ### 运行截图 11 | 12 | ![](images/运行截图1.png) 13 | ![](images/运行截图2.png) 14 | 15 | ## 使用说明 16 | 17 | 项目使用的第三方 python 库有:rich、requests、aiohttp、py_mini_racer,可使用 pip install XXX 命令安装。 18 | 19 | ## 配置文件说明 20 | 21 | | 条目 | 说明 | 22 | | --------------- | ---------------------------------------------------------------------------------------------------- | 23 | | accounts | 要下载的帐号信息,可添加多个帐号 | 24 | | mark | 账号标识,可以设置为空字符串 | 25 | | url | 账号主页链接(必须为电脑网页端链接) | 26 | | earliest | 要下载的作品最早发布日期(默认为 2016/9/20) | 27 | | latest | 要下载的作品最晚发布日期(默认为 前一天日期) | 28 | | cookies | 必填项,可根据下图从浏览器复制,并通过程序运行填入配置文件 ![](images/复制cookie.png) | 29 | | save_folder | 下载视频存储文件夹(默认为项目根目录) | 30 | | download_videos | 设置为 “False”,则不下载视频 | 31 | | download_images | 设置为 “False”,则不下载图集 | 32 | | name_format | 下载的视频命名格式(可选项:create_time(视频发布日期) id(视频 id) type(图集/视频) desc(视频描述文本) | 33 | | split | 上述 “name_format” 不同项间的间隔符(默认为 “-”) | 34 | | date_format | 上述 “name_format” 中日期格式(默认为 “%Y-%m-%d”(年月日)) | 35 | 36 | ## 免责声明 (Disclaimer) 37 | 38 | - 本项目仅用于学习和研究使用,不得用于任何商业和非法目的。使用本项目提供的功能,用户需自行承担可能带来的一切法律责任。 39 | 40 | - 使用本项目的内容,即代表您同意本免责声明的所有条款和条件。如果你不接受以上的免责声明,请立即停止使用本项目。 41 | 42 | - 如有侵犯到您的知识产权、个人隐私等,请立即联系我们, 我们将积极配合保护您的权益。 43 | 44 | ## 项目参考 (Refer) 45 | 46 | - https://github.com/NearHuiwen/TiktokDouyinCrawler 47 | - https://github.com/JoeanAmier/TikTokDownloader 48 | - https://github.com/Johnserf-Seed/f2 49 | - https://github.com/Johnserf-Seed/TikTokDownload 50 | - https://github.com/Evil0ctal/Douyin_TikTok_Download_API 51 | - https://github.com/NearHuiwen/TiktokDouyinCrawler 52 | - https://github.com/ihmily/DouyinLiveRecorder 53 | - https://github.com/encode/httpx/ 54 | - https://github.com/Textualize/rich 55 | - https://github.com/omnilib/aiosqlite 56 | - https://github.com/borisbabic/browser_cookie3 57 | - https://github.com/pyinstaller/pyinstaller 58 | - https://ffmpeg.org/ffmpeg-all.html 59 | - https://html5up.net/hyperspace 60 | -------------------------------------------------------------------------------- /images/复制cookie.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuanDD123/douyin_download/be45590c5c3ed63fb5fec3705682daa5d0e668bc/images/复制cookie.png -------------------------------------------------------------------------------- /images/运行截图1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuanDD123/douyin_download/be45590c5c3ed63fb5fec3705682daa5d0e668bc/images/运行截图1.png -------------------------------------------------------------------------------- /images/运行截图2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuanDD123/douyin_download/be45590c5c3ed63fb5fec3705682daa5d0e668bc/images/运行截图2.png -------------------------------------------------------------------------------- /pip_install.txt: -------------------------------------------------------------------------------- 1 | pip install rich 2 | pip install requests 3 | pip install aiohttp 4 | pip install py_mini_racer 5 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | from src.scheduler import Scheduler 2 | 3 | if __name__ == '__main__': 4 | Scheduler().run() 5 | -------------------------------------------------------------------------------- /src/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GuanDD123/douyin_download/be45590c5c3ed63fb5fec3705682daa5d0e668bc/src/__init__.py -------------------------------------------------------------------------------- /src/backup/__init__.py: -------------------------------------------------------------------------------- 1 | from .recorder import DownloadRecorder 2 | from .items import DownloadItems -------------------------------------------------------------------------------- /src/backup/items.py: -------------------------------------------------------------------------------- 1 | from os.path import ( 2 | join as join_path, 3 | exists 4 | ) 5 | from os import remove 6 | from json import dump, load 7 | from rich import print 8 | 9 | from ..config import ( 10 | PROJECT_ROOT, 11 | ENCODE, 12 | YELLOW 13 | ) 14 | 15 | 16 | class DownloadItems: 17 | path = join_path(PROJECT_ROOT, 'cache/ItemsInfo.json') 18 | 19 | def read(self): 20 | '''获取账号信息、作品信息并返回''' 21 | if exists(self.path): 22 | with open(self.path, encoding=ENCODE) as f: 23 | data = load(f) 24 | return (data[0], data[1:]) 25 | else: 26 | print(f'[{YELLOW}]账号信息、作品信息数据已丢失!\n数据文件路径:{self.path}') 27 | return (None, None) 28 | 29 | def save(self, account: dict, items: list[dict]): 30 | '''将账号信息及作品信息覆写到文件''' 31 | with open(self.path, 'w', encoding=ENCODE) as f: 32 | data = [] 33 | data.append(account) 34 | data.extend(items) 35 | dump(data, f, ensure_ascii=False, indent=4, default=lambda x: str(x)) 36 | 37 | def delete(self): 38 | '''删除账号信息、作品信息信息文件''' 39 | if exists(self.path): 40 | remove(self.path) 41 | -------------------------------------------------------------------------------- /src/backup/recorder.py: -------------------------------------------------------------------------------- 1 | from os.path import ( 2 | join as join_path, 3 | exists, 4 | ) 5 | from os import remove 6 | from rich import print 7 | 8 | from ..config import ( 9 | YELLOW, 10 | PROJECT_ROOT, 11 | ENCODE 12 | ) 13 | 14 | 15 | class DownloadRecorder: 16 | path = join_path(PROJECT_ROOT, 'cache/IDRecorder.txt') 17 | 18 | def __init__(self): 19 | self.records = set() 20 | 21 | def read(self): 22 | '''获取下载记录,保存到 self.records''' 23 | if exists(self.path): 24 | with open(self.path, encoding=ENCODE) as f: 25 | self.records = {line.strip() for line in f} 26 | else: 27 | print(f'[{YELLOW}]作品下载记录数据已丢失!\n数据文件路径:{self.path}') 28 | 29 | def open_(self): 30 | self.f_obj = open(self.path, 'a', encoding=ENCODE) 31 | 32 | def save(self, id: str): 33 | '''将已下载 id 添加到文件''' 34 | self.f_obj.write(f'{id}\n') 35 | self.f_obj.flush() 36 | 37 | def delete(self): 38 | '''删除下载记录文件''' 39 | if exists(self.path): 40 | remove(self.path) -------------------------------------------------------------------------------- /src/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .constant import ( 2 | PROJECT_ROOT, 3 | ENCODE, 4 | USER_AGENT, PHONE_USER_AGENT, 5 | RETRY_ACCOUNT, RETRY_FILE, 6 | TEXT_REPLACEMENT, 7 | DESCRIPTION_LENGTH, 8 | WHITE, YELLOW, GREEN, RED, CYAN, MAGENTA, 9 | CHUNK, 10 | TIMEOUT, 11 | CONCURRENCY 12 | ) 13 | from .cookie import Cookie 14 | from .settings import Settings 15 | -------------------------------------------------------------------------------- /src/config/constant.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | from os import name 3 | 4 | PROJECT_ROOT = dirname(dirname(dirname(__file__))) 5 | 6 | ENCODE = 'UTF-8-SIG' if name == 'nt' else 'UTF-8' 7 | 8 | USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' 9 | PHONE_USER_AGENT = 'com.ss.android.ugc.trill/494+Mozilla/5.0+(Linux;+Android+12;+2112123G+Build/SKQ1.211006.001;+wv)+AppleWebKit/537.36+(KHTML,+like+Gecko)+Version/4.0+Chrome/107.0.5304.105+Mobile+Safari/537.36' 10 | 11 | # 颜色设置,支持标准颜色名称、Hex、RGB 格式 12 | WHITE = '#aaaaaa' 13 | CYAN = 'bright_cyan' 14 | RED = 'bright_red' 15 | YELLOW = 'bright_yellow' 16 | GREEN = 'bright_green' 17 | MAGENTA = 'bright_magenta' 18 | 19 | # 文件 desc 最大长度限制 20 | DESCRIPTION_LENGTH = 64 21 | 22 | # 重新执行的最大次数 23 | RETRY_ACCOUNT = 3 24 | RETRY_FILE = 2 25 | 26 | # 非法字符集合 27 | TEXT_REPLACEMENT = frozenset() 28 | 29 | # 每次从服务器接收的数据块大小 30 | CHUNK = 1024 * 1024 31 | 32 | # 请求超时时间 33 | TIMEOUT = 60 * 5 34 | 35 | # 文件下载最大协程数 36 | CONCURRENCY = 5 37 | -------------------------------------------------------------------------------- /src/config/cookie.py: -------------------------------------------------------------------------------- 1 | from re import finditer 2 | from rich import print 3 | 4 | from .constant import CYAN, GREEN 5 | from .settings import Settings 6 | from ..encrypt_params import MsToken, TtWid 7 | 8 | 9 | class Cookie: 10 | def __init__(self, settings: Settings) -> None: 11 | self.settings = settings 12 | 13 | def input_save(self): 14 | '''输入 cookie,转为 dict,保存到 Settings.cookies 属性中,并存入配置文件''' 15 | while not (cookie := input(f'请粘贴 Cookie 内容: ')): 16 | continue 17 | self.settings.cookies = self._generate_dict(cookie) 18 | self._check() 19 | self._save() 20 | 21 | def update(self): 22 | '''更新 Settings.cookies 与 Settings.headers''' 23 | if self.settings.cookies: 24 | self._add_cookies() 25 | self.settings.headers['Cookie'] = self._generate_str(self.settings.cookies) 26 | 27 | def _check(self): 28 | '''检查 Settings.cookies 是否已登录;删除空键值对''' 29 | if not self.settings.cookies['sessionid_ss']: 30 | print(f'[{CYAN}]当前 Cookie 未登录') 31 | else: 32 | print(f'[{CYAN}]当前 Cookie 已登录') 33 | 34 | keys_to_remove = [key for key, value in self.settings.cookies.items() if value is None] 35 | for key in keys_to_remove: 36 | del self.settings.cookies[key] 37 | 38 | def _save(self): 39 | '''将 Settings.cookies 存储到 settings.json''' 40 | self.settings.settings['cookies'] = self.settings.cookies 41 | self.settings.save() 42 | print(f'[{GREEN}]写入 Cookie 成功!') 43 | 44 | def _add_cookies(self): 45 | parameters = (MsToken.get_real_ms_token(), TtWid.get_tt_wid()) 46 | for i in parameters: 47 | if isinstance(i, dict): 48 | self.settings.cookies |= i 49 | 50 | @staticmethod 51 | def _generate_str(cookies: dict): 52 | '''根据 dict 生成 str''' 53 | if cookies: 54 | result = [f'{k}={v}' for k, v in cookies.items()] 55 | return '; '.join(result) 56 | 57 | @staticmethod 58 | def _generate_dict(cookie: str): 59 | '''根据 str 生成 dict''' 60 | cookies_key = { 61 | 'passport_csrf_token', 62 | 'passport_csrf_token_default', 63 | 'my_rd', 64 | 'passport_auth_status', 65 | 'passport_auth_status_ss', 66 | 'd_ticket', 67 | 'publish_badge_show_info', 68 | 'volume_info', 69 | '__live_version__', 70 | 'download_guide', 71 | 'EnhanceDownloadGuide', 72 | 'pwa2', 73 | 'live_can_add_dy_2_desktop', 74 | 'live_use_vvc', 75 | 'store-region', 76 | 'store-region-src', 77 | 'strategyABtestKey', 78 | 'FORCE_LOGIN', 79 | 'LOGIN_STATUS', 80 | '__security_server_data_status', 81 | '_bd_ticket_crypt_doamin', 82 | 'n_mh', 83 | 'passport_assist_user', 84 | 'sid_ucp_sso_v1', 85 | 'ssid_ucp_sso_v1', 86 | 'sso_uid_tt', 87 | 'sso_uid_tt_ss', 88 | 'toutiao_sso_user', 89 | 'toutiao_sso_user_ss', 90 | 'sessionid', 91 | 'sessionid_ss', 92 | 'sid_guard', 93 | 'sid_tt', 94 | 'sid_ucp_v1', 95 | 'ssid_ucp_v1', 96 | 'uid_tt', 97 | 'uid_tt_ss', 98 | 'FOLLOW_NUMBER_YELLOW_POINT_INFO', 99 | 'vdg_s', 100 | '_bd_ticket_crypt_cookie', 101 | 'FOLLOW_LIVE_POINT_INFO', 102 | 'bd_ticket_guard_client_data', 103 | 'bd_ticket_guard_client_web_domain', 104 | 'home_can_add_dy_2_desktop', 105 | 'odin_tt', 106 | 'stream_recommend_feed_params', 107 | 'IsDouyinActive', 108 | 'stream_player_status_params', 109 | 's_v_web_id', 110 | '__ac_nonce', 111 | 'dy_sheight', 112 | 'dy_swidth', 113 | 'ttcid', 114 | 'xgplayer_user_id', 115 | '__ac_signature', 116 | 'tt_scid' 117 | } 118 | cookies = {}.fromkeys(cookies_key) 119 | matches = finditer(r'(?P[^=;,]+)=(?P[^;,]+)', cookie) 120 | for match in matches: 121 | key = match.group('key').strip() 122 | value = match.group('value').strip() 123 | if key in cookies_key: 124 | cookies[key] = value 125 | return cookies 126 | -------------------------------------------------------------------------------- /src/config/settings.py: -------------------------------------------------------------------------------- 1 | from os.path import ( 2 | join as join_path, 3 | exists 4 | ) 5 | from json import dump, load 6 | from json.decoder import JSONDecodeError 7 | from re import match 8 | from os import makedirs 9 | from copy import deepcopy 10 | from datetime import date, timedelta, datetime 11 | from rich import print 12 | 13 | from .constant import ( 14 | PROJECT_ROOT, 15 | RED, YELLOW, GREEN, 16 | ENCODE, 17 | USER_AGENT 18 | ) 19 | 20 | 21 | class Settings: 22 | file = join_path(PROJECT_ROOT, 'settings.json') # 配置文件 23 | default_settings = { 24 | 'accounts': [ 25 | { 26 | 'mark': '账号标识,可以设置为空字符串', 27 | 'url': '账号主页链接', 28 | 'earliest': '作品最早发布日期', 29 | 'latest': '作品最晚发布日期' 30 | }, 31 | ], 32 | 'cookies': {}, 33 | 'save_folder': PROJECT_ROOT, 34 | 'download_videos': 'True', 35 | 'download_images': 'False', 36 | 'name_format': 'create_time id type desc', 37 | 'split': '-', 38 | 'date_format': '%Y-%m-%d' 39 | } 40 | 41 | def __init__(self) -> None: 42 | self.headers = {'Referer': 'https://www.douyin.com/', 'User-Agent': USER_AGENT} 43 | 44 | def load_settings(self): 45 | '''读取配置文件内容,并将配置保存到 self.settings 属性; 46 | 如果没有配置文件,则创建默认配置文件; 47 | 若缺少参数,询问是否创建默认配置文件''' 48 | self.settings = self._read() 49 | if self.settings: 50 | if set(self.default_settings.keys()) <= (set(self.settings.keys())): 51 | self._load_accounts() 52 | self._load_cookies() 53 | self._load_save_folder() 54 | self._load_download() 55 | self._load_name() 56 | else: 57 | print(f'[{RED}]配置文件 settings.json 缺少必要的参数!') 58 | if input('是否生成默认配置文件?Y/N:').lower() == 'y': 59 | self._create() 60 | 61 | def save(self): 62 | '''将 self.settings 覆写到配置文件''' 63 | with open(self.file, 'w', encoding=ENCODE) as f: 64 | dump(self.settings, f, indent=4, ensure_ascii=False) 65 | print(f'[{GREEN}]保存配置成功!') 66 | 67 | def _load_download(self): 68 | self.download_videos = True if str(self.settings['download_videos']).lower() != 'false' else False 69 | self.download_images = True if str(self.settings['download_images']).lower() != 'false' else False 70 | 71 | def _load_name(self): 72 | self.name_format = str(self.settings['name_format']).split() 73 | if (not self.name_format) or ( 74 | not set(self.name_format) <= {'id', 'desc', 'create_time', 'type'}): 75 | self.name_format = self.default_settings['name_format'].split() 76 | 77 | self.split = str(self.settings['split']) or self.default_settings['split'] 78 | self.date_format = str(self.settings['date_format']) or self.default_settings['date_format'] 79 | 80 | def _load_save_folder(self): 81 | self.save_folder = str(self.settings['save_folder']) 82 | if not self.save_folder: 83 | print(f'[{YELLOW}]参数 "save_folder" 未设置,将使用默认存储位置 {self.default_settings['save_folder']}!') 84 | self.save_folder = self.default_settings['save_folder'] 85 | elif not exists(self.save_folder): 86 | makedirs(self.save_folder) 87 | 88 | def _read(self): 89 | '''读取配置文件并返回配置内容; 90 | 如果没有配置文件,则创建默认配置文件''' 91 | if exists(self.file): 92 | try: 93 | with open(self.file, encoding=ENCODE) as f: 94 | return load(f) 95 | except JSONDecodeError: 96 | print(f'[{RED}]配置文件 settings.json 格式错误,请检查 JSON 格式!') 97 | else: 98 | self._create() 99 | 100 | def _create(self): 101 | '''创建默认配置文件''' 102 | with open(self.file, 'w', encoding=ENCODE) as f: 103 | dump(self.default_settings, f, indent=4, ensure_ascii=False) 104 | print(f'[{GREEN}]创建默认配置文件 settings.json 成功!') 105 | 106 | def _load_accounts(self): 107 | self.accounts = deepcopy(self.settings['accounts']) 108 | for account in self.accounts: 109 | account['sec_user_id'] = self._extract_sec_user_id(account['mark'], account['url']) 110 | account['earliest_date'] = self._generate_date_earliest(account['earliest']) 111 | account['latest_date'] = self._generate_date_latest(account['latest']) 112 | if account['sec_user_id'] is None: 113 | break 114 | 115 | def _extract_sec_user_id(self, mark: str, url: str) -> str | None: 116 | sec_user_id = match( 117 | r'https://www\.douyin\.com/user/([A-Za-z0-9_-]+)(\?.*)?', url).group(1) 118 | if sec_user_id: 119 | return sec_user_id 120 | else: 121 | print(f'[{RED}]参数 accounts 中账号 {mark} 的 url {url} 错误,提取 sec_user_id 失败!') 122 | return 123 | 124 | def _generate_date_earliest(self, date_: str): 125 | if not date_: 126 | return date(2016, 9, 20) 127 | else: 128 | try: 129 | return datetime.strptime(date_, '%Y/%m/%d').date() 130 | except ValueError: 131 | print(f'[{YELLOW}]作品最早发布日期 {date_} 无效') 132 | return date(2016, 9, 20) 133 | 134 | def _generate_date_latest(self, date_: str): 135 | if not date_: 136 | return date.today() - timedelta(days=1) 137 | else: 138 | try: 139 | return datetime.strptime(date_, '%Y/%m/%d').date() 140 | except ValueError: 141 | print(f'[{YELLOW}]作品最晚发布日期无效 {date_}') 142 | return date.today() - timedelta(days=1) 143 | 144 | def _load_cookies(self): 145 | self.cookies = deepcopy(self.settings['cookies']) 146 | if not isinstance(self.cookies, dict): 147 | print(f'[{YELLOW}]参数 "cookies" 格式错误,请重新设置!') 148 | self.cookies = {} 149 | -------------------------------------------------------------------------------- /src/download/__init__.py: -------------------------------------------------------------------------------- 1 | from .acquire import Acquire 2 | from .parse import Parse 3 | from .download import Download -------------------------------------------------------------------------------- /src/download/acquire.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | from urllib.parse import urlencode 3 | from requests import exceptions, get 4 | from rich.progress import ( 5 | BarColumn, 6 | Progress, 7 | TextColumn, 8 | TimeElapsedColumn, 9 | ) 10 | from random import randint 11 | from time import sleep 12 | from rich import print 13 | 14 | from ..config import MAGENTA, YELLOW, TIMEOUT 15 | from ..encrypt_params import get_a_bogus 16 | from ..tool import retry 17 | from ..config import Settings 18 | 19 | 20 | class Acquire(): 21 | post_api = 'https://www.douyin.com/aweme/v1/web/aweme/post/' 22 | 23 | def __init__(self, settings: Settings): 24 | self.settings = settings 25 | 26 | def request_items(self, sec_user_id: str, earliest: date): 27 | '''获取账号作品数据并返回''' 28 | items = [] 29 | with self._progress_object() as progress: 30 | progress.add_task('正在获取账号主页数据', total=None) 31 | self.cursor = 0 32 | self.finished = False 33 | while not self.finished: 34 | if (items_page := self._request_items_page(sec_user_id)): 35 | if not items_page == [None]: 36 | items.extend(items_page) 37 | self._early_stop(earliest) 38 | return items 39 | 40 | def _progress_object(self): 41 | return Progress( 42 | TextColumn('[progress.description]{task.description}', style=MAGENTA, justify='left'), 43 | '•', 44 | BarColumn(bar_width=20), 45 | '•', 46 | TimeElapsedColumn(), 47 | transient=True, 48 | ) 49 | 50 | @retry 51 | def _request_items_page(self, sec_user_id: str): 52 | '''获取单页作品数据,更新 self.cursor''' 53 | params = { 54 | 'device_platform': 'webapp', 55 | 'aid': '6383', 56 | 'channel': 'channel_pc_web', 57 | 'sec_user_id': sec_user_id, 58 | 'max_cursor': self.cursor, 59 | 'locate_query': 'false', 60 | 'show_live_replay_strategy': '1', 61 | 'need_time_list': '0' if self.cursor else '1', 62 | 'time_list_query': '0', 63 | 'whale_cut_token': '', 64 | 'cut_version': '1', 65 | 'count': '18', 66 | 'publish_video_strategy_type': '2', 67 | 'pc_client_type': '1', 68 | 'version_code': '170400', 69 | 'version_name': '17.4.0', 70 | 'cookie_enabled': 'true', 71 | 'platform': 'PC', 72 | 'downlink': '10', 73 | } 74 | self._deal_url_params(params) 75 | if not (data := self._send_get(params=params)): 76 | print(f'[{YELLOW}]获取账号作品数据失败') 77 | self.finished = True 78 | else: 79 | try: 80 | if (items_page := data['aweme_list']) is None: 81 | print(f'[{YELLOW}]该账号为私密账号,需要使用登录后的 Cookie,且登录的账号需要关注该私密账号') 82 | self.finished = True 83 | else: 84 | self.cursor = data['max_cursor'] 85 | self.finished = not data['has_more'] 86 | return items_page or [None] 87 | except KeyError: 88 | print(f'[{YELLOW}]账号作品数据响应内容异常: {data}') 89 | self.finished = True 90 | 91 | def _send_get(self, params): 92 | '''返回 json 格式数据''' 93 | try: 94 | response = get( 95 | self.post_api, 96 | params=params, 97 | timeout=TIMEOUT, 98 | headers=self.settings.headers) 99 | self._wait() 100 | except ( 101 | exceptions.ProxyError, 102 | exceptions.SSLError, 103 | exceptions.ChunkedEncodingError, 104 | exceptions.ConnectionError, 105 | ): 106 | print(f'[{YELLOW}]网络异常,请求 {self.post_api}?{urlencode(params)} 失败') 107 | return 108 | except exceptions.ReadTimeout: 109 | print(f'[{YELLOW}]网络异常,请求 {self.post_api}?{urlencode(params)} 超时') 110 | return 111 | try: 112 | return response.json() 113 | except exceptions.JSONDecodeError: 114 | if response.text: 115 | print(f'[{YELLOW}]响应内容不是有效的 JSON 格式:{response.text}') 116 | else: 117 | print(f'[{YELLOW}]响应内容为空,可能是接口失效或者 Cookie 失效,请尝试更新 Cookie') 118 | 119 | @staticmethod 120 | def _wait(): 121 | sleep(randint(15, 45)/10) 122 | 123 | def _deal_url_params(self, params: dict, number: int = 8): 124 | '''添加 msToken、X-Bogus''' 125 | if 'msToken' in self.settings.cookies: 126 | params['msToken'] = self.settings.cookies['msToken'] 127 | params['a_bogus'] = get_a_bogus(params) 128 | 129 | def _early_stop(self, earliest: date): 130 | '''如果获取数据的发布日期已经早于限制日期,就不需要再获取下一页的数据了''' 131 | if earliest > date.fromtimestamp(self.cursor / 1000): 132 | self.finished = True 133 | -------------------------------------------------------------------------------- /src/download/download.py: -------------------------------------------------------------------------------- 1 | from os import makedirs 2 | from os.path import join as join_path, exists 3 | from rich.progress import ( 4 | SpinnerColumn, 5 | BarColumn, 6 | DownloadColumn, 7 | Progress, 8 | TextColumn, 9 | TimeRemainingColumn, 10 | ) 11 | from rich import print 12 | from yarl import URL 13 | from asyncio import Semaphore, gather, run, create_task, TimeoutError 14 | from aiohttp import ClientSession, ClientResponse, ClientTimeout 15 | 16 | from ..config import ( 17 | GREEN, CYAN, YELLOW, MAGENTA, 18 | CHUNK, TIMEOUT, CONCURRENCY 19 | ) 20 | from ..config import Settings, Cookie 21 | from ..tool import Cleaner, retry_async 22 | from ..backup import DownloadRecorder 23 | 24 | 25 | class Download: 26 | def __init__(self, settings: Settings, cleaner: Cleaner, cookie: Cookie, 27 | download_recorder: DownloadRecorder): 28 | self.download_recorder = download_recorder 29 | self.settings = settings 30 | self.cleaner = cleaner 31 | self.cookie = cookie 32 | 33 | def download_files(self, items: list[dict], account_id: str, account_mark: str): 34 | '''下载作品文件''' 35 | print(f'[{CYAN}]\n开始下载作品文件\n') 36 | save_folder = self._create_save_folder(account_id, account_mark) 37 | tasks_info = self._generate_task(items, save_folder) 38 | with self._progress_object() as progress: 39 | run(self._download_files(tasks_info, progress)) 40 | 41 | async def _download_file(self, task_info: tuple, progress: Progress, sem: Semaphore): 42 | await self._request_file(*task_info, progress, sem) 43 | 44 | async def _download_files(self, tasks_info: list, progress: Progress): 45 | sem = Semaphore(CONCURRENCY) 46 | tasks = [] 47 | for task_info in tasks_info: 48 | task = create_task(self._download_file(task_info, progress, sem)) 49 | tasks.append(task) 50 | await gather(*tasks) 51 | 52 | def _generate_task(self, items: list[dict], save_folder: str): 53 | '''生成下载任务信息列表并返回''' 54 | tasks = [] 55 | for item in items: 56 | id = item['id'] 57 | desc = item['desc'] 58 | name = self.cleaner.filter_name(self.settings.split.join( 59 | item[key] for key in self.settings.name_format)) 60 | format = item.get('format') # 图片任务解析时没有提取字段,在下面直接设置为 .jpeg 61 | if (type := item['type']) == '图集': 62 | for index, info in enumerate(item['downloads'], start=1): 63 | if (task := self._generate_task_image(id, desc, name, index, info[0], info[1], info[2], save_folder)) is not None: 64 | tasks.append(task) 65 | elif type == '视频': 66 | url = item['downloads'] 67 | width = item['width'] 68 | height = item['height'] 69 | if (task := self._generate_task_video(id, desc, name, format, url, width, height, save_folder)) is not None: 70 | tasks.append(task) 71 | return tasks 72 | 73 | def _generate_task_image(self, id: str, desc: str, name: str, index: int, url: str, width: int, height: int, save_folder: str): 74 | '''生成图片下载任务信息''' 75 | show = f'图集 {id} {desc[:15]}' 76 | if id in self.download_recorder.records: 77 | print(f'[{CYAN}]{show} 存在下载记录,跳过下载') 78 | elif exists(path := join_path(save_folder, f'{name}_{index}.jpeg')): 79 | print(f'[{CYAN}]{show} 文件已存在,跳过下载') 80 | else: 81 | return (url, path, show, id, width, height) 82 | 83 | def _generate_task_video(self, id: str, desc: str, name: str, format: str, url: str, width: int, height: int, save_folder: str): 84 | '''生成视频下载任务信息''' 85 | show = f'视频 {id} {desc[:15]}' 86 | if id in self.download_recorder.records: 87 | print(f'[{CYAN}]{show} 存在下载记录,跳过下载') 88 | elif exists(path := join_path(save_folder, name+format)): 89 | print(f'[{CYAN}]{show} 文件已存在,跳过下载') 90 | else: 91 | return (url, path, show, id, width, height) 92 | 93 | @retry_async 94 | async def _request_file(self, url: str, path: str, show: str, id: str, width: int, height: int, 95 | progress: Progress, sem: Semaphore): 96 | '''下载 url 对应文件''' 97 | async with sem: 98 | try: 99 | async with ClientSession(headers=self.settings.headers, timeout=ClientTimeout(TIMEOUT)) as session: 100 | async with session.get(URL(url, encoded=True)) as response: 101 | if not (content_length := int(response.headers.get('content-length', 0))): 102 | print(f'[{YELLOW}]{show} {url} 响应内容为空') 103 | elif response.status != 200 and response.status != 206: 104 | print(f'[{YELLOW}]{show} {url} 响应状态码异常 {response.status}') 105 | else: 106 | await self._save_file(path, show, id, width, height, response, content_length, progress) 107 | return True 108 | except TimeoutError: 109 | print(f'[{YELLOW}]{show} {url} 响应超时') 110 | 111 | async def _save_file(self, path: str, show: str, id: str, width: int, height: int, 112 | response: ClientResponse, content_length: int, progress: Progress): 113 | task_id = progress.add_task(show, total=content_length or None) 114 | with open(path, 'wb') as f: 115 | async for chunk in response.content.iter_chunked(CHUNK): 116 | f.write(chunk) 117 | progress.update(task_id, advance=len(chunk)) 118 | progress.remove_task(task_id) 119 | if max(width, height) < 1920: 120 | color = YELLOW 121 | else: 122 | color = GREEN 123 | print(f'[{GREEN}]{show} [{color}]清晰度:{width}×{height} [{GREEN}]下载成功') 124 | self.download_recorder.save(id) 125 | 126 | def _progress_object(self): 127 | return Progress( 128 | TextColumn('[progress.description]{task.description}', style=MAGENTA, justify='left'), 129 | SpinnerColumn(), 130 | BarColumn(bar_width=20), 131 | '[progress.percentage]{task.percentage:>3.1f}%', 132 | '•', 133 | DownloadColumn(binary_units=True), 134 | '•', 135 | TimeRemainingColumn(), 136 | transient=True, 137 | ) 138 | 139 | def _create_save_folder(self, id: str, mark: str): 140 | '''新建存储文件夹,返回文件夹路径''' 141 | folder = join_path(self.settings.save_folder, f'UID{id}_{mark}_发布作品') 142 | makedirs(folder, exist_ok=True) 143 | return folder 144 | -------------------------------------------------------------------------------- /src/download/parse.py: -------------------------------------------------------------------------------- 1 | from datetime import date 2 | 3 | from ..tool import Cleaner 4 | from ..config import Settings, DESCRIPTION_LENGTH 5 | 6 | 7 | class Parse: 8 | def __init__(self, cleaner: Cleaner, settings: Settings) -> None: 9 | self.cleaner = cleaner 10 | self.settings = settings 11 | 12 | def extract_account(self, account: dict, item: dict): 13 | '''提取账号 id、昵称,检查账号 mark''' 14 | account['id'] = self._extract_value(item, 'author.uid') 15 | account['name'] = self.cleaner.filter_name( 16 | self._extract_value(item, 'author.nickname'), 17 | default='无效账号昵称') 18 | account['mark'] = self.cleaner.filter_name( 19 | account['mark'], default=account['name']) 20 | 21 | def extract_items(self, items: list[dict], earliest: date, latest: date): 22 | '''提取发布作品信息并返回''' 23 | results = [] 24 | for item in items: 25 | result = {} 26 | self._extract_common(item, result) 27 | if (result['create_time_date'] <= latest) and (result['create_time_date'] >= earliest): 28 | if (gallery := self._extract_value(item, 'images')): 29 | if self.settings.download_images: 30 | self._extract_gallery(gallery, result) 31 | results.append(result) 32 | elif self.settings.download_videos: 33 | self._extract_video(self._extract_value(item, 'video'), result) 34 | results.append(result) 35 | return results 36 | 37 | def _extract_common(self, item: dict, result: dict): 38 | '''提取图文/视频作品共有信息''' 39 | result['id'] = self._extract_value(item, 'aweme_id') 40 | if desc:=self._extract_value(item, 'desc'): 41 | result['desc'] = self.cleaner.clear_spaces(self.cleaner.filter_name(desc))[:DESCRIPTION_LENGTH] 42 | else: 43 | result['desc'] = '作品描述为空' 44 | result['create_timestamp'] = self._extract_value(item, 'create_time') 45 | result['create_time_date'] = date.fromtimestamp(int(result['create_timestamp'])) 46 | result['create_time'] = date.strftime(result['create_time_date'], self.settings.date_format) 47 | 48 | def _extract_gallery(self, gallery: dict, result: dict): 49 | '''提取图文作品信息''' 50 | result['type'] = '图集' 51 | result['share_url'] = f'https://www.douyin.com/note/{result["id"]}' 52 | result['downloads'] = [] 53 | for image in gallery: 54 | url = self._extract_value(image, 'url_list[0]') 55 | width = self._extract_value(image, 'width') 56 | height = self._extract_value(image, 'height') 57 | result['downloads'].append((url, width, height)) 58 | 59 | def _extract_video(self, video: dict, result: dict): 60 | '''提取视频作品信息''' 61 | result['type'] = '视频' 62 | result['format'] = '.'+self._extract_value(video, 'format') 63 | result['share_url'] = f'https://www.douyin.com/video/{result["id"]}' 64 | result['downloads'] = self._extract_value( 65 | video, 'play_addr.url_list[0]') 66 | result['height'] = self._extract_value(video, 'height') 67 | result['width'] = self._extract_value(video, 'width') 68 | 69 | @staticmethod 70 | def _extract_value(data: dict, attribute_chain: str): 71 | '''根据 attribute_chain 从 dict 中提取值''' 72 | attributes = attribute_chain.split('.') 73 | for attribute in attributes: 74 | if '[' in attribute: 75 | parts = attribute.split('[', 1) 76 | attribute = parts[0] 77 | index = int(parts[1].split(']', 1)[0]) 78 | data = data[attribute][index] 79 | else: 80 | data = data[attribute] 81 | if not data: 82 | return 83 | return data 84 | -------------------------------------------------------------------------------- /src/encrypt_params/__init__.py: -------------------------------------------------------------------------------- 1 | from .msToken import MsToken 2 | from .ttWid import TtWid 3 | from .verifyfp import VerifyFp 4 | from .webid import WebID 5 | from .js_port import get_a_bogus -------------------------------------------------------------------------------- /src/encrypt_params/a_bogus.js: -------------------------------------------------------------------------------- 1 | // All the content in this article is only for learning and communication use, not for any other purpose, strictly prohibited for commercial use and illegal use, otherwise all the consequences are irrelevant to the author! 2 | function rc4_encrypt(plaintext, key) { 3 | var s = []; 4 | for (var i = 0; i < 256; i++) { 5 | s[i] = i; 6 | } 7 | var j = 0; 8 | for (var i = 0; i < 256; i++) { 9 | j = (j + s[i] + key.charCodeAt(i % key.length)) % 256; 10 | var temp = s[i]; 11 | s[i] = s[j]; 12 | s[j] = temp; 13 | } 14 | 15 | var i = 0; 16 | var j = 0; 17 | var cipher = []; 18 | for (var k = 0; k < plaintext.length; k++) { 19 | i = (i + 1) % 256; 20 | j = (j + s[i]) % 256; 21 | var temp = s[i]; 22 | s[i] = s[j]; 23 | s[j] = temp; 24 | var t = (s[i] + s[j]) % 256; 25 | cipher.push(String.fromCharCode(s[t] ^ plaintext.charCodeAt(k))); 26 | } 27 | return cipher.join(''); 28 | } 29 | 30 | function le(e, r) { 31 | return (e << (r %= 32) | e >>> 32 - r) >>> 0 32 | } 33 | 34 | function de(e) { 35 | return 0 <= e && e < 16 ? 2043430169 : 16 <= e && e < 64 ? 2055708042 : void console['error']("invalid j for constant Tj") 36 | } 37 | 38 | function pe(e, r, t, n) { 39 | return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | r & n | t & n) >>> 0 : (console['error']('invalid j for bool function FF'), 40 | 0) 41 | } 42 | 43 | function he(e, r, t, n) { 44 | return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | ~r & n) >>> 0 : (console['error']('invalid j for bool function GG'), 45 | 0) 46 | } 47 | 48 | function reset() { 49 | this.reg[0] = 1937774191, 50 | this.reg[1] = 1226093241, 51 | this.reg[2] = 388252375, 52 | this.reg[3] = 3666478592, 53 | this.reg[4] = 2842636476, 54 | this.reg[5] = 372324522, 55 | this.reg[6] = 3817729613, 56 | this.reg[7] = 2969243214, 57 | this["chunk"] = [], 58 | this["size"] = 0 59 | } 60 | 61 | function write(e) { 62 | var a = "string" == typeof e ? function (e) { 63 | n = encodeURIComponent(e)['replace'](/%([0-9A-F]{2})/g, (function (e, r) { 64 | return String['fromCharCode']("0x" + r) 65 | } 66 | )) 67 | , a = new Array(n['length']); 68 | return Array['prototype']['forEach']['call'](n, (function (e, r) { 69 | a[r] = e.charCodeAt(0) 70 | } 71 | )), 72 | a 73 | }(e) : e; 74 | this.size += a.length; 75 | var f = 64 - this['chunk']['length']; 76 | if (a['length'] < f) 77 | this['chunk'] = this['chunk'].concat(a); 78 | else 79 | for (this['chunk'] = this['chunk'].concat(a.slice(0, f)); this['chunk'].length >= 64;) 80 | this['_compress'](this['chunk']), 81 | f < a['length'] ? this['chunk'] = a['slice'](f, Math['min'](f + 64, a['length'])) : this['chunk'] = [], 82 | f += 64 83 | } 84 | 85 | function sum(e, t) { 86 | e && (this['reset'](), 87 | this['write'](e)), 88 | this['_fill'](); 89 | for (var f = 0; f < this.chunk['length']; f += 64) 90 | this._compress(this['chunk']['slice'](f, f + 64)); 91 | var i = null; 92 | if (t == 'hex') { 93 | i = ""; 94 | for (f = 0; f < 8; f++) 95 | i += se(this['reg'][f]['toString'](16), 8, "0") 96 | } else 97 | for (i = new Array(32), 98 | f = 0; f < 8; f++) { 99 | var c = this.reg[f]; 100 | i[4 * f + 3] = (255 & c) >>> 0, 101 | c >>>= 8, 102 | i[4 * f + 2] = (255 & c) >>> 0, 103 | c >>>= 8, 104 | i[4 * f + 1] = (255 & c) >>> 0, 105 | c >>>= 8, 106 | i[4 * f] = (255 & c) >>> 0 107 | } 108 | return this['reset'](), 109 | i 110 | } 111 | 112 | function _compress(t) { 113 | if (t < 64) 114 | console.error("compress error: not enough data"); 115 | else { 116 | for (var f = function (e) { 117 | for (var r = new Array(132), t = 0; t < 16; t++) 118 | r[t] = e[4 * t] << 24, 119 | r[t] |= e[4 * t + 1] << 16, 120 | r[t] |= e[4 * t + 2] << 8, 121 | r[t] |= e[4 * t + 3], 122 | r[t] >>>= 0; 123 | for (var n = 16; n < 68; n++) { 124 | var a = r[n - 16] ^ r[n - 9] ^ le(r[n - 3], 15); 125 | a = a ^ le(a, 15) ^ le(a, 23), 126 | r[n] = (a ^ le(r[n - 13], 7) ^ r[n - 6]) >>> 0 127 | } 128 | for (n = 0; n < 64; n++) 129 | r[n + 68] = (r[n] ^ r[n + 4]) >>> 0; 130 | return r 131 | }(t), i = this['reg'].slice(0), c = 0; c < 64; c++) { 132 | var o = le(i[0], 12) + i[4] + le(de(c), c) 133 | , s = ((o = le(o = (4294967295 & o) >>> 0, 7)) ^ le(i[0], 12)) >>> 0 134 | , u = pe(c, i[0], i[1], i[2]); 135 | u = (4294967295 & (u = u + i[3] + s + f[c + 68])) >>> 0; 136 | var b = he(c, i[4], i[5], i[6]); 137 | b = (4294967295 & (b = b + i[7] + o + f[c])) >>> 0, 138 | i[3] = i[2], 139 | i[2] = le(i[1], 9), 140 | i[1] = i[0], 141 | i[0] = u, 142 | i[7] = i[6], 143 | i[6] = le(i[5], 19), 144 | i[5] = i[4], 145 | i[4] = (b ^ le(b, 9) ^ le(b, 17)) >>> 0 146 | } 147 | for (var l = 0; l < 8; l++) 148 | this['reg'][l] = (this['reg'][l] ^ i[l]) >>> 0 149 | } 150 | } 151 | 152 | function _fill() { 153 | var a = 8 * this['size'] 154 | , f = this['chunk']['push'](128) % 64; 155 | for (64 - f < 8 && (f -= 64); f < 56; f++) 156 | this.chunk['push'](0); 157 | for (var i = 0; i < 4; i++) { 158 | var c = Math['floor'](a / 4294967296); 159 | this['chunk'].push(c >>> 8 * (3 - i) & 255) 160 | } 161 | for (i = 0; i < 4; i++) 162 | this['chunk']['push'](a >>> 8 * (3 - i) & 255) 163 | 164 | } 165 | 166 | function SM3() { 167 | this.reg = []; 168 | this.chunk = []; 169 | this.size = 0; 170 | this.reset() 171 | } 172 | SM3.prototype.reset = reset; 173 | SM3.prototype.write = write; 174 | SM3.prototype.sum = sum; 175 | SM3.prototype._compress = _compress; 176 | SM3.prototype._fill = _fill; 177 | 178 | function result_encrypt(long_str, num = null) { 179 | let s_obj = { 180 | "s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=", 181 | "s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=", 182 | "s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=", 183 | "s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe", 184 | "s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe" 185 | } 186 | let constant = { 187 | "0": 16515072, 188 | "1": 258048, 189 | "2": 4032, 190 | "str": s_obj[num], 191 | } 192 | 193 | let result = ""; 194 | let lound = 0; 195 | let long_int = get_long_int(lound, long_str); 196 | for (let i = 0; i < long_str.length / 3 * 4; i++) { 197 | if (Math.floor(i / 4) !== lound) { 198 | lound += 1; 199 | long_int = get_long_int(lound, long_str); 200 | } 201 | let key = i % 4; 202 | switch (key) { 203 | case 0: 204 | temp_int = (long_int & constant["0"]) >> 18; 205 | result += constant["str"].charAt(temp_int); 206 | break; 207 | case 1: 208 | temp_int = (long_int & constant["1"]) >> 12; 209 | result += constant["str"].charAt(temp_int); 210 | break; 211 | case 2: 212 | temp_int = (long_int & constant["2"]) >> 6; 213 | result += constant["str"].charAt(temp_int); 214 | break; 215 | case 3: 216 | temp_int = long_int & 63; 217 | result += constant["str"].charAt(temp_int); 218 | break; 219 | default: 220 | break; 221 | } 222 | } 223 | return result; 224 | } 225 | 226 | function get_long_int(round, long_str) { 227 | round = round * 3; 228 | return (long_str.charCodeAt(round) << 16) | (long_str.charCodeAt(round + 1) << 8) | (long_str.charCodeAt(round + 2)); 229 | } 230 | 231 | function gener_random(random, option) { 232 | return [ 233 | (random & 255 & 170) | option[0] & 85, // 163 234 | (random & 255 & 85) | option[0] & 170, //87 235 | (random >> 8 & 255 & 170) | option[1] & 85, //37 236 | (random >> 8 & 255 & 85) | option[1] & 170, //41 237 | ] 238 | } 239 | 240 | ////////////////////////////////////////////// 241 | function generate_rc4_bb_str(url_search_params, user_agent, window_env_str, suffix = "cus", Arguments = [0, 1, 14]) { 242 | let sm3 = new SM3() 243 | let start_time = Date.now() 244 | /** 245 | * 进行3次加密处理 246 | * 1: url_search_params两次sm3之的结果 247 | * 2: 对后缀两次sm3之的结果 248 | * 3: 对ua处理之后的结果 249 | */ 250 | // url_search_params两次sm3之的结果 251 | let url_search_params_list = sm3.sum(sm3.sum(url_search_params + suffix)) 252 | // 对后缀两次sm3之的结果 253 | let cus = sm3.sum(sm3.sum(suffix)) 254 | // 对ua处理之后的结果 255 | let ua = sm3.sum(result_encrypt(rc4_encrypt(user_agent, String.fromCharCode.apply(null, [0.00390625, 1, 14])), "s3")) 256 | // 257 | let end_time = Date.now() 258 | // b 259 | let b = { 260 | 8: 3, // 固定 261 | 10: end_time, //3次加密结束时间 262 | 15: { 263 | "aid": 6383, 264 | "pageId": 6241, 265 | "boe": false, 266 | "ddrt": 7, 267 | "paths": { 268 | "include": [ 269 | {}, 270 | {}, 271 | {}, 272 | {}, 273 | {}, 274 | {}, 275 | {} 276 | ], 277 | "exclude": [] 278 | }, 279 | "track": { 280 | "mode": 0, 281 | "delay": 300, 282 | "paths": [] 283 | }, 284 | "dump": true, 285 | "rpU": "" 286 | }, 287 | 16: start_time, //3次加密开始时间 288 | 18: 44, //固定 289 | 19: [1, 0, 1, 5], 290 | } 291 | 292 | //3次加密开始时间 293 | b[20] = (b[16] >> 24) & 255 294 | b[21] = (b[16] >> 16) & 255 295 | b[22] = (b[16] >> 8) & 255 296 | b[23] = b[16] & 255 297 | b[24] = (b[16] / 256 / 256 / 256 / 256) >> 0 298 | b[25] = (b[16] / 256 / 256 / 256 / 256 / 256) >> 0 299 | 300 | // 参数Arguments [0, 1, 14, ...] 301 | // let Arguments = [0, 1, 14] 302 | b[26] = (Arguments[0] >> 24) & 255 303 | b[27] = (Arguments[0] >> 16) & 255 304 | b[28] = (Arguments[0] >> 8) & 255 305 | b[29] = Arguments[0] & 255 306 | 307 | b[30] = (Arguments[1] / 256) & 255 308 | b[31] = (Arguments[1] % 256) & 255 309 | b[32] = (Arguments[1] >> 24) & 255 310 | b[33] = (Arguments[1] >> 16) & 255 311 | 312 | b[34] = (Arguments[2] >> 24) & 255 313 | b[35] = (Arguments[2] >> 16) & 255 314 | b[36] = (Arguments[2] >> 8) & 255 315 | b[37] = Arguments[2] & 255 316 | 317 | // (url_search_params + "cus") 两次sm3之的结果 318 | /**let url_search_params_list = [ 319 | 91, 186, 35, 86, 143, 253, 6, 76, 320 | 34, 21, 167, 148, 7, 42, 192, 219, 321 | 188, 20, 182, 85, 213, 74, 213, 147, 322 | 37, 155, 93, 139, 85, 118, 228, 213 323 | ]*/ 324 | b[38] = url_search_params_list[21] 325 | b[39] = url_search_params_list[22] 326 | 327 | // ("cus") 对后缀两次sm3之的结果 328 | /** 329 | * let cus = [ 330 | 136, 101, 114, 147, 58, 77, 207, 201, 331 | 215, 162, 154, 93, 248, 13, 142, 160, 332 | 105, 73, 215, 241, 83, 58, 51, 43, 333 | 255, 38, 168, 141, 216, 194, 35, 236 334 | ]*/ 335 | b[40] = cus[21] 336 | b[41] = cus[22] 337 | 338 | // 对ua处理之后的结果 339 | /** 340 | * let ua = [ 341 | 129, 190, 70, 186, 86, 196, 199, 53, 342 | 99, 38, 29, 209, 243, 17, 157, 69, 343 | 147, 104, 53, 23, 114, 126, 66, 228, 344 | 135, 30, 168, 185, 109, 156, 251, 88 345 | ]*/ 346 | b[42] = ua[23] 347 | b[43] = ua[24] 348 | 349 | //3次加密结束时间 350 | b[44] = (b[10] >> 24) & 255 351 | b[45] = (b[10] >> 16) & 255 352 | b[46] = (b[10] >> 8) & 255 353 | b[47] = b[10] & 255 354 | b[48] = b[8] 355 | b[49] = (b[10] / 256 / 256 / 256 / 256) >> 0 356 | b[50] = (b[10] / 256 / 256 / 256 / 256 / 256) >> 0 357 | 358 | 359 | // object配置项 360 | b[51] = b[15]['pageId'] 361 | b[52] = (b[15]['pageId'] >> 24) & 255 362 | b[53] = (b[15]['pageId'] >> 16) & 255 363 | b[54] = (b[15]['pageId'] >> 8) & 255 364 | b[55] = b[15]['pageId'] & 255 365 | 366 | b[56] = b[15]['aid'] 367 | b[57] = b[15]['aid'] & 255 368 | b[58] = (b[15]['aid'] >> 8) & 255 369 | b[59] = (b[15]['aid'] >> 16) & 255 370 | b[60] = (b[15]['aid'] >> 24) & 255 371 | 372 | // 中间进行了环境检测 373 | // 代码索引: 2496 索引值: 17 (索引64关键条件) 374 | // '1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32'.charCodeAt()得到65位数组 375 | /** 376 | * let window_env_list = [49, 53, 51, 54, 124, 55, 52, 55, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 48, 124, 51, 377 | * 48, 124, 48, 124, 48, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 49, 53, 51, 54, 124, 56, 378 | * 54, 52, 124, 49, 53, 50, 53, 124, 55, 52, 55, 124, 50, 52, 124, 50, 52, 124, 87, 105, 110, 379 | * 51, 50] 380 | */ 381 | let window_env_list = []; 382 | for (let index = 0; index < window_env_str.length; index++) { 383 | window_env_list.push(window_env_str.charCodeAt(index)) 384 | } 385 | b[64] = window_env_list.length 386 | b[65] = b[64] & 255 387 | b[66] = (b[64] >> 8) & 255 388 | 389 | b[69] = [].length 390 | b[70] = b[69] & 255 391 | b[71] = (b[69] >> 8) & 255 392 | 393 | b[72] = b[18] ^ b[20] ^ b[26] ^ b[30] ^ b[38] ^ b[40] ^ b[42] ^ b[21] ^ b[27] ^ b[31] ^ b[35] ^ b[39] ^ b[41] ^ b[43] ^ b[22] ^ 394 | b[28] ^ b[32] ^ b[36] ^ b[23] ^ b[29] ^ b[33] ^ b[37] ^ b[44] ^ b[45] ^ b[46] ^ b[47] ^ b[48] ^ b[49] ^ b[50] ^ b[24] ^ 395 | b[25] ^ b[52] ^ b[53] ^ b[54] ^ b[55] ^ b[57] ^ b[58] ^ b[59] ^ b[60] ^ b[65] ^ b[66] ^ b[70] ^ b[71] 396 | let bb = [ 397 | b[18], b[20], b[52], b[26], b[30], b[34], b[58], b[38], b[40], b[53], b[42], b[21], b[27], b[54], b[55], b[31], 398 | b[35], b[57], b[39], b[41], b[43], b[22], b[28], b[32], b[60], b[36], b[23], b[29], b[33], b[37], b[44], b[45], 399 | b[59], b[46], b[47], b[48], b[49], b[50], b[24], b[25], b[65], b[66], b[70], b[71] 400 | ] 401 | bb = bb.concat(window_env_list).concat(b[72]) 402 | return rc4_encrypt(String.fromCharCode.apply(null, bb), String.fromCharCode.apply(null, [121])); 403 | } 404 | 405 | function generate_random_str() { 406 | let random_str_list = [] 407 | random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [3, 45])) 408 | random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 0])) 409 | random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 5])) 410 | return String.fromCharCode.apply(null, random_str_list) 411 | } 412 | 413 | function generate_a_bogus(url_search_params, user_agent) { 414 | /** 415 | * url_search_params:"device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR" 416 | * user_agent:"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" 417 | */ 418 | let result_str = generate_random_str() + generate_rc4_bb_str( 419 | url_search_params, 420 | user_agent, 421 | "1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32" 422 | ); 423 | return result_encrypt(result_str, "s4") + "="; 424 | } 425 | 426 | //测试调用 427 | // console.log(generate_a_bogus( 428 | // "device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR", 429 | // "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36" 430 | // )); -------------------------------------------------------------------------------- /src/encrypt_params/a_bogus_RPC/RPC.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | 4 | parameter = "device_platform=webapp&aid=6383&channel=channel_pc_web&..." 5 | url = "http://127.0.0.1:12080/go" 6 | data = { 7 | "group": "zzz", 8 | "action": "getData", 9 | "param": json.dumps( 10 | {"parameter": parameter} 11 | ) 12 | } 13 | 14 | res = requests.post(url, data=data) 15 | print(res.text) 16 | -------------------------------------------------------------------------------- /src/encrypt_params/a_bogus_RPC/env.js: -------------------------------------------------------------------------------- 1 | function Hlclient(wsURL) { 2 | this.wsURL = wsURL; 3 | this.handlers = { 4 | _execjs: function (resolve, param) { 5 | var res = eval(param); 6 | if (!res) { 7 | resolve("没有返回值"); 8 | } else { 9 | resolve(res); 10 | } 11 | }, 12 | }; 13 | this.socket = undefined; 14 | if (!wsURL) { 15 | throw new Error("wsURL can not be empty!!"); 16 | } 17 | this.connect(); 18 | } 19 | 20 | Hlclient.prototype.connect = function () { 21 | console.log("begin of connect to wsURL: " + this.wsURL); 22 | var _this = this; 23 | try { 24 | this.socket = new WebSocket(this.wsURL); 25 | this.socket.onmessage = function (e) { 26 | _this.handlerRequest(e.data); 27 | }; 28 | } catch (e) { 29 | console.log("connection failed,reconnect after 10s"); 30 | setTimeout(function () { 31 | _this.connect(); 32 | }, 10000); 33 | } 34 | this.socket.onclose = function () { 35 | console.log("rpc已关闭"); 36 | setTimeout(function () { 37 | _this.connect(); 38 | }, 10000); 39 | }; 40 | this.socket.addEventListener("open", (event) => { 41 | console.log("rpc连接成功"); 42 | }); 43 | this.socket.addEventListener("error", (event) => { 44 | console.error("rpc连接出错,请检查是否打开服务端:", event.error); 45 | }); 46 | }; 47 | Hlclient.prototype.send = function (msg) { 48 | this.socket.send(msg); 49 | }; 50 | 51 | Hlclient.prototype.regAction = function (func_name, func) { 52 | if (typeof func_name !== "string") { 53 | throw new Error("an func_name must be string"); 54 | } 55 | if (typeof func !== "function") { 56 | throw new Error("must be function"); 57 | } 58 | console.log("register func_name: " + func_name); 59 | this.handlers[func_name] = func; 60 | return true; 61 | }; 62 | 63 | //收到消息后这里处理, 64 | Hlclient.prototype.handlerRequest = function (requestJson) { 65 | var _this = this; 66 | try { 67 | var result = JSON.parse(requestJson); 68 | } catch (error) { 69 | console.log("catch error", requestJson); 70 | result = transjson(requestJson); 71 | } 72 | //console.log(result) 73 | if (!result["action"]) { 74 | this.sendResult("", "need request param {action}"); 75 | return; 76 | } 77 | var action = result["action"]; 78 | var theHandler = this.handlers[action]; 79 | if (!theHandler) { 80 | this.sendResult(action, "action not found"); 81 | return; 82 | } 83 | try { 84 | if (!result["param"]) { 85 | theHandler(function (response) { 86 | _this.sendResult(action, response); 87 | }); 88 | return; 89 | } 90 | var param = result["param"]; 91 | try { 92 | param = JSON.parse(param); 93 | } catch (e) {} 94 | theHandler(function (response) { 95 | _this.sendResult(action, response); 96 | }, param); 97 | } catch (e) { 98 | console.log("error: " + e); 99 | _this.sendResult(action, e); 100 | } 101 | }; 102 | 103 | Hlclient.prototype.sendResult = function (action, e) { 104 | if (typeof e === "object" && e !== null) { 105 | try { 106 | e = JSON.stringify(e); 107 | } catch (v) { 108 | console.log(v); //不是json无需操作 109 | } 110 | } 111 | this.send(action + atob("aGxeX14") + e); 112 | }; 113 | 114 | function transjson(formdata) { 115 | var regex = /"action":(?.*?),/g; 116 | var actionName = regex.exec(formdata).groups.actionName; 117 | stringfystring = formdata.match(/{..data..:.*..\w+..:\s...*?..}/g).pop(); 118 | stringfystring = stringfystring.replace(/\\"/g, '"'); 119 | paramstring = JSON.parse(stringfystring); 120 | tens = `{"action":` + actionName + `,"param":{}}`; 121 | tjson = JSON.parse(tens); 122 | tjson.param = paramstring; 123 | return tjson; 124 | } 125 | 126 | // 注入环境后连接通信 127 | var demo = new Hlclient("ws://127.0.0.1:12080/ws?group=zzz"); 128 | 129 | // 注册 js 方法 130 | demo.regAction("getData", function (resolve, param) { 131 | win_u[3] = param["parameter"]; 132 | res = win_s.apply(null, win_u); 133 | resolve(res); 134 | }); 135 | -------------------------------------------------------------------------------- /src/encrypt_params/general.py: -------------------------------------------------------------------------------- 1 | from requests import ( 2 | post, 3 | exceptions 4 | ) 5 | from rich import print 6 | 7 | from ..config import USER_AGENT, RED 8 | from ..tool import retry 9 | 10 | 11 | HEADERS = {'User-Agent': USER_AGENT} 12 | 13 | 14 | 15 | @retry 16 | def send_post(url: str, headers: dict, data: str): 17 | try: 18 | return post(url, data=data, timeout=10, headers=headers) 19 | except ( 20 | exceptions.ProxyError, 21 | exceptions.SSLError, 22 | exceptions.ChunkedEncodingError, 23 | exceptions.ConnectionError, 24 | exceptions.ReadTimeout, 25 | ): 26 | return 27 | 28 | 29 | def extract_value(response_headers: dict, key: str): 30 | '''从 response_headers['Set-Cookie'] 中,提取第一个键对应的值''' 31 | set_cookie = response_headers.get('Set-Cookie') 32 | if set_cookie: 33 | try: 34 | value = set_cookie.split('; ')[0].split('=', 1) 35 | return {value[0]: value[1]} 36 | except IndexError: 37 | print(f'[{RED}]获取 {key} 参数失败!') 38 | -------------------------------------------------------------------------------- /src/encrypt_params/js_port.py: -------------------------------------------------------------------------------- 1 | from os.path import join as join_path 2 | from urllib import parse 3 | from py_mini_racer import MiniRacer 4 | 5 | from ..config import PROJECT_ROOT, USER_AGENT 6 | 7 | def get_a_bogus(query: dict): 8 | path = join_path(PROJECT_ROOT, 'src/encrypt_params/a_bogus.js') 9 | with open(path, 'r', encoding='utf-8') as f: 10 | a_bogus_js_code = f.read() 11 | a_bogus_ctx = MiniRacer() 12 | a_bogus_ctx.eval(a_bogus_js_code) 13 | query = parse.unquote(parse.urlencode(query)) 14 | a_bogus = a_bogus_ctx.call('generate_a_bogus', query, USER_AGENT) 15 | return a_bogus 16 | -------------------------------------------------------------------------------- /src/encrypt_params/msToken.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | from string import ascii_letters, digits 3 | from json import dumps 4 | from time import time 5 | 6 | 7 | from .general import send_post, extract_value 8 | from .general import HEADERS 9 | 10 | 11 | class MsToken: 12 | '''代码参考: https://github.com/Johnserf-Seed/f2/blob/main/f2/apps/douyin/utils.py''' 13 | 14 | @staticmethod 15 | def get_fake_ms_token(key='msToken', size=107): 16 | '''根据传入长度产生随机字符串''' 17 | base_str = ascii_letters + digits 18 | length = len(base_str) - 1 19 | return {key: ''.join( 20 | base_str[randint(0, length)] for _ in range(size))} 21 | 22 | @staticmethod 23 | def get_real_ms_token(): 24 | headers = HEADERS | {'Content-Type': 'text/plain;charset=UTF-8'} 25 | api = 'https://mssdk.bytedance.com/web/report' 26 | data = { 27 | 'magic': 538969122, 28 | 'version': 1, 29 | 'dataType': 8, 30 | 'strData': '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', 31 | } 32 | if response := send_post(api, headers, 33 | dumps(data | {'tspFromClient': int(time() * 1000)})): 34 | return extract_value(response.headers, 'msToken') 35 | -------------------------------------------------------------------------------- /src/encrypt_params/ttWid.py: -------------------------------------------------------------------------------- 1 | from json import dumps 2 | 3 | from .general import send_post, extract_value 4 | from .general import HEADERS 5 | 6 | 7 | class TtWid: 8 | '''代码参考: https://github.com/Johnserf-Seed/f2/blob/main/f2/apps/douyin/utils.py''' 9 | 10 | @staticmethod 11 | def get_tt_wid(): 12 | api = 'https://ttwid.bytedance.com/ttwid/union/register/' 13 | data = { 14 | 'region': 'cn', 15 | 'aid': 1768, 16 | 'needFid': 'false', 17 | 'service': 'www.ixigua.com', 18 | 'migrate_info': {'ticket': '', 'source': 'node'}, 'cbUrlProtocol': 'https', 19 | 'union': 'true' 20 | } 21 | if response := send_post(api, HEADERS, dumps(data)): 22 | return extract_value(response.headers, 'ttwid') 23 | -------------------------------------------------------------------------------- /src/encrypt_params/verifyfp.py: -------------------------------------------------------------------------------- 1 | from time import time 2 | from random import random 3 | 4 | class VerifyFp: 5 | '''代码参考: https://github.com/Johnserf-Seed/TikTokDownload/blob/main/Util/Cookies.py''' 6 | 7 | @staticmethod 8 | def get_verify_fp(): 9 | e = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz' 10 | t = len(e) 11 | milliseconds = int(round(time() * 1000)) 12 | base36 = '' 13 | while milliseconds > 0: 14 | remainder = milliseconds % 36 15 | if remainder < 10: 16 | base36 = str(remainder) + base36 17 | else: 18 | base36 = chr(ord('a') + remainder - 10) + base36 19 | milliseconds //= 36 20 | r = base36 21 | o = [''] * 36 22 | o[8] = o[13] = o[18] = o[23] = '_' 23 | o[14] = '4' 24 | 25 | for i in range(36): 26 | if not o[i]: 27 | n = 0 or int(random() * t) 28 | if i == 19: 29 | n = 3 & n | 8 30 | o[i] = e[n] 31 | return f'verify_{r}_' + ''.join(o) 32 | -------------------------------------------------------------------------------- /src/encrypt_params/webid.py: -------------------------------------------------------------------------------- 1 | from requests import exceptions 2 | from json import dumps 3 | from rich import print 4 | 5 | from .general import send_post 6 | from ..config import USER_AGENT, RED 7 | 8 | 9 | class WebID: 10 | 11 | @staticmethod 12 | def get_web_id(user_agent: str = USER_AGENT): 13 | api = 'https://mcs.zijieapi.com/webid' 14 | headers = {'User-Agent': user_agent} 15 | data = { 16 | 'app_id': 6383, 17 | 'url': 'https://www.douyin.com/', 18 | 'user_agent': '{user_agent}', 19 | 'referer': 'https://www.douyin.com/', 20 | 'user_unique_id': '' 21 | } 22 | try: 23 | if response := send_post(api, headers, dumps(data)): 24 | return response.json().get('web_id') 25 | raise KeyError 26 | except (exceptions.JSONDecodeError, KeyError): 27 | print(f'[{RED}]获取 webid 参数失败!') 28 | -------------------------------------------------------------------------------- /src/scheduler.py: -------------------------------------------------------------------------------- 1 | from os.path import ( 2 | join as join_path, 3 | exists, 4 | ) 5 | from os import makedirs 6 | from shutil import rmtree 7 | from datetime import date 8 | from rich import print 9 | from rich.prompt import Prompt 10 | from textwrap import dedent 11 | import subprocess 12 | 13 | from .config import ( 14 | PROJECT_ROOT, 15 | TEXT_REPLACEMENT, 16 | WHITE, CYAN 17 | ) 18 | from .config import Settings, Cookie 19 | from .tool import Cleaner 20 | from .download import Acquire, Download, Parse 21 | from .backup import DownloadRecorder, DownloadItems 22 | 23 | 24 | class Scheduler: 25 | def __init__(self) -> None: 26 | self.download_recorder = DownloadRecorder() 27 | self.download_items = DownloadItems() 28 | self.cleaner = Cleaner() 29 | self.settings = Settings() 30 | self.cookie = Cookie(self.settings) 31 | self.parse = Parse(self.cleaner, self.settings) 32 | self.download = Download(self.settings, self.cleaner, self.cookie, self.download_recorder) 33 | self.acquirer = Acquire(self.settings) 34 | 35 | def run(self): 36 | self.check_config() 37 | self.main_menu() 38 | self.close() 39 | 40 | def check_config(self): 41 | self.cleaner.set_rule(TEXT_REPLACEMENT) 42 | self.cache_folder = join_path(PROJECT_ROOT, 'cache') 43 | self.settings.load_settings() 44 | 45 | def main_menu(self): 46 | tips = dedent( 47 | f''' 48 | {'='*25} 49 | 1. 复制粘贴写入 Cookie 50 | 2. 修改配置文件(Linux) 51 | {'='*25} 52 | 3. 批量下载账号作品(配置文件) 53 | {'='*25} 54 | 55 | 请选择运行模式:''') 56 | while (mode := Prompt.ask(f'[{CYAN}]{tips}', choices=['q', '1', '2', '3'], default='3')) != 'q': 57 | if mode == '1': 58 | self.cookie.input_save() 59 | elif mode == '2': 60 | subprocess.run(['xdg-open', self.settings.file]) 61 | try: 62 | subprocess.run(['xdg-open', join_path(PROJECT_ROOT, '已下载账号信息.json')]) 63 | except: 64 | pass 65 | input() 66 | self.settings.load_settings() 67 | elif mode == '3': 68 | if exists(self.cache_folder): 69 | self._continue_last_download() 70 | else: 71 | makedirs(self.cache_folder) 72 | self._deal_accounts() 73 | 74 | def close(self): 75 | try: 76 | rmtree(self.cache_folder) 77 | self.download_recorder.delete() 78 | self.download_items.delete() 79 | except: 80 | pass 81 | finally: 82 | print(f'[{WHITE}]程序结束运行') 83 | 84 | def _continue_last_download(self): 85 | if input('检测到程序上次未正常退出,是否提取上次下载信息:').lower() == 'y': 86 | account, items = self.download_items.read() 87 | if account and items: 88 | self.cookie.update() 89 | self.download_recorder.read() 90 | print(f'[{CYAN}]\n开始提取上次未下载完作品数据') 91 | account_name = account['name'] 92 | account_id = account['id'] 93 | account_mark = account['mark'] 94 | print(f'[{CYAN}]账号标识:{account_mark};账号昵称:{account_name};账号 ID:{account_id}') 95 | self.download_recorder.open_() 96 | self.download.download_files(items, account_id, account_mark) 97 | self.download_recorder.f_obj.close() 98 | else: 99 | self.download_recorder.delete() 100 | self.download_items.delete() 101 | 102 | def _deal_accounts(self): 103 | accounts = self.settings.accounts 104 | print(f'[{CYAN}]共有 {len(accounts)} 个账号的作品等待下载') 105 | for num, account in enumerate(accounts, start=1): 106 | self.cookie.update() 107 | self._deal_account(num, account) 108 | 109 | def _deal_account(self, num: int, account: dict[str, str | date]): 110 | for i in ( 111 | f'\n开始处理第 {num} 个账号' if num else '开始处理账号', 112 | f'账号标识:{account["mark"] or "空"}', 113 | f'最早发布日期:{account["earliest"] or "空"},最晚发布日期:{account["latest"] or "空"}' 114 | ): 115 | print(f'[{CYAN}]{i}') 116 | items = self.acquirer.request_items(account['sec_user_id'], account['earliest_date']) 117 | if items: 118 | print(f'[{CYAN}]\n开始提取作品数据') 119 | self.parse.extract_account(account, items[0]) 120 | account_id = account['id'] 121 | account_name = account['name'] 122 | account_mark = account['mark'] 123 | print(f'[{CYAN}]账号昵称:{account_name};账号 ID:{account_id}') 124 | items = self.parse.extract_items(items, account['earliest_date'], account['latest_date']) 125 | print(f'[{CYAN}]当前账号作品数量: {len(items)}') 126 | self.download_items.save(account, items) 127 | self.download_recorder.open_() 128 | self.download.download_files(items, account_id, account_mark) 129 | self.download_recorder.f_obj.close() 130 | return True 131 | -------------------------------------------------------------------------------- /src/tool/__init__.py: -------------------------------------------------------------------------------- 1 | from .function import ( 2 | retry, retry_async 3 | ) 4 | from .cleaner import Cleaner -------------------------------------------------------------------------------- /src/tool/cleaner.py: -------------------------------------------------------------------------------- 1 | from platform import system 2 | from string import whitespace 3 | from rich import print 4 | 5 | from ..config import YELLOW 6 | 7 | 8 | class Cleaner: 9 | 10 | def __init__(self): 11 | '''替换字符串中包含的非法字符, 12 | 默认根据系统类型生成对应的非法字符集合,也可以自行设置非法字符集合''' 13 | self.rule = self.default_rule() 14 | 15 | def default_rule(self): 16 | '''根据系统类型生成默认非法字符集合''' 17 | now_system = system() 18 | if now_system in ('Windows', 'Darwin'): 19 | rule = {'/', '\\', '|', '<', '>', '\'', '\"', '?', ':', '*', '\x00'} # Windows 系统和 Mac 系统 20 | elif now_system == 'Linux': 21 | rule = {'/', '\x00'} # Linux 系统 22 | else: 23 | print(f'[{YELLOW}]不受支持的操作系统类型,可能无法正常去除非法字符!') 24 | rule = set() 25 | return rule | {i for i in whitespace[1:]} # 补充换行符等非法字符 26 | 27 | def set_rule(self, rule: set, update=True): 28 | '''设置非法字符集合 29 | update: 如果是 True,则与原有规则集合合并,否则替换原有规则集合''' 30 | self.rule = self.rule | rule if update else rule 31 | 32 | def filter_name(self, text: str, inquire=False, default: str = ''): 33 | '''去除非法字符''' 34 | for i in self.rule: 35 | text = text.replace(i, ' ') 36 | text = text.strip().strip('.') 37 | 38 | if inquire: 39 | return text or self.filter_name(self.illegal_nickname()) 40 | else: 41 | return text or default 42 | 43 | @staticmethod 44 | def clear_spaces(string: str): 45 | '''将连续的空格转换为单个空格''' 46 | return ' '.join(string.split()) 47 | 48 | def illegal_nickname(self): 49 | '''当 账号昵称/标识 过滤非法字符后不是有效的文件夹名称时,如何处理异常''' 50 | return input('当前 账号昵称/标识 不是有效的文件夹名称,请输入临时的账号标识或者合集标识:') 51 | -------------------------------------------------------------------------------- /src/tool/function.py: -------------------------------------------------------------------------------- 1 | from ..config import RETRY_ACCOUNT, RETRY_FILE 2 | 3 | def retry(function): 4 | '''发生错误时尝试重新执行''' 5 | 6 | def inner(*args, **kwargs): 7 | if r := function(*args, **kwargs): 8 | return r 9 | else: 10 | for _ in range(RETRY_ACCOUNT): 11 | if r := function(*args, **kwargs): 12 | return r 13 | return r 14 | 15 | return inner 16 | 17 | def retry_async(function): 18 | '''发生错误时尝试重新执行''' 19 | 20 | async def inner(*args, **kwargs): 21 | if r := await function(*args, **kwargs): 22 | return r 23 | else: 24 | for _ in range(RETRY_FILE): 25 | if r := await function(*args, **kwargs): 26 | return r 27 | return r 28 | 29 | return inner 30 | --------------------------------------------------------------------------------