无限制、多线程、带进度条的TinyPNG云压缩工具[Python]

1,259 阅读10分钟

写在前面

TinyPNG-Unlimited:基于TinyPNG开发者API的图片压缩Python命令行工具。

特性:自动申请API密钥,以多线程形式批量进行TinyPNG压缩,并附带上传、下载和总体任务的进度条

本文重点分析项目的几个技术点:

  1. 如何实现自动化申请密钥
  2. 多线程中锁的使用
  3. 为图片添加隐藏信息作为标记
  4. 上传下载进度条的实现

引入

TinyPNG 是大家常用的智能有损图片压缩网站,该网站的压缩对视觉的影响几乎不可见,但是显著压缩文件体积,以节省空间储存,方便网络传输。

通过官网可以直接上传图片进行压缩,限制每次最多20张,大小不超过5M

而通过邮箱免费申请TinyPNG官方API密钥,可以获得每月500张图片的免费压缩次数(不限文件大小):申请地址

目前已有许多基于TinyPNG的压缩工具,都是利用上述方式实现图片的免费云压缩

但是如果有大量图片超过500张,这些图片体积又超出TinyPNG网页上传限制(5M),想要轻松完成压缩任务的最好方式当然是使用TinyPNG的付费服务。

TinyPNG-Unlimited 独辟蹊径,通过自动申请API密钥,实现了无限制的免费云压缩一条龙。

再次重申:

本项目仅供技术研究使用,请勿用于任何商业及非法用途,产生的任何后果作者概不负责!

项目概览

功能清单

  1. 通过多个临时邮箱自动申请TinyPNG官方API密钥,以实现无限制使用TinyPNG
  2. 自动切换不可用密钥(即将达到500次免费压缩的密钥)
  3. 多线程上传下载图片,加快批量压缩进度
  4. 可选使用代理上传、下载图片
  5. 可选递归子文件夹,可通过正则匹配需要压缩的文件名
  6. 可选通过配置文件批量添加图片文件名、文件夹任务列表
  7. 可选输出压缩日志到图片输出文件夹目录
  8. 显示上传、下载和总体任务的进度条
  9. 为每个压缩后的图片添加压缩标记字节(不影响图片内容),避免重复压缩
  10. 上传、下载带有超时时间
  11. 压缩错误自动重试,超出重试次数输出错误文件列表,下次运行时自动重新压缩

工具截图

image.png

image.png

image.png

项目难点与实现

1. 自动申请API密钥

首先,我们需要理顺一下申请流程:

  1. 提供邮箱,在 API申请地址 中提交申请
  2. 邮箱收到账号激活邮件,从中得到激活链接
  3. 打开激活链接,进入该账号控制台
  4. 在控制台中添加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

该类可以包裹文件对象,为文件对象的readwrite方法添加回调

上传

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)

最后

相关链接:

  1. TinyPNG-Unlimited 项目
  2. TinyPNG API 申请
  3. Snapmail API 文档

本文就到此结束了,希望大家有所收获,欢迎点赞评论关注。

如果文中有不对的地方,或是大家有不同的见解,欢迎指出。

再次重申:

本项目仅供技术研究使用,请勿用于任何商业及非法用途,产生的任何后果作者概不负责!