sticker-convert 2.13.3.0__py3-none-any.whl → 2.17.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. sticker_convert/__main__.py +24 -27
  2. sticker_convert/auth/__init__.py +0 -0
  3. sticker_convert/auth/auth_base.py +19 -0
  4. sticker_convert/{utils/auth/get_discord_auth.py → auth/auth_discord.py} +149 -118
  5. sticker_convert/{utils/auth/get_kakao_auth.py → auth/auth_kakao_android_login.py} +331 -330
  6. sticker_convert/auth/auth_kakao_desktop_login.py +327 -0
  7. sticker_convert/{utils/auth/get_kakao_desktop_auth.py → auth/auth_kakao_desktop_memdump.py} +281 -263
  8. sticker_convert/{utils/auth/get_line_auth.py → auth/auth_line.py} +98 -80
  9. sticker_convert/{utils/auth/get_signal_auth.py → auth/auth_signal.py} +139 -135
  10. sticker_convert/auth/auth_telethon.py +161 -0
  11. sticker_convert/{utils/auth/get_viber_auth.py → auth/auth_viber.py} +250 -235
  12. sticker_convert/{utils/auth → auth}/telegram_api.py +736 -675
  13. sticker_convert/cli.py +623 -608
  14. sticker_convert/converter.py +1093 -1084
  15. sticker_convert/definitions.py +4 -0
  16. sticker_convert/downloaders/download_band.py +111 -110
  17. sticker_convert/downloaders/download_base.py +171 -166
  18. sticker_convert/downloaders/download_discord.py +92 -91
  19. sticker_convert/downloaders/download_kakao.py +417 -404
  20. sticker_convert/downloaders/download_line.py +484 -475
  21. sticker_convert/downloaders/download_ogq.py +80 -79
  22. sticker_convert/downloaders/download_signal.py +108 -105
  23. sticker_convert/downloaders/download_telegram.py +56 -55
  24. sticker_convert/downloaders/download_viber.py +121 -120
  25. sticker_convert/gui.py +788 -873
  26. sticker_convert/gui_components/frames/comp_frame.py +180 -166
  27. sticker_convert/gui_components/frames/config_frame.py +156 -113
  28. sticker_convert/gui_components/frames/control_frame.py +32 -30
  29. sticker_convert/gui_components/frames/cred_frame.py +232 -233
  30. sticker_convert/gui_components/frames/input_frame.py +139 -137
  31. sticker_convert/gui_components/frames/output_frame.py +112 -110
  32. sticker_convert/gui_components/frames/right_clicker.py +25 -23
  33. sticker_convert/gui_components/windows/advanced_compression_window.py +757 -757
  34. sticker_convert/gui_components/windows/base_window.py +7 -2
  35. sticker_convert/gui_components/windows/discord_get_auth_window.py +79 -82
  36. sticker_convert/gui_components/windows/kakao_get_auth_window.py +511 -321
  37. sticker_convert/gui_components/windows/line_get_auth_window.py +94 -102
  38. sticker_convert/gui_components/windows/signal_get_auth_window.py +84 -89
  39. sticker_convert/gui_components/windows/viber_get_auth_window.py +168 -168
  40. sticker_convert/ios-message-stickers-template/.github/FUNDING.yml +3 -3
  41. sticker_convert/ios-message-stickers-template/README.md +10 -10
  42. sticker_convert/ios-message-stickers-template/stickers/Info.plist +43 -43
  43. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Info.plist +31 -31
  44. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/Contents.json +6 -6
  45. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/Contents.json +20 -20
  46. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/Sticker 1.sticker/Contents.json +9 -9
  47. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/Sticker 2.sticker/Contents.json +9 -9
  48. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/Sticker Pack.stickerpack/Sticker 3.sticker/Contents.json +9 -9
  49. sticker_convert/ios-message-stickers-template/stickers StickerPackExtension/Stickers.xcstickers/iMessage App Icon.stickersiconset/Contents.json +91 -91
  50. sticker_convert/ios-message-stickers-template/stickers.xcodeproj/project.pbxproj +364 -364
  51. sticker_convert/ios-message-stickers-template/stickers.xcodeproj/project.xcworkspace/contents.xcworkspacedata +7 -7
  52. sticker_convert/ios-message-stickers-template/stickers.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -8
  53. sticker_convert/ios-message-stickers-template/stickers.xcodeproj/xcuserdata/niklaspeterson.xcuserdatad/xcschemes/xcschememanagement.plist +14 -14
  54. sticker_convert/job.py +166 -130
  55. sticker_convert/job_option.py +1 -0
  56. sticker_convert/locales/en_US/LC_MESSAGES/base.mo +0 -0
  57. sticker_convert/locales/ja_JP/LC_MESSAGES/base.mo +0 -0
  58. sticker_convert/locales/zh_CN/LC_MESSAGES/base.mo +0 -0
  59. sticker_convert/locales/zh_TW/LC_MESSAGES/base.mo +0 -0
  60. sticker_convert/py.typed +0 -0
  61. sticker_convert/resources/NotoColorEmoji.ttf +0 -0
  62. sticker_convert/resources/help.ja_JP.json +88 -0
  63. sticker_convert/resources/help.json +10 -7
  64. sticker_convert/resources/help.zh_CN.json +88 -0
  65. sticker_convert/resources/help.zh_TW.json +88 -0
  66. sticker_convert/resources/input.ja_JP.json +74 -0
  67. sticker_convert/resources/input.json +121 -121
  68. sticker_convert/resources/input.zh_CN.json +74 -0
  69. sticker_convert/resources/input.zh_TW.json +74 -0
  70. sticker_convert/resources/output.ja_JP.json +38 -0
  71. sticker_convert/resources/output.zh_CN.json +38 -0
  72. sticker_convert/resources/output.zh_TW.json +38 -0
  73. sticker_convert/uploaders/compress_wastickers.py +186 -177
  74. sticker_convert/uploaders/upload_base.py +44 -35
  75. sticker_convert/uploaders/upload_signal.py +218 -203
  76. sticker_convert/uploaders/upload_telegram.py +353 -338
  77. sticker_convert/uploaders/upload_viber.py +178 -169
  78. sticker_convert/uploaders/xcode_imessage.py +295 -286
  79. sticker_convert/utils/callback.py +238 -6
  80. sticker_convert/utils/emoji.py +16 -4
  81. sticker_convert/utils/files/json_resources_loader.py +24 -19
  82. sticker_convert/utils/files/metadata_handler.py +3 -3
  83. sticker_convert/utils/translate.py +108 -0
  84. sticker_convert/utils/url_detect.py +40 -37
  85. sticker_convert/version.py +1 -1
  86. {sticker_convert-2.13.3.0.dist-info → sticker_convert-2.17.0.0.dist-info}/METADATA +89 -74
  87. {sticker_convert-2.13.3.0.dist-info → sticker_convert-2.17.0.0.dist-info}/RECORD +91 -74
  88. sticker_convert/utils/auth/telethon_setup.py +0 -97
  89. sticker_convert/utils/singletons.py +0 -18
  90. {sticker_convert-2.13.3.0.dist-info → sticker_convert-2.17.0.0.dist-info}/WHEEL +0 -0
  91. {sticker_convert-2.13.3.0.dist-info → sticker_convert-2.17.0.0.dist-info}/entry_points.txt +0 -0
  92. {sticker_convert-2.13.3.0.dist-info → sticker_convert-2.17.0.0.dist-info}/licenses/LICENSE +0 -0
  93. {sticker_convert-2.13.3.0.dist-info → sticker_convert-2.17.0.0.dist-info}/top_level.txt +0 -0
@@ -1,475 +1,484 @@
1
- #!/usr/bin/env python3
2
- from __future__ import annotations
3
-
4
- import json
5
- import os
6
- import string
7
- import zipfile
8
- from io import BytesIO
9
- from pathlib import Path
10
- from typing import Any, Dict, List, Optional, Tuple
11
- from urllib import parse
12
-
13
- import requests
14
- from bs4 import BeautifulSoup
15
- from PIL import Image
16
-
17
- from sticker_convert.downloaders.download_base import DownloadBase
18
- from sticker_convert.job_option import CredOption, InputOption
19
- from sticker_convert.utils.auth.get_line_auth import GetLineAuth
20
- from sticker_convert.utils.callback import CallbackProtocol, CallbackReturn
21
- from sticker_convert.utils.files.metadata_handler import MetadataHandler
22
- from sticker_convert.utils.media.apple_png_normalize import ApplePngNormalize
23
-
24
- # Reference: https://github.com/doubleplusc/Line-sticker-downloader/blob/master/sticker_dl.py
25
-
26
-
27
- class MetadataLine:
28
- @staticmethod
29
- def analyze_url(url: str) -> Optional[Tuple[str, str, bool]]:
30
- region = ""
31
- is_emoji = False
32
- if url.startswith("line://shop/detail/"):
33
- pack_id = url.replace("line://shop/detail/", "")
34
- if len(url) == 24 and all(c in string.hexdigits for c in url):
35
- is_emoji = True
36
- elif url.startswith("https://store.line.me/stickershop/product/"):
37
- pack_id = url.replace(
38
- "https://store.line.me/stickershop/product/", ""
39
- ).split("/")[0]
40
- region = url.replace(
41
- "https://store.line.me/stickershop/product/", ""
42
- ).split("/")[1]
43
- elif url.startswith("https://line.me/S/sticker"):
44
- url_parsed = parse.urlparse(url)
45
- pack_id = url.replace("https://line.me/S/sticker/", "").split("/")[0]
46
- region = parse.parse_qs(url_parsed.query)["lang"][0]
47
- elif url.startswith("https://store.line.me/officialaccount/event/sticker/"):
48
- pack_id = url.replace(
49
- "https://store.line.me/officialaccount/event/sticker/", ""
50
- ).split("/")[0]
51
- region = url.replace(
52
- "https://store.line.me/officialaccount/event/sticker/", ""
53
- ).split("/")[1]
54
- elif url.startswith("https://store.line.me/emojishop/product/"):
55
- pack_id = url.replace("https://store.line.me/emojishop/product/", "").split(
56
- "/"
57
- )[0]
58
- region = url.replace("https://store.line.me/emojishop/product/", "").split(
59
- "/"
60
- )[1]
61
- is_emoji = True
62
- elif url.startswith("https://line.me/S/emoji"):
63
- url_parsed = parse.urlparse(url)
64
- pack_id = parse.parse_qs(url_parsed.query)["id"][0]
65
- region = parse.parse_qs(url_parsed.query)["lang"][0]
66
- is_emoji = True
67
- elif len(url) == 24 and all(c in string.hexdigits for c in url):
68
- pack_id = url
69
- is_emoji = True
70
- elif url.isnumeric():
71
- pack_id = url
72
- else:
73
- return None
74
-
75
- return pack_id, region, is_emoji
76
-
77
- @staticmethod
78
- def get_metadata_sticon(
79
- pack_id: str, region: str
80
- ) -> Optional[Tuple[str, str, List[Dict[str, Any]], str, bool, bool]]:
81
- pack_meta_r = requests.get(
82
- f"https://stickershop.line-scdn.net/sticonshop/v1/{pack_id}/sticon/iphone/meta.json"
83
- )
84
-
85
- if pack_meta_r.status_code == 200:
86
- pack_meta = json.loads(pack_meta_r.text)
87
- else:
88
- return None
89
-
90
- if region == "":
91
- region = "en"
92
-
93
- pack_store_page = requests.get(
94
- f"https://store.line.me/emojishop/product/{pack_id}/{region}"
95
- )
96
-
97
- if pack_store_page.status_code != 200:
98
- return None
99
-
100
- pack_store_page_soup = BeautifulSoup(pack_store_page.text, "html.parser")
101
-
102
- title_tag = pack_store_page_soup.find(class_="mdCMN38Item01Txt") # type: ignore
103
- if title_tag:
104
- title = title_tag.text
105
- else:
106
- return None
107
-
108
- author_tag = pack_store_page_soup.find(class_="mdCMN38Item01Author") # type: ignore
109
- if author_tag:
110
- author = author_tag.text
111
- else:
112
- return None
113
-
114
- files = pack_meta["orders"]
115
-
116
- resource_type = pack_meta.get("sticonResourceType")
117
- has_animation = True if resource_type == "ANIMATION" else False
118
- has_sound = False
119
-
120
- return title, author, files, resource_type, has_animation, has_sound
121
-
122
- @staticmethod
123
- def get_metadata_stickers(
124
- pack_id: str, region: str
125
- ) -> Optional[Tuple[str, str, List[Dict[str, Any]], str, bool, bool]]:
126
- pack_meta_r = requests.get(
127
- f"https://stickershop.line-scdn.net/stickershop/v1/product/{pack_id}/android/productInfo.meta"
128
- )
129
-
130
- if pack_meta_r.status_code == 200:
131
- pack_meta = json.loads(pack_meta_r.text)
132
- else:
133
- return None
134
-
135
- if region == "":
136
- if "en" in pack_meta["title"]:
137
- # Prefer en release
138
- region = "en"
139
- else:
140
- # If no en release, use whatever comes first
141
- region = pack_meta["title"].keys()[0]
142
-
143
- if region == "zh-Hant":
144
- region = "zh_TW"
145
-
146
- title = pack_meta["title"].get("en")
147
- if title is None:
148
- title = pack_meta["title"][region]
149
-
150
- author = pack_meta["author"].get("en")
151
- if author is None:
152
- author = pack_meta["author"][region]
153
-
154
- files = pack_meta["stickers"]
155
-
156
- resource_type = pack_meta.get("stickerResourceType")
157
- has_animation = pack_meta.get("hasAnimation")
158
- has_sound = pack_meta.get("hasSound")
159
-
160
- return title, author, files, resource_type, has_animation, has_sound
161
-
162
-
163
- class DownloadLine(DownloadBase):
164
- def __init__(self, *args: Any, **kwargs: Any) -> None:
165
- super().__init__(*args, **kwargs)
166
- self.headers = {
167
- "referer": "https://store.line.me",
168
- "user-agent": "Android",
169
- "x-requested-with": "XMLHttpRequest",
170
- }
171
- self.cookies = self.load_cookies()
172
- self.sticker_text_dict: Dict[int, Any] = {}
173
-
174
- def load_cookies(self) -> Dict[str, str]:
175
- cookies: Dict[str, str] = {}
176
- if self.opt_cred and self.opt_cred.line_cookies:
177
- line_cookies = self.opt_cred.line_cookies
178
-
179
- try:
180
- line_cookies_dict = json.loads(line_cookies)
181
- for c in line_cookies_dict:
182
- cookies[c["name"]] = c["value"]
183
- except json.decoder.JSONDecodeError:
184
- try:
185
- for i in line_cookies.split(";"):
186
- c_key, c_value = i.split("=")
187
- cookies[c_key] = c_value
188
- except ValueError:
189
- self.cb.put(
190
- 'Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"'
191
- )
192
-
193
- if not GetLineAuth.validate_cookies(cookies):
194
- self.cb.put(
195
- 'Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"'
196
- )
197
- cookies = {}
198
-
199
- return cookies
200
-
201
- def get_pack_url(self) -> str:
202
- # Reference: https://sora.vercel.app/line-sticker-download
203
- if self.is_emoji:
204
- if self.resource_type == "ANIMATION":
205
- pack_url = f"https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package_animation.zip"
206
- else:
207
- pack_url = f"https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package.zip"
208
- else:
209
- if (
210
- self.resource_type in ("ANIMATION", "ANIMATION_SOUND", "POPUP")
211
- or self.has_sound is True
212
- or self.has_animation is True
213
- ):
214
- pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickerpack@2x.zip"
215
- elif self.resource_type == "PER_STICKER_TEXT":
216
- pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_custom_plus_base@2x.zip"
217
- elif self.resource_type == "NAME_TEXT":
218
- pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_name_base@2x.zip"
219
- else:
220
- pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickers@2x.zip"
221
-
222
- return pack_url
223
-
224
- def decompress(
225
- self,
226
- zf: zipfile.ZipFile,
227
- f_path: str,
228
- num: int,
229
- prefix: str = "",
230
- suffix: str = "",
231
- ) -> None:
232
- data = zf.read(f_path)
233
- ext = Path(f_path).suffix
234
- if ext == ".png" and not self.is_emoji and int() < 775:
235
- data = ApplePngNormalize.normalize(data)
236
- self.cb.put(f"Read {f_path}")
237
-
238
- out_path = Path(self.out_dir, prefix + str(num).zfill(3) + suffix + ext)
239
- with open(out_path, "wb") as f:
240
- f.write(data)
241
-
242
- def decompress_emoticon(self, zip_file: bytes) -> None:
243
- with zipfile.ZipFile(BytesIO(zip_file)) as zf:
244
- self.cb.put("Unzipping...")
245
-
246
- self.cb.put(
247
- (
248
- "bar",
249
- None,
250
- {"set_progress_mode": "determinate", "steps": len(self.pack_files)},
251
- )
252
- )
253
-
254
- for num, sticker in enumerate(self.pack_files):
255
- if self.has_animation is True:
256
- f_path = str(sticker) + "_animation.png"
257
- else:
258
- f_path = str(sticker) + ".png"
259
- self.decompress(zf, f_path, num)
260
-
261
- self.cb.put("update_bar")
262
-
263
- def decompress_stickers(self, zip_file: bytes) -> None:
264
- with zipfile.ZipFile(BytesIO(zip_file)) as zf:
265
- self.cb.put("Unzipping...")
266
-
267
- self.cb.put(
268
- (
269
- "bar",
270
- None,
271
- {"set_progress_mode": "determinate", "steps": len(self.pack_files)},
272
- )
273
- )
274
-
275
- for num, sticker in enumerate(self.pack_files):
276
- if self.resource_type == "POPUP":
277
- if sticker.get("popup", {}).get("layer") == "BACKGROUND":
278
- f_path = str(sticker["id"]) + "@2x.png"
279
- self.decompress(zf, f_path, num, "preview-")
280
- f_path = "popup/" + str(sticker["id"]) + ".png"
281
- elif self.has_animation is True:
282
- f_path = "animation@2x/" + str(sticker["id"]) + "@2x.png"
283
- else:
284
- f_path = str(sticker["id"]) + "@2x.png"
285
- self.decompress(zf, f_path, num)
286
-
287
- if self.resource_type == "PER_STICKER_TEXT":
288
- self.sticker_text_dict[num] = {
289
- "sticker_id": sticker["id"],
290
- "sticker_text": sticker["customPlus"]["defaultText"],
291
- }
292
-
293
- elif self.resource_type == "NAME_TEXT":
294
- self.sticker_text_dict[num] = {
295
- "sticker_id": sticker["id"],
296
- "sticker_text": "",
297
- }
298
-
299
- if self.has_sound:
300
- f_path = "sound/" + str(sticker["id"]) + ".m4a"
301
- self.decompress(zf, f_path, num)
302
-
303
- self.cb.put("update_bar")
304
-
305
- def edit_custom_sticker_text(self) -> None:
306
- line_sticker_text_path = Path(self.out_dir, "line-sticker-text.txt")
307
-
308
- if not line_sticker_text_path.is_file():
309
- with open(line_sticker_text_path, "w+", encoding="utf-8") as f:
310
- json.dump(self.sticker_text_dict, f, indent=4, ensure_ascii=False)
311
-
312
- msg_block = (
313
- "The Line sticker pack you are downloading can have customized text.\n"
314
- )
315
- msg_block += "line-sticker-text.txt has been created in input directory.\n"
316
- msg_block += "Please edit line-sticker-text.txt, then continue."
317
- self.cb.put(("msg_block", (msg_block,), None))
318
- if self.cb_return:
319
- self.cb_return.get_response()
320
-
321
- with open(line_sticker_text_path, "r", encoding="utf-8") as f:
322
- self.sticker_text_dict = json.load(f)
323
-
324
- def get_custom_sticker_text_urls(self) -> List[Tuple[str, Path]]:
325
- custom_sticker_text_urls: List[Tuple[str, Path]] = []
326
- name_text_key_cache: Dict[str, str] = {}
327
-
328
- for num, data in self.sticker_text_dict.items():
329
- out_path = Path(self.out_dir, str(num).zfill(3))
330
- sticker_id = data["sticker_id"]
331
- sticker_text = data["sticker_text"]
332
-
333
- if self.resource_type == "PER_STICKER_TEXT":
334
- out_path_text = out_path.with_name(out_path.name + "-text.png")
335
- custom_sticker_text_urls.append(
336
- (
337
- f"https://store.line.me/overlay/sticker/{self.pack_id}/{sticker_id}/iPhone/sticker.png?text={parse.quote(sticker_text)}",
338
- out_path_text,
339
- )
340
- )
341
-
342
- elif self.resource_type == "NAME_TEXT" and sticker_text:
343
- out_path_text = out_path.with_name(out_path.name + "-text.png")
344
- name_text_key = name_text_key_cache.get(sticker_text, None)
345
- if not name_text_key:
346
- name_text_key = self.get_name_text_key(sticker_text)
347
- if name_text_key:
348
- name_text_key_cache[sticker_text] = name_text_key
349
- else:
350
- continue
351
-
352
- custom_sticker_text_urls.append(
353
- (
354
- f"https://stickershop.line-scdn.net/stickershop/v1/sticker/{sticker_id}/iPhone/overlay/name/{name_text_key}/sticker@2x.png",
355
- out_path_text,
356
- )
357
- )
358
-
359
- return custom_sticker_text_urls
360
-
361
- def get_name_text_key(self, sticker_text: str) -> Optional[str]:
362
- params = {"text": sticker_text}
363
-
364
- response = requests.get(
365
- f"https://store.line.me/api/custom-sticker/preview/{self.pack_id}/{self.region}",
366
- params=params,
367
- cookies=self.cookies,
368
- headers=self.headers,
369
- )
370
-
371
- response_dict = json.loads(response.text)
372
-
373
- if response_dict["errorMessage"]:
374
- self.cb.put(
375
- f"Failed to generate customized text {sticker_text} due to: {response_dict['errorMessage']}"
376
- )
377
- return None
378
-
379
- name_text_key = (
380
- response_dict["productPayload"]["customOverlayUrl"]
381
- .split("name/")[-1]
382
- .split("/main.png")[0]
383
- )
384
-
385
- return name_text_key
386
-
387
- def combine_custom_text(self) -> None:
388
- for i in sorted(self.out_dir.iterdir()):
389
- if i.name.endswith("-text.png"):
390
- base_path = Path(self.out_dir, i.name.replace("-text.png", ".png"))
391
- text_path = Path(self.out_dir, i.name)
392
-
393
- with Image.open(base_path) as im:
394
- base_img: Image.Image = im.convert("RGBA")
395
-
396
- with Image.open(text_path) as im:
397
- text_img = im.convert("RGBA")
398
-
399
- with Image.alpha_composite(base_img, text_img) as im:
400
- im.save(base_path)
401
-
402
- os.remove(text_path)
403
-
404
- self.cb.put(f"Combined {i.name.replace('-text.png', '.png')}")
405
-
406
- def download_stickers_line(self) -> Tuple[int, int]:
407
- url_data = MetadataLine.analyze_url(self.url)
408
- if url_data:
409
- self.pack_id, self.region, self.is_emoji = url_data
410
- else:
411
- self.cb.put("Download failed: Unsupported URL format")
412
- return 0, 0
413
-
414
- if self.is_emoji:
415
- metadata = MetadataLine.get_metadata_sticon(self.pack_id, self.region)
416
- else:
417
- metadata = MetadataLine.get_metadata_stickers(self.pack_id, self.region)
418
-
419
- if metadata:
420
- (
421
- self.title,
422
- self.author,
423
- self.pack_files,
424
- self.resource_type,
425
- self.has_animation,
426
- self.has_sound,
427
- ) = metadata
428
- else:
429
- self.cb.put("Download failed: Failed to get metadata")
430
- return 0, 0
431
-
432
- MetadataHandler.set_metadata(self.out_dir, title=self.title, author=self.author)
433
-
434
- pack_url = self.get_pack_url()
435
- zip_file = self.download_file(pack_url)
436
-
437
- if zip_file:
438
- self.cb.put(f"Downloaded {pack_url}")
439
- else:
440
- self.cb.put(f"Cannot download {pack_url}")
441
- return 0, 0
442
-
443
- if self.is_emoji:
444
- self.decompress_emoticon(zip_file)
445
- else:
446
- self.decompress_stickers(zip_file)
447
-
448
- custom_sticker_text_urls: List[Tuple[str, Path]] = []
449
- if self.sticker_text_dict and (
450
- self.resource_type == "PER_STICKER_TEXT"
451
- or (self.resource_type == "NAME_TEXT" and self.cookies)
452
- ):
453
- self.edit_custom_sticker_text()
454
- custom_sticker_text_urls = self.get_custom_sticker_text_urls()
455
- elif self.resource_type == "NAME_TEXT" and not self.cookies:
456
- self.cb.put('Warning: Line "Custom stickers" is supplied as input')
457
- self.cb.put(
458
- "However, adding custom message requires Line cookies, and it is not supplied"
459
- )
460
- self.cb.put("Continuing without adding custom text to stickers")
461
-
462
- self.download_multiple_files(custom_sticker_text_urls, headers=self.headers)
463
- self.combine_custom_text()
464
-
465
- return len(self.pack_files), len(self.pack_files)
466
-
467
- @staticmethod
468
- def start(
469
- opt_input: InputOption,
470
- opt_cred: Optional[CredOption],
471
- cb: CallbackProtocol,
472
- cb_return: CallbackReturn,
473
- ) -> Tuple[int, int]:
474
- downloader = DownloadLine(opt_input, opt_cred, cb, cb_return)
475
- return downloader.download_stickers_line()
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import string
7
+ import zipfile
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+ from typing import Any, Dict, List, Optional, Tuple
11
+ from urllib import parse
12
+
13
+ import requests
14
+ from bs4 import BeautifulSoup
15
+ from PIL import Image
16
+
17
+ from sticker_convert.auth.auth_line import AuthLine
18
+ from sticker_convert.downloaders.download_base import DownloadBase
19
+ from sticker_convert.job_option import CredOption, InputOption
20
+ from sticker_convert.utils.callback import CallbackProtocol, CallbackReturn
21
+ from sticker_convert.utils.files.metadata_handler import MetadataHandler
22
+ from sticker_convert.utils.media.apple_png_normalize import ApplePngNormalize
23
+ from sticker_convert.utils.translate import I
24
+
25
+ # Reference: https://github.com/doubleplusc/Line-sticker-downloader/blob/master/sticker_dl.py
26
+
27
+
28
+ class MetadataLine:
29
+ @staticmethod
30
+ def analyze_url(url: str) -> Optional[Tuple[str, str, bool]]:
31
+ region = ""
32
+ is_emoji = False
33
+ if url.startswith("line://shop/detail/"):
34
+ pack_id = url.replace("line://shop/detail/", "")
35
+ if len(url) == 24 and all(c in string.hexdigits for c in url):
36
+ is_emoji = True
37
+ elif url.startswith("https://store.line.me/stickershop/product/"):
38
+ pack_id = url.replace(
39
+ "https://store.line.me/stickershop/product/", ""
40
+ ).split("/")[0]
41
+ region = url.replace(
42
+ "https://store.line.me/stickershop/product/", ""
43
+ ).split("/")[1]
44
+ elif url.startswith("https://line.me/S/sticker"):
45
+ url_parsed = parse.urlparse(url)
46
+ pack_id = url.replace("https://line.me/S/sticker/", "").split("/")[0]
47
+ region = parse.parse_qs(url_parsed.query)["lang"][0]
48
+ elif url.startswith("https://store.line.me/officialaccount/event/sticker/"):
49
+ pack_id = url.replace(
50
+ "https://store.line.me/officialaccount/event/sticker/", ""
51
+ ).split("/")[0]
52
+ region = url.replace(
53
+ "https://store.line.me/officialaccount/event/sticker/", ""
54
+ ).split("/")[1]
55
+ elif url.startswith("https://store.line.me/emojishop/product/"):
56
+ pack_id = url.replace("https://store.line.me/emojishop/product/", "").split(
57
+ "/"
58
+ )[0]
59
+ region = url.replace("https://store.line.me/emojishop/product/", "").split(
60
+ "/"
61
+ )[1]
62
+ is_emoji = True
63
+ elif url.startswith("https://line.me/S/emoji"):
64
+ url_parsed = parse.urlparse(url)
65
+ pack_id = parse.parse_qs(url_parsed.query)["id"][0]
66
+ region = parse.parse_qs(url_parsed.query)["lang"][0]
67
+ is_emoji = True
68
+ elif len(url) == 24 and all(c in string.hexdigits for c in url):
69
+ pack_id = url
70
+ is_emoji = True
71
+ elif url.isnumeric():
72
+ pack_id = url
73
+ else:
74
+ return None
75
+
76
+ return pack_id, region, is_emoji
77
+
78
+ @staticmethod
79
+ def get_metadata_sticon(
80
+ pack_id: str, region: str
81
+ ) -> Optional[Tuple[str, str, List[Dict[str, Any]], str, bool, bool]]:
82
+ pack_meta_r = requests.get(
83
+ f"https://stickershop.line-scdn.net/sticonshop/v1/{pack_id}/sticon/iphone/meta.json"
84
+ )
85
+
86
+ if pack_meta_r.status_code == 200:
87
+ pack_meta = json.loads(pack_meta_r.text)
88
+ else:
89
+ return None
90
+
91
+ if region == "":
92
+ region = "en"
93
+
94
+ pack_store_page = requests.get(
95
+ f"https://store.line.me/emojishop/product/{pack_id}/{region}"
96
+ )
97
+
98
+ if pack_store_page.status_code != 200:
99
+ return None
100
+
101
+ pack_store_page_soup = BeautifulSoup(pack_store_page.text, "html.parser")
102
+
103
+ title_tag = pack_store_page_soup.find(class_="mdCMN38Item01Txt") # type: ignore
104
+ if title_tag:
105
+ title = title_tag.text
106
+ else:
107
+ return None
108
+
109
+ author_tag = pack_store_page_soup.find(class_="mdCMN38Item01Author") # type: ignore
110
+ if author_tag:
111
+ author = author_tag.text
112
+ else:
113
+ return None
114
+
115
+ files = pack_meta["orders"]
116
+
117
+ resource_type = pack_meta.get("sticonResourceType")
118
+ has_animation = True if resource_type == "ANIMATION" else False
119
+ has_sound = False
120
+
121
+ return title, author, files, resource_type, has_animation, has_sound
122
+
123
+ @staticmethod
124
+ def get_metadata_stickers(
125
+ pack_id: str, region: str
126
+ ) -> Optional[Tuple[str, str, List[Dict[str, Any]], str, bool, bool]]:
127
+ pack_meta_r = requests.get(
128
+ f"https://stickershop.line-scdn.net/stickershop/v1/product/{pack_id}/android/productInfo.meta"
129
+ )
130
+
131
+ if pack_meta_r.status_code == 200:
132
+ pack_meta = json.loads(pack_meta_r.text)
133
+ else:
134
+ return None
135
+
136
+ if region == "":
137
+ if "en" in pack_meta["title"]:
138
+ # Prefer en release
139
+ region = "en"
140
+ else:
141
+ # If no en release, use whatever comes first
142
+ region = pack_meta["title"].keys()[0]
143
+
144
+ if region == "zh-Hant":
145
+ region = "zh_TW"
146
+
147
+ title = pack_meta["title"].get("en")
148
+ if title is None:
149
+ title = pack_meta["title"][region]
150
+
151
+ author = pack_meta["author"].get("en")
152
+ if author is None:
153
+ author = pack_meta["author"][region]
154
+
155
+ files = pack_meta["stickers"]
156
+
157
+ resource_type = pack_meta.get("stickerResourceType")
158
+ has_animation = pack_meta.get("hasAnimation")
159
+ has_sound = pack_meta.get("hasSound")
160
+
161
+ return title, author, files, resource_type, has_animation, has_sound
162
+
163
+
164
+ class DownloadLine(DownloadBase):
165
+ def __init__(self, *args: Any, **kwargs: Any) -> None:
166
+ self.MSG_CUSTOM_TEXT = I(
167
+ "The Line sticker pack you are downloading can have customized text.\n"
168
+ "line-sticker-text.txt has been created in input directory.\n"
169
+ "Please edit line-sticker-text.txt, then continue."
170
+ )
171
+
172
+ super().__init__(*args, **kwargs)
173
+ self.headers = {
174
+ "referer": "https://store.line.me",
175
+ "user-agent": "Android",
176
+ "x-requested-with": "XMLHttpRequest",
177
+ }
178
+ self.cookies = self.load_cookies()
179
+ self.sticker_text_dict: Dict[int, Any] = {}
180
+
181
+ def load_cookies(self) -> Dict[str, str]:
182
+ cookies: Dict[str, str] = {}
183
+ if self.opt_cred and self.opt_cred.line_cookies:
184
+ line_cookies = self.opt_cred.line_cookies
185
+
186
+ try:
187
+ line_cookies_dict = json.loads(line_cookies)
188
+ for c in line_cookies_dict:
189
+ cookies[c["name"]] = c["value"]
190
+ except json.decoder.JSONDecodeError:
191
+ try:
192
+ for i in line_cookies.split(";"):
193
+ c_key, c_value = i.split("=")
194
+ cookies[c_key] = c_value
195
+ except ValueError:
196
+ self.cb.put(
197
+ I(
198
+ 'Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"'
199
+ )
200
+ )
201
+
202
+ if not AuthLine.validate_cookies(cookies):
203
+ self.cb.put(
204
+ I(
205
+ 'Warning: Line cookies invalid, you will not be able to add text to "Custom stickers"'
206
+ )
207
+ )
208
+ cookies = {}
209
+
210
+ return cookies
211
+
212
+ def get_pack_url(self) -> str:
213
+ # Reference: https://sora.vercel.app/line-sticker-download
214
+ if self.is_emoji:
215
+ if self.resource_type == "ANIMATION":
216
+ pack_url = f"https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package_animation.zip"
217
+ else:
218
+ pack_url = f"https://stickershop.line-scdn.net/sticonshop/v1/{self.pack_id}/sticon/iphone/package.zip"
219
+ else:
220
+ if (
221
+ self.resource_type in ("ANIMATION", "ANIMATION_SOUND", "POPUP")
222
+ or self.has_sound is True
223
+ or self.has_animation is True
224
+ ):
225
+ pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickerpack@2x.zip"
226
+ elif self.resource_type == "PER_STICKER_TEXT":
227
+ pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_custom_plus_base@2x.zip"
228
+ elif self.resource_type == "NAME_TEXT":
229
+ pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/sticker_name_base@2x.zip"
230
+ else:
231
+ pack_url = f"https://stickershop.line-scdn.net/stickershop/v1/product/{self.pack_id}/iphone/stickers@2x.zip"
232
+
233
+ return pack_url
234
+
235
+ def decompress(
236
+ self,
237
+ zf: zipfile.ZipFile,
238
+ f_path: str,
239
+ num: int,
240
+ prefix: str = "",
241
+ suffix: str = "",
242
+ ) -> None:
243
+ data = zf.read(f_path)
244
+ ext = Path(f_path).suffix
245
+ if ext == ".png" and not self.is_emoji and int() < 775:
246
+ data = ApplePngNormalize.normalize(data)
247
+ self.cb.put(I("Read {}").format(f_path))
248
+
249
+ out_path = Path(self.out_dir, prefix + str(num).zfill(3) + suffix + ext)
250
+ with open(out_path, "wb") as f:
251
+ f.write(data)
252
+
253
+ def decompress_emoticon(self, zip_file: bytes) -> None:
254
+ with zipfile.ZipFile(BytesIO(zip_file)) as zf:
255
+ self.cb.put(I("Unzipping..."))
256
+
257
+ self.cb.put(
258
+ (
259
+ "bar",
260
+ None,
261
+ {"set_progress_mode": "determinate", "steps": len(self.pack_files)},
262
+ )
263
+ )
264
+
265
+ for num, sticker in enumerate(self.pack_files):
266
+ if self.has_animation is True:
267
+ f_path = str(sticker) + "_animation.png"
268
+ else:
269
+ f_path = str(sticker) + ".png"
270
+ self.decompress(zf, f_path, num)
271
+
272
+ self.cb.put("update_bar")
273
+
274
+ def decompress_stickers(self, zip_file: bytes) -> None:
275
+ with zipfile.ZipFile(BytesIO(zip_file)) as zf:
276
+ self.cb.put(I("Unzipping..."))
277
+
278
+ self.cb.put(
279
+ (
280
+ "bar",
281
+ None,
282
+ {"set_progress_mode": "determinate", "steps": len(self.pack_files)},
283
+ )
284
+ )
285
+
286
+ for num, sticker in enumerate(self.pack_files):
287
+ if self.resource_type == "POPUP":
288
+ if sticker.get("popup", {}).get("layer") == "BACKGROUND":
289
+ f_path = str(sticker["id"]) + "@2x.png"
290
+ self.decompress(zf, f_path, num, "preview-")
291
+ f_path = "popup/" + str(sticker["id"]) + ".png"
292
+ elif self.has_animation is True:
293
+ f_path = "animation@2x/" + str(sticker["id"]) + "@2x.png"
294
+ else:
295
+ f_path = str(sticker["id"]) + "@2x.png"
296
+ self.decompress(zf, f_path, num)
297
+
298
+ if self.resource_type == "PER_STICKER_TEXT":
299
+ self.sticker_text_dict[num] = {
300
+ "sticker_id": sticker["id"],
301
+ "sticker_text": sticker["customPlus"]["defaultText"],
302
+ }
303
+
304
+ elif self.resource_type == "NAME_TEXT":
305
+ self.sticker_text_dict[num] = {
306
+ "sticker_id": sticker["id"],
307
+ "sticker_text": "",
308
+ }
309
+
310
+ if self.has_sound:
311
+ f_path = "sound/" + str(sticker["id"]) + ".m4a"
312
+ self.decompress(zf, f_path, num)
313
+
314
+ self.cb.put("update_bar")
315
+
316
+ def edit_custom_sticker_text(self) -> None:
317
+ line_sticker_text_path = Path(self.out_dir, "line-sticker-text.txt")
318
+
319
+ if not line_sticker_text_path.is_file():
320
+ with open(line_sticker_text_path, "w+", encoding="utf-8") as f:
321
+ json.dump(self.sticker_text_dict, f, indent=4, ensure_ascii=False)
322
+ self.cb.put(("msg_block", (self.MSG_CUSTOM_TEXT,), None))
323
+ if self.cb_return:
324
+ self.cb_return.get_response()
325
+
326
+ with open(line_sticker_text_path, "r", encoding="utf-8") as f:
327
+ self.sticker_text_dict = json.load(f)
328
+
329
+ def get_custom_sticker_text_urls(self) -> List[Tuple[str, Path]]:
330
+ custom_sticker_text_urls: List[Tuple[str, Path]] = []
331
+ name_text_key_cache: Dict[str, str] = {}
332
+
333
+ for num, data in self.sticker_text_dict.items():
334
+ out_path = Path(self.out_dir, str(num).zfill(3))
335
+ sticker_id = data["sticker_id"]
336
+ sticker_text = data["sticker_text"]
337
+
338
+ if self.resource_type == "PER_STICKER_TEXT":
339
+ out_path_text = out_path.with_name(out_path.name + "-text.png")
340
+ custom_sticker_text_urls.append(
341
+ (
342
+ f"https://store.line.me/overlay/sticker/{self.pack_id}/{sticker_id}/iPhone/sticker.png?text={parse.quote(sticker_text)}",
343
+ out_path_text,
344
+ )
345
+ )
346
+
347
+ elif self.resource_type == "NAME_TEXT" and sticker_text:
348
+ out_path_text = out_path.with_name(out_path.name + "-text.png")
349
+ name_text_key = name_text_key_cache.get(sticker_text, None)
350
+ if not name_text_key:
351
+ name_text_key = self.get_name_text_key(sticker_text)
352
+ if name_text_key:
353
+ name_text_key_cache[sticker_text] = name_text_key
354
+ else:
355
+ continue
356
+
357
+ custom_sticker_text_urls.append(
358
+ (
359
+ f"https://stickershop.line-scdn.net/stickershop/v1/sticker/{sticker_id}/iPhone/overlay/name/{name_text_key}/sticker@2x.png",
360
+ out_path_text,
361
+ )
362
+ )
363
+
364
+ return custom_sticker_text_urls
365
+
366
+ def get_name_text_key(self, sticker_text: str) -> Optional[str]:
367
+ params = {"text": sticker_text}
368
+
369
+ response = requests.get(
370
+ f"https://store.line.me/api/custom-sticker/preview/{self.pack_id}/{self.region}",
371
+ params=params,
372
+ cookies=self.cookies,
373
+ headers=self.headers,
374
+ )
375
+
376
+ response_dict = json.loads(response.text)
377
+
378
+ if response_dict["errorMessage"]:
379
+ self.cb.put(
380
+ I("Failed to generate customized text {} due to: {}").format(
381
+ sticker_text, response_dict["errorMessage"]
382
+ )
383
+ )
384
+ return None
385
+
386
+ name_text_key = (
387
+ response_dict["productPayload"]["customOverlayUrl"]
388
+ .split("name/")[-1]
389
+ .split("/main.png")[0]
390
+ )
391
+
392
+ return name_text_key
393
+
394
+ def combine_custom_text(self) -> None:
395
+ for i in sorted(self.out_dir.iterdir()):
396
+ if i.name.endswith("-text.png"):
397
+ base_path = Path(self.out_dir, i.name.replace("-text.png", ".png"))
398
+ text_path = Path(self.out_dir, i.name)
399
+
400
+ with Image.open(base_path) as im:
401
+ base_img: Image.Image = im.convert("RGBA")
402
+
403
+ with Image.open(text_path) as im:
404
+ text_img = im.convert("RGBA")
405
+
406
+ with Image.alpha_composite(base_img, text_img) as im:
407
+ im.save(base_path)
408
+
409
+ os.remove(text_path)
410
+
411
+ self.cb.put(
412
+ I("Combined {}").format(i.name.replace("-text.png", ".png"))
413
+ )
414
+
415
+ def download_stickers_line(self) -> Tuple[int, int]:
416
+ url_data = MetadataLine.analyze_url(self.url)
417
+ if url_data:
418
+ self.pack_id, self.region, self.is_emoji = url_data
419
+ else:
420
+ self.cb.put(I("Download failed: Unsupported URL format"))
421
+ return 0, 0
422
+
423
+ if self.is_emoji:
424
+ metadata = MetadataLine.get_metadata_sticon(self.pack_id, self.region)
425
+ else:
426
+ metadata = MetadataLine.get_metadata_stickers(self.pack_id, self.region)
427
+
428
+ if metadata:
429
+ (
430
+ self.title,
431
+ self.author,
432
+ self.pack_files,
433
+ self.resource_type,
434
+ self.has_animation,
435
+ self.has_sound,
436
+ ) = metadata
437
+ else:
438
+ self.cb.put(I("Download failed: Failed to get metadata"))
439
+ return 0, 0
440
+
441
+ MetadataHandler.set_metadata(self.out_dir, title=self.title, author=self.author)
442
+
443
+ pack_url = self.get_pack_url()
444
+ zip_file = self.download_file(pack_url)
445
+
446
+ if zip_file:
447
+ self.cb.put(I("Downloaded {}").format(pack_url))
448
+ else:
449
+ self.cb.put(I("Cannot download {}").format(pack_url))
450
+ return 0, 0
451
+
452
+ if self.is_emoji:
453
+ self.decompress_emoticon(zip_file)
454
+ else:
455
+ self.decompress_stickers(zip_file)
456
+
457
+ custom_sticker_text_urls: List[Tuple[str, Path]] = []
458
+ if self.sticker_text_dict and (
459
+ self.resource_type == "PER_STICKER_TEXT"
460
+ or (self.resource_type == "NAME_TEXT" and self.cookies)
461
+ ):
462
+ self.edit_custom_sticker_text()
463
+ custom_sticker_text_urls = self.get_custom_sticker_text_urls()
464
+ elif self.resource_type == "NAME_TEXT" and not self.cookies:
465
+ self.cb.put('Warning: Line "Custom stickers" is supplied as input')
466
+ self.cb.put(
467
+ "However, adding custom message requires Line cookies, and it is not supplied"
468
+ )
469
+ self.cb.put("Continuing without adding custom text to stickers")
470
+
471
+ self.download_multiple_files(custom_sticker_text_urls, headers=self.headers)
472
+ self.combine_custom_text()
473
+
474
+ return len(self.pack_files), len(self.pack_files)
475
+
476
+ @staticmethod
477
+ def start(
478
+ opt_input: InputOption,
479
+ opt_cred: Optional[CredOption],
480
+ cb: CallbackProtocol,
481
+ cb_return: CallbackReturn,
482
+ ) -> Tuple[int, int]:
483
+ downloader = DownloadLine(opt_input, opt_cred, cb, cb_return)
484
+ return downloader.download_stickers_line()