发布 1.8 版本

This commit is contained in:
JoeanAmier 2024-02-19 22:09:38 +08:00
parent 2c8efd0c27
commit 18f92134d8
23 changed files with 284 additions and 78 deletions

View File

@ -25,7 +25,9 @@
<li>✅ 持久化储存作品信息至文件</li>
<li>✅ 作品文件储存至单独文件夹</li>
<li>✅ 后台监听剪贴板下载作品</li>
<li>✅ 记录已下载作品 ID</li>
<li>☑️ 支持 API 调用功能</li>
<li>☑️ 支持命令行参数下载作品文件</li>
</ul>
<ul><b>脚本功能</b>
<li>✅ 下载小红书无水印作品文件</li>
@ -38,11 +40,11 @@
</ul>
<h1>📸 程序截图</h1>
<p><b>🎥 点击图片观看演示视频</b></p>
<a href="https://www.bilibili.com/video/BV1nQ4y137it/"><img src="static/screenshot/程序运行截图CN1.png" alt=""></a>
<a href="https://www.bilibili.com/video/BV1PJ4m1Y7Jt/"><img src="static/screenshot/程序运行截图CN1.png" alt=""></a>
<hr>
<a href="https://www.bilibili.com/video/BV1nQ4y137it/"><img src="static/screenshot/程序运行截图CN2.png" alt=""></a>
<a href="https://www.bilibili.com/video/BV1PJ4m1Y7Jt/"><img src="static/screenshot/程序运行截图CN2.png" alt=""></a>
<hr>
<a href="https://www.bilibili.com/video/BV1nQ4y137it/"><img src="static/screenshot/程序运行截图CN3.png" alt=""></a>
<a href="https://www.bilibili.com/video/BV1PJ4m1Y7Jt/"><img src="static/screenshot/程序运行截图CN3.png" alt=""></a>
<h1>🔗 支持链接</h1>
<ul>
<li><code>https://www.xiaohongshu.com/explore/作品ID</code></li>
@ -74,41 +76,44 @@
<h1>💻 二次开发</h1>
<p>如果有其他需求,可以根据 <code>main.py</code> 的注释提示进行代码调用或修改!</p>
<pre>
# 示例链接
error_link = "https://github.com/JoeanAmier/XHS_Downloader"
demo_link = "https://www.xiaohongshu.com/explore/xxxxxxxxxx"
multiple_links = f"{demo_link} {demo_link} {demo_link}"
# 实例对象
work_path = "D:\\" # 作品数据/文件保存根路径,默认值:项目根路径
folder_name = "Download" # 作品文件储存文件夹名称自动创建默认值Download
user_agent = "" # 请求头 User-Agent
cookie = "" # 小红书网页版 Cookie无需登录
proxy = None # 网络代理
timeout = 5 # 请求数据超时限制单位默认值10
chunk = 1024 * 1024 * 10 # 下载文件时,每次从服务器获取的数据块大小,单位:字节
max_retry = 2 # 请求数据失败时重试的最大次数单位默认值5
record_data = False # 是否记录作品数据至文件
image_format = "WEBP" # 图文作品文件下载格式支持PNG、WEBP
folder_mode = False # 是否将每个作品的文件储存至单独的文件夹
async with XHS() as xhs:
pass # 使用默认参数
async with XHS(work_path=work_path,
folder_name=folder_name,
user_agent=user_agent,
cookie=cookie,
proxy=proxy,
timeout=timeout,
chunk=chunk,
max_retry=max_retry,
record_data=record_data,
image_format=image_format,
folder_mode=folder_mode,
) as xhs: # 使用自定义参数
download = True # 是否下载作品文件默认值False
# 返回作品详细信息,包括下载地址
print(await xhs.extract(error_link, download)) # 获取数据失败时返回空字典
print(await xhs.extract(demo_link, download))
print(await xhs.extract(multiple_links, download)) # 支持传入多个作品链接
async def example():
"""通过代码设置参数,适合二次开发"""
# 示例链接
error_link = "https://github.com/JoeanAmier/XHS_Downloader"
demo_link = "https://www.xiaohongshu.com/explore/xxxxxxxxxx"
multiple_links = f"{demo_link} {demo_link} {demo_link}"
# 实例对象
work_path = "D:\\" # 作品数据/文件保存根路径,默认值:项目根路径
folder_name = "Download" # 作品文件储存文件夹名称自动创建默认值Download
user_agent = "" # 请求头 User-Agent可选参数
cookie = "" # 小红书网页版 Cookie无需登录必需参数
proxy = None # 网络代理
timeout = 5 # 请求数据超时限制单位默认值10
chunk = 1024 * 1024 * 10 # 下载文件时,每次从服务器获取的数据块大小,单位:字节
max_retry = 2 # 请求数据失败时重试的最大次数单位默认值5
record_data = False # 是否记录作品数据至文件
image_format = "WEBP" # 图文作品文件下载格式支持PNG、WEBP
folder_mode = False # 是否将每个作品的文件储存至单独的文件夹
async with XHS() as xhs:
pass # 使用默认参数
async with XHS(work_path=work_path,
folder_name=folder_name,
user_agent=user_agent,
cookie=cookie,
proxy=proxy,
timeout=timeout,
chunk=chunk,
max_retry=max_retry,
record_data=record_data,
image_format=image_format,
folder_mode=folder_mode,
) as xhs: # 使用自定义参数
download = True # 是否下载作品文件默认值False
efficient = True # 高效模式,禁用请求延时
# 返回作品详细信息,包括下载地址
print(await xhs.extract(error_link, download, efficient)) # 获取数据失败时返回空字典
print(await xhs.extract(demo_link, download, efficient))
print(await xhs.extract(multiple_links, download, efficient)) # 支持传入多个作品链接
</pre>
<h1>⚙️ 配置文件</h1>
<p>项目根目录下的 <code>settings.json</code> 文件,首次运行自动生成,可以自定义部分运行参数。</p>
@ -199,14 +204,19 @@ async with XHS(work_path=work_path,
</table>
<h1>🌐 Cookie</h1>
<ol>
<li>打开浏览器(可选无痕模式启动),访问小红书任意网页</li>
<li><code>F12</code> 打开开发人员工具</li>
<li>选择 <code>控制台</code> 选项卡</li>
<li>输入 <code>document.cookie</code> 后回车确认</li>
<li>输出内容即为所需 Cookie</li>
<li>打开浏览器(可选无痕模式启动),访问 <code>https://www.xiaohongshu.com/explore</code></li>
<li>按下 <code>F12</code> 打开开发人员工具</li>
<li>选择 <code>网络</code> 选项卡</li>
<li>选择 <code>Fetch/XHR</code> 筛选器</li>
<li>点击小红书页面任意作品</li>
<li><code>网络</code> 选项卡挑选包含 Cookie 的数据包</li>
<li>检查 Cookie 是否包含 <code>web_session</code> 字段</li>
<li>全选复制包含 <code>web_session</code> 字段的 Cookie</li>
</ol>
<br>
<img src="static/screenshot/获取Cookie示意图.png" alt="">
<h1>🗳 下载记录</h1>
<p>XHS-Downloader 会将下载过的作品 ID 储存至数据库当重复下载相同的作品时XHS-Downloader 会自动跳过该作品的文件下载(即使作品文件不存在),如果想要重新下载作品文件,请先删除数据库中对应的作品 ID再使用 XHS-Downloader 下载作品文件!</p>
<h1>♥️ 支持项目</h1>
<p>如果 <b>XHS-Downloader</b> 对您有帮助,请考虑为它点个 <b>Star</b> ⭐,感谢您的支持!</p>
<table>
@ -230,6 +240,9 @@ async with XHS(work_path=work_path,
<li>Email: yonglelolu@gmail.com</li>
</ul>
<p>
<b>如果您在使用 XHS-Downloader 的时候遇到问题,请先阅读<a href="https://github.com/ryanhanwu/How-To-Ask-Questions-The-Smart-Way/blob/main/README-zh_CN.md">《提问的智慧》</a>,然后加入 QQ 群聊寻求帮助!</b>
</p>
<p>
<b>如果您通过 Email 联系我,我可能无法及时查看并回复信息,我会尽力在七天内回复您的邮件;如果有紧急事项或需要更快的回复,请通过其他方式与我联系,谢谢理解!</b>
</p>
<p><b>如果您对抖音 / TikTok 感兴趣,可以了解一下我的另一个开源项目 <a href="https://github.com/JoeanAmier/TikTokDownloader">TikTokDownloader</a></b></p>

11
main.py
View File

@ -13,8 +13,8 @@ async def example():
# 实例对象
work_path = "D:\\" # 作品数据/文件保存根路径,默认值:项目根路径
folder_name = "Download" # 作品文件储存文件夹名称自动创建默认值Download
user_agent = "" # 请求头 User-Agent
cookie = "" # 小红书网页版 Cookie无需登录
user_agent = "" # 请求头 User-Agent,可选参数
cookie = "" # 小红书网页版 Cookie无需登录,必需参数
proxy = None # 网络代理
timeout = 5 # 请求数据超时限制单位默认值10
chunk = 1024 * 1024 * 10 # 下载文件时,每次从服务器获取的数据块大小,单位:字节
@ -37,10 +37,11 @@ async def example():
folder_mode=folder_mode,
) as xhs: # 使用自定义参数
download = True # 是否下载作品文件默认值False
efficient = True # 高效模式,禁用请求延时
# 返回作品详细信息,包括下载地址
print(await xhs.extract(error_link, download)) # 获取数据失败时返回空字典
print(await xhs.extract(demo_link, download))
print(await xhs.extract(multiple_links, download)) # 支持传入多个作品链接
print(await xhs.extract(error_link, download, efficient)) # 获取数据失败时返回空字典
print(await xhs.extract(demo_link, download, efficient))
print(await xhs.extract(multiple_links, download, efficient)) # 支持传入多个作品链接
async def main():

View File

@ -3,4 +3,4 @@ textual>=0.47.1
pyperclip>=1.8.2
lxml>=5.1.0
PyYAML>=6.0.1
aiosqlite>=0.19.0
aiosqlite>=0.20.0

45
source/TUI/about.py Normal file
View File

@ -0,0 +1,45 @@
from textual.app import ComposeResult
from textual.binding import Binding
from textual.screen import Screen
from textual.widgets import Footer
from textual.widgets import Header
from textual.widgets import Label
from source.module import (
PROJECT,
)
from source.translator import (
Chinese,
English,
)
__all__ = ["About"]
class About(Screen):
BINDINGS = [
Binding(
key="q",
action="quit",
description="退出程序/Quit"),
Binding(
key="u",
action="check_update_about",
description="检查更新/Update"),
Binding(
key="b",
action="index",
description="返回首页/Back"),
]
def __init__(self, language: Chinese | English):
super().__init__()
self.prompt = language
def compose(self) -> ComposeResult:
yield Header()
yield Label()
yield Footer()
def on_mount(self) -> None:
self.title = PROJECT

View File

@ -13,9 +13,11 @@ from source.translator import (
Chinese,
English,
)
# from .about import About
from .index import Index
from .loading import Loading
from .monitor import Monitor
from .record import Record
from .setting import Setting
from .update import Update
@ -53,6 +55,8 @@ class XHSDownloader(App):
name="setting")
self.install_screen(Index(self.APP, self.prompt), name="index")
self.install_screen(Loading(self.prompt), name="loading")
# self.install_screen(About(self.prompt), name="about")
self.install_screen(Record(self.APP, self.prompt), name="record")
await self.push_screen("index")
async def action_settings(self):
@ -62,15 +66,26 @@ class XHSDownloader(App):
await self.push_screen("setting", save_settings)
async def action_about(self):
await self.push_screen("about")
async def action_index(self):
await self.push_screen("index")
async def action_record(self):
await self.push_screen("record")
async def refresh_screen(self):
self.pop_screen()
await self.APP.recorder.database.close()
await self.APP.close()
self.__initialization()
await self.__aenter__()
self.uninstall_screen("index")
self.uninstall_screen("setting")
self.uninstall_screen("loading")
# self.uninstall_screen("about")
self.uninstall_screen("record")
self.install_screen(Index(self.APP, self.prompt), name="index")
self.install_screen(
Setting(
@ -78,13 +93,21 @@ class XHSDownloader(App):
self.prompt),
name="setting")
self.install_screen(Loading(self.prompt), name="loading")
# self.install_screen(About(self.prompt), name="about")
self.install_screen(Record(self.APP, self.prompt), name="record")
await self.push_screen("index")
def update_result(self, tip: str) -> None:
self.query_one(RichLog).write(tip)
log = self.query_one(RichLog)
log.write(tip)
log.write(">" * 50)
async def action_check_update(self):
await self.push_screen(Update(self.APP, self.prompt), callback=self.update_result)
async def action_clipboard(self):
async def action_check_update_about(self):
await self.push_screen("index")
await self.action_check_update()
async def action_monitor(self):
await self.push_screen(Monitor(self.APP, self.prompt))

View File

@ -1,9 +1,7 @@
from asyncio import create_task
from webbrowser import open
from pyperclip import paste
from rich.text import Text
from textual import on
from textual import work
from textual.app import ComposeResult
from textual.binding import Binding
from textual.containers import HorizontalScroll
@ -26,7 +24,6 @@ from source.module import (
LICENCE,
REPOSITORY,
GENERAL,
USERSCRIPT,
)
from source.translator import (
English,
@ -40,9 +37,10 @@ class Index(Screen):
BINDINGS = [
Binding(key="q", action="quit", description="退出程序/Quit"),
Binding(key="u", action="check_update", description="检查更新/Update"),
Binding(key="m", action="user_script", description="获取脚本/Script"),
Binding(key="s", action="settings", description="程序设置/Settings"),
Binding(key="c", action="clipboard", description="监听链接/ClipBoard"),
Binding(key="r", action="record", description="下载记录/Record"),
Binding(key="m", action="monitor", description="开启监听/Monitor"),
# Binding(key="a", action="about", description="关于项目/About"),
]
def __init__(self, app: XHS, language: Chinese | English):
@ -68,7 +66,7 @@ class Index(Screen):
Label(
Text(
self.prompt.input_box_title,
style=PROMPT), id="prompt",
style=PROMPT), classes="prompt",
),
Input(placeholder=self.prompt.input_prompt),
HorizontalScroll(
@ -89,7 +87,7 @@ class Index(Screen):
@on(Button.Pressed, "#deal")
async def deal_button(self):
if self.url.value:
await create_task(self.deal())
self.deal()
else:
self.tip.write(Text(self.prompt.invalid_link, style=WARNING))
self.tip.write(Text(">" * 50, style=GENERAL))
@ -102,6 +100,7 @@ class Index(Screen):
def paste_button(self):
self.query_one(Input).value = paste()
@work()
async def deal(self):
await self.app.push_screen("loading")
if any(await self.xhs.extract(self.url.value, True, log=self.tip)):
@ -109,7 +108,3 @@ class Index(Screen):
else:
self.tip.write(Text(self.prompt.download_failure, style=ERROR))
self.app.pop_screen()
@staticmethod
def action_user_script():
open(USERSCRIPT)

View File

@ -37,7 +37,7 @@ class Monitor(Screen):
def compose(self) -> ComposeResult:
yield Header()
yield Label(Text(self.prompt.monitor_mode, style=INFO), id="monitor")
yield Label(Text(self.prompt.monitor_mode, style=INFO), classes="prompt")
yield RichLog(markup=True, wrap=True)
yield Button(self.prompt.close_monitor, id="close")
yield Footer()

46
source/TUI/record.py Normal file
View File

@ -0,0 +1,46 @@
from textual import on
from textual.app import ComposeResult
from textual.containers import Grid
from textual.containers import HorizontalScroll
from textual.screen import ModalScreen
from textual.widgets import Button
from textual.widgets import Input
from textual.widgets import Label
from source.application import XHS
from source.translator import (
Chinese,
English,
)
__all__ = ["Record"]
class Record(ModalScreen):
def __init__(self, app: XHS, language: Chinese | English):
super().__init__()
self.xhs = app
self.prompt = language
def compose(self) -> ComposeResult:
yield Grid(
Label(self.prompt.record_title, classes="prompt"),
Input(placeholder=self.prompt.record_placeholder, id="id", ),
HorizontalScroll(
Button(self.prompt.record_enter_button, id="enter", ),
Button(self.prompt.record_close_button, id="close"), ),
id="record",
)
async def delete(self, text: str):
await self.xhs.recorder.delete_many(text.split())
@on(Button.Pressed, "#enter")
async def save_settings(self):
text = self.query_one(Input)
await self.delete(text.value)
text.value = ""
@on(Button.Pressed, "#close")
def reset(self):
self.dismiss()

View File

@ -10,6 +10,7 @@ from pyperclip import paste
from source.expansion import Converter
from source.expansion import Namespace
from source.module import IDRecorder
from source.module import Manager
from source.module import (
ROOT,
@ -81,6 +82,7 @@ class XHS:
self.explore = Explore()
self.convert = Converter()
self.download = Download(self.manager)
self.recorder = IDRecorder(self.manager)
self.clipboard_cache: str = ""
self.queue = Queue()
self.event = Event()
@ -96,11 +98,19 @@ class XHS:
name = self.__naming_rules(container)
path = self.manager.folder
if (u := container["下载地址"]) and download:
path = await self.download.run(u, name, container["作品类型"], log, bar)
if await self.skip_download(i := container["作品ID"]):
logging(log, self.prompt.exist_record(i))
else:
path, result = await self.download.run(u, name, container["作品类型"], log, bar)
await self.__add_record(i, result)
elif not u:
logging(log, self.prompt.download_link_error, ERROR)
self.manager.save_data(path, name, container)
async def __add_record(self, id_: str, result: tuple) -> None:
if all(result):
await self.recorder.add(id_)
async def extract(self, url: str, download=False, efficient=False, log=None, bar=None) -> list[dict]:
# return # 调试代码
urls = await self.__extract_links(url, log)
@ -179,6 +189,9 @@ class XHS:
def stop_monitor(self):
self.event.set()
async def skip_download(self, id_: str) -> bool:
return bool(await self.recorder.select(id_))
@staticmethod
async def __suspend(efficient: bool) -> None:
if efficient:
@ -186,9 +199,11 @@ class XHS:
await wait()
async def __aenter__(self):
await self.recorder.__aenter__()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.recorder.__aexit__(exc_type, exc_value, traceback)
await self.close()
async def close(self):

View File

@ -33,7 +33,7 @@ class Download:
self.video_format = "mp4"
self.image_format = manager.image_format
async def run(self, urls: list, name: str, type_: str, log, bar) -> Path:
async def run(self, urls: list, name: str, type_: str, log, bar) -> tuple[Path, tuple]:
path = self.__generate_path(name)
match type_:
case "视频":
@ -52,8 +52,8 @@ class Download:
bar) for url,
name,
format_ in tasks]
await gather(*tasks)
return path
result = await gather(*tasks)
return path, result
def __generate_path(self, name: str):
path = self.manager.archive(self.folder, name, self.folder_mode)

View File

@ -1,6 +1,6 @@
from .extend import Account
from .manager import Manager
from .recorder import Recorder
from .recorder import IDRecorder
from .settings import Settings
from .static import (
VERSION_MAJOR,
@ -31,7 +31,7 @@ from .tools import (
__all__ = [
"Account",
"Settings",
"Recorder",
"IDRecorder",
"Manager",
"VERSION_MAJOR",
"VERSION_MINOR",

View File

@ -1,5 +1,46 @@
__all__ = ["Recorder"]
from aiosqlite import connect
from source.module import Manager
__all__ = ["IDRecorder"]
class Recorder:
pass
class IDRecorder:
def __init__(self, manager: Manager):
self.file = manager.root.joinpath("XHS-Downloader.db")
self.database = None
self.cursor = None
async def __connect_database(self):
self.database = await connect(self.file)
self.cursor = await self.database.cursor()
await self.cursor.execute("CREATE TABLE IF NOT EXISTS explore_ids (ID TEXT PRIMARY KEY);")
await self.database.commit()
async def select(self, id_: str):
await self.cursor.execute("SELECT ID FROM explore_ids WHERE ID=?", (id_,))
return await self.cursor.fetchone()
async def add(self, id_: str) -> None:
await self.cursor.execute("REPLACE INTO explore_ids VALUES (?);", (id_,))
await self.database.commit()
async def delete(self, id_: str) -> None:
if id_:
await self.cursor.execute("DELETE FROM explore_ids WHERE ID=?", (id_,))
await self.database.commit()
async def delete_many(self, ids: list | tuple):
[await self.delete(i) for i in ids]
async def all(self):
await self.cursor.execute("SELECT ID FROM explore_ids")
return [i[0] for i in await self.cursor.fetchmany()]
async def __aenter__(self):
await self.__connect_database()
return self
async def __aexit__(self, exc_type, exc_value, traceback):
await self.cursor.close()
await self.database.close()

View File

@ -23,7 +23,7 @@ __all__ = [
VERSION_MAJOR = 1
VERSION_MINOR = 8
VERSION_BETA = True
VERSION_BETA = False
ROOT = Path(__file__).resolve().parent.parent.parent
PROJECT = f"XHS-Downloader V{VERSION_MAJOR}.{
VERSION_MINOR}{" Beta" if VERSION_BETA else ""}"

View File

@ -74,13 +74,18 @@ class Chinese:
monitor_text: str = "程序会自动读取并提取剪贴板中的小红书作品链接,并自动下载链接对应的作品文件,如需关闭,请点击关闭按钮,或者向剪贴板写入 “close” 文本!"
close_monitor: str = "退出监听剪贴板模式"
record_title: str = "请输入待删除的小红书作品链接或作品 ID"
record_placeholder: str = "支持输入作品 ID 或包含作品 ID 的作品链接,多个链接或 ID 之间使用空格分隔"
record_enter_button: str = "删除指定作品 ID"
record_close_button: str = "返回"
@staticmethod
def request_error(url: str) -> str:
return f"网络异常,请求 {url} 失败!"
@staticmethod
def skip_download(name: str) -> str:
return f"{name} 已存在,跳过下载!"
return f"{name} 文件已存在,跳过下载!"
@staticmethod
def download_success(name: str) -> str:
@ -113,3 +118,7 @@ class Chinese:
@staticmethod
def official_version_update(major: int, minor: int) -> str:
return f"检测到新版本:{major}.{minor}"
@staticmethod
def exist_record(id_: str) -> str:
return f"作品 {id_} 存在下载记录,跳过下载!"

View File

@ -95,6 +95,13 @@ class English(Chinese):
"please click the close button or write the \"close\" text to the clipboard!")
close_monitor: str = "Exit monitoring clipboard mode"
record_title: str = "Please enter the link or ID of the Xiaohongshu work to be deleted:"
record_placeholder: str = (
"Support input of works ID or links containing work ID, with multiple links or IDs "
"separated by spaces")
record_enter_button: str = "Delete specified works ID"
record_close_button: str = "return"
@staticmethod
def request_error(url: str) -> str:
return f"Network error, failed to access {url}!"
@ -134,3 +141,7 @@ class English(Chinese):
@staticmethod
def official_version_update(major: int, minor: int) -> str:
return f"New version detected: {major}.{minor}"
@staticmethod
def exist_record(id_: str) -> str:
return f"works {id_} has a download record, skipping download!"

View File

@ -1,4 +1,4 @@
ScrollableContainer, RichLog, Monitor {
ScrollableContainer, RichLog, Monitor, About {
background: #2f3542;
}
Button {
@ -17,7 +17,7 @@ Button {
.horizontal-layout > * {
width: 25vw;
}
Button#deal, Button#paste, Button#save {
Button#deal, Button#paste, Button#save, Button#enter {
tint: #27ae60 60%;
}
Button#reset, Button#abandon, Button#close {
@ -32,7 +32,7 @@ Label {
Label.params {
margin: 1 0 0 0;
}
Label#prompt, Label#monitor {
Label.prompt {
padding: 1;
}
Bar {
@ -52,6 +52,13 @@ Bar > .bar--complete {
background: #353b48;
border: double #747d8c;
}
#record {
grid-size: 1 3;
width: 80vw;
height: 12;
background: #353b48;
border: double #747d8c;
}
ModalScreen {
align: center middle;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 149 KiB

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 133 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 75 KiB