写在前面
TinyPNG-Unlimited:基于TinyPNG开发者API的图片压缩Python命令行工具。
特性:自动申请API密钥,以多线程形式批量进行TinyPNG压缩,并附带上传、下载和总体任务的进度条
本文重点分析项目的几个技术点:
- 如何实现自动化申请密钥
- 多线程中锁的使用
- 为图片添加隐藏信息作为标记
- 上传下载进度条的实现
引入
TinyPNG
是大家常用的智能有损图片压缩网站,该网站的压缩对视觉的影响几乎不可见,但是显著压缩文件体积,以节省空间储存,方便网络传输。
通过官网可以直接上传图片进行压缩,限制每次最多20张,大小不超过5M
而通过邮箱免费申请TinyPNG官方API密钥,可以获得每月500张图片的免费压缩次数(不限文件大小):申请地址
目前已有许多基于TinyPNG的压缩工具,都是利用上述方式实现图片的免费云压缩
但是如果有大量图片超过500张,这些图片体积又超出TinyPNG网页上传限制(5M),想要轻松完成压缩任务的最好方式当然是使用TinyPNG的付费服务。
而 TinyPNG-Unlimited 独辟蹊径,通过自动申请API密钥,实现了无限制的免费云压缩一条龙。
再次重申:
本项目仅供技术研究使用,请勿用于任何商业及非法用途,产生的任何后果作者概不负责!
项目概览
功能清单
- 通过多个临时邮箱自动申请TinyPNG官方API密钥,以实现无限制使用TinyPNG
- 自动切换不可用密钥(即将达到500次免费压缩的密钥)
- 多线程上传下载图片,加快批量压缩进度
- 可选使用代理上传、下载图片
- 可选递归子文件夹,可通过正则匹配需要压缩的文件名
- 可选通过配置文件批量添加图片文件名、文件夹任务列表
- 可选输出压缩日志到图片输出文件夹目录
- 显示上传、下载和总体任务的进度条
- 为每个压缩后的图片添加压缩标记字节(不影响图片内容),避免重复压缩
- 上传、下载带有超时时间
- 压缩错误自动重试,超出重试次数输出错误文件列表,下次运行时自动重新压缩
工具截图
项目难点与实现
1. 自动申请API密钥
首先,我们需要理顺一下申请流程:
- 提供邮箱,在 API申请地址 中提交申请
- 邮箱收到账号激活邮件,从中得到激活链接
- 打开激活链接,进入该账号控制台
- 在控制台中添加API密钥,将新密钥保存即可
流程理顺了,那么只需要一步一步实现即可。
提供邮箱,提交申请
普通的邮箱数量有限,自然而然想到可以使用临时邮箱,最好是提供了接口方便查收邮件的临时邮箱。在一番搜索之后,发现了Snapmail.cc 这个可以用于自动化测试的临时邮箱。提供了关键的获取指定邮箱内电子邮件的接口,非常符合我们的需求。
所以我们先进入申请地址,打开开发者工具内的网络监控,用临时邮箱提交申请。
根据抓包结果,写出申请请求代码:
with requests.Session() as session:
mail = 'abcabcasdas@snapmail.cc'
res = session.post('https://tinypng.com/web/api', json={
"fullName": mail[:mail.find('@')],
"mail": mail
})
接收邮件,提取链接
根据 Snapmail API 文档 写出获取邮件的请求代码。
url = f'emailList/{mail}'
params = {'count': 1}
mail_res = session.get('https://www.snapmail.cc/' + url.strip('/'), params=params)
在理想状态下,成功获得了激活邮件,就可以从mail_res
中提取账号激活链接
mail_res_json = mail_res.json()
match = re.search(r'(https://tinify.com/login?token=.*?api)', mail_res_json[0]['text'])
url = match.group(1)
打开链接,进入控制台
这一步主要靠抓包分析进入控制台关键的请求,然后将其请求模拟出来。
通过抓包分析,然后多次模拟请求的尝试,找到了进入控制台关键的两个请求:
session.get(url) # 打开激活链接
auth = (session.get('https://tinify.com/web/session')).json()['token'] # 获取鉴权用于后续请求
headers = {
'authorization': f"Bearer {auth}"
}
添加密钥,保存密钥
继续抓包分析,找出申请密钥和获取密钥的请求:
session.post('https://api.tinify.com/api/keys', headers=headers) # 添加新密钥
res = session.get('https://api.tinify.com/api', headers=headers) # 获取所有密钥
key = res.json()['keys'][-1]['key']
# 将key保存即可
当然,实际使用时,模拟请求并不会如此顺利。
要考虑到请求失败的情况,并在一定限度内重新发起请求,提高代码的鲁棒性。
2. 多线程压缩中的密钥切换
由于官方提供了Python库:tinify
,而且库的功能也比较完整,因此本项目基于tinify
进行云压缩。
但是tinify
是单线程压缩,速度比较慢,所以将其转化为多线程,提高批量压缩效率。
使用ThreadPoolExecutor
线程池,即可将单线程同步任务轻松转为多线程。
此处给出伪代码,有兴趣的可以打开项目源码查看。
thread_num = 4
# 4核心理论可以8线程,但是上传速度才是决速步
with ThreadPoolExecutor(thread_num) as pool:
future_list = []
for old_path in file_list:
future_list.append(pool.submit(compress_from_file, old_path, new_path))
for future in as_completed(future_list):
info = future.result()
使用线程池后就遇到了多线程中常见的资源共享问题:
tinify
设置API密钥后,所有压缩请求都会通过该API密钥发送,并且在收到响应时tinify
更新tinify.compression_count
为响应中附带的已使用压缩次数。
为了批量压缩能顺利进行,每次压缩后应当检查当前密钥是否可用,不可用则切换密钥。
(其实也可以等请求失败后判断为密钥不可用,但是都会碰到切换密钥的问题)
但是多线程情况下,当一次压缩结束,最早的线程中发现密钥即将不可用时,其他多个线程已经继续使用这个密钥进行压缩了,等到每个线程都发现密钥不可用,然后切换新密钥,就会导致密钥连续切换多次。
为了解决这个问题,我们需要使用锁
锁,形象生动地表现了它的功能。将一定资源锁定,其他程序若想访问则必须阻塞等其解锁后才能访问。
我们可以在每个线程任务中加上一段加锁的检验密钥可用性的代码:
# 伪代码
lock = RLock() # 递归锁
def check_compression_count():
"""
检测密钥是否即将限额,是则切换到下一条
"""
count = get_compression_count()
# logger.debug('当前密钥可用性: [{}/500]', count)
if count >= 490: # 即将达到限额,更换新密钥(因为处于多线程,要留好余量)
logger.warning('当前密钥即将达到限额: [{}/500], 正在切换新密钥', count)
set_next_key() # 切换到下一条密钥
refresh_compression_count() # 刷新tinify中的已使用压缩次数
def compress_from_file(old_path, new_path):
with lock: # 加锁避免多个线程同时切换密钥
check_compression_count() # 检验压缩次数是否足够,不够则切换密钥
old_key = tinify.key
res = upload_img(old_path) # 上传图片进行云压缩,得到响应会更新压缩次数
with lock: # 加锁避免多个线程同时刷新密钥次数,减少资源占用
# 因为得到响应会更新压缩次数,但此次数是旧密钥的次数
# 若别的线程切换了密钥,那么本线程的旧次数会覆盖新密钥的次数的数据
# 因此若发现密钥切换,必须重新刷新密钥次数来保证其准确性
if tinify.key != old_key:
refresh_compression_count()
download_img(res, new_path)
如此,多线程压缩切换密钥的痛难点就解决了。
3. 避免重复压缩图片
图片的重复压缩,不仅影响图片质量,而且浪费压缩时间和压缩次数。
首先想到的解决方式:在项目中记录已经压缩过的文件路径,再次遇到相同路径时不再压缩。
但是这种方式治标不治本,若出现相同路径的不同图片或者相同图片不同路径时就无能为力了。
本项目的最终解决方式为:为图片尾部添加压缩字节标记,或者可以称为图片隐写术的一种应用
原理:
一个图片文件分为多个区域,多个区域都被特定字节包裹(类似于HTML标签)。
在标签之外添加的内容并不会被解析,从而实现将隐藏信息写入图片文件。
因此,本项目将四个字节:b'tiny'
追加到下载好的压缩文件中,即为图片尾部添加压缩字节标记
对任何图片压缩前都检验文件最后四个字节是否为b'tiny'
,即可判断是否已压缩
def check_if_compressed(path) -> bool:
"""
检验图片是否被本程序标记为压缩
"""
with open(path, 'rb') as f:
f.seek(-4, 2)
return f.read(4) == b'tiny'
def add_compressed_tag(path):
"""
为图片添加压缩标记
"""
with open(tmp_path, 'ab') as f:
f.write(b'tiny')
4. 上传、下载进度条
Python中进度条可以用tqdm
库,可以在命令行界面显示简洁的进度条
而显示上传、下载的进度,最重要的一步就是为每次上传、下载传输添加回调,然后在回调中触发进度条更新。
添加回调可以使用tqdm
提供的CallbackIOWrapper
类
该类可以包裹文件对象,为文件对象的read
、write
方法添加回调
上传
tinify
中的上传无法添加timeout
参数,因此本项目重写其上传方法
上传时为需要上传的图片文件对象添加read
回调,每次上传一定数据时requests
都会调用文件对象的f.read(chunk_size)
方法,触发回调执行bar.update(chunk_size)
,实现进度条的更新
# 伪代码
with tqdm(file=sys.stdout, desc=f'[上传进度]: {file_name}', colour='green', leave=False,
total=file_size, unit="B", unit_scale=True, unit_divisor=1024) as bar:
# unit="B", unit_scale=True, unit_divisor=1024 即可适配文件传输
with open(path, "rb") as f:
# 为文件对象的read方法添加回调,回调为更新进度条
wrapped_file = CallbackIOWrapper(bar.update, f, "read")
url = upload_from_file(wrapped_file, timeout=upload_timeout)
def upload_from_file(f, timeout=60) -> str:
"""
重写库方法添加超时参数,上传图片,返回云端压缩后图片链接
"""
s: Session = tinify.get_client().session # 使用tinify的session
# timeout是服务器响应超时时间
# 注意此时间在每次服务器做出任何响应时重置,所以不是整个请求和响应的时间
res = s.post('https://api.tinify.com/shrink', data=f, timeout=timeout)
count = res.headers.get('compression-count')
tinify.compression_count = int(count)
return res.headers.get('location') # 压缩后图片的链接
下载
tinify
中的下载同样无法添加timeout
参数,而且没使用流传输,因此本项目重写其下载方法
添加进度条的方式和上传差不多,只是回调需要添加到f.write(chunk_size)
中。
当然,流传输的情况下也可以在for data iter_content(chunk_size)
迭代时直接更新进度条。
# 伪代码
def download_file(path, url, timeout=30):
"""
安全的下载文件并保存到指定路径
:param path: 路径
:param url: 图片下载链接
:param timeout: 下载超时
"""
file_name = os.path.basename(path)
if not os.path.exists(cls.tmp_dir):
os.mkdir(cls.tmp_dir)
# 若下载时直接覆盖原文件,原文件被open(path, 'w')覆盖,下载意外中断会只剩下残缺的文件
# 使用临时路径可以避免这个问题
tmp_path = os.path.abspath(os.path.join(tmp_dir, f'{file_name}_{round(time.time())}'))
# 使用流传输
res = cls._session.get(url, stream=True, timeout=timeout)
file_size = int(res.headers.get('content-length', 0))
with tqdm(file=sys.stdout, desc=f'[下载进度]: {file_name}', colour='red', ncols=120, leave=False,
ascii=' ▇', total=file_size, unit="B", unit_scale=True, unit_divisor=1024) as bar:
with open(tmp_path, 'wb') as f:
wrapped_file = CallbackIOWrapper(bar.update, f, 'write')
# 迭代响应体
for data in res.iter_content(2048):
wrapped_file.write(data)
# 当然也可以不用CallbackIOWrapper
# 直接在每次迭代中使用bar.update(2048),效果是一样的
# 移动到指定目录
move(tmp_path, path)
最后
相关链接:
本文就到此结束了,希望大家有所收获,欢迎点赞评论关注。
如果文中有不对的地方,或是大家有不同的见解,欢迎指出。
再次重申:
本项目仅供技术研究使用,请勿用于任何商业及非法用途,产生的任何后果作者概不负责!