appcask 0.1.0

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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 84b4342166055f34caca13c015782050cc53a3b822def01f171d5b7a3b46cfd6
4
+ data.tar.gz: 2d3a8ae4795e748adcc170cb9a718604fe121b1342a87dc562a6c7cf7db62ad2
5
+ SHA512:
6
+ metadata.gz: d352aaccc7f028d535de981d1381d924f9e01c572cd5ccebd37950ce645625f22c2b38cd0eb0aa75cc8c6fbaec0b930d6659e3e0e0438323b1af8a86460f1685
7
+ data.tar.gz: e7f972f9abfafb77b5e8bb62af302b51eb61e3e0702a08979941265f7a52d7dda260a848c6d2eca8448d4bfd5fa7d5ec207c2b4ebe60e43bf74c89a06f41ab00
@@ -0,0 +1,86 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <project version="4">
3
+ <component name="AutoImportSettings">
4
+ <option name="autoReloadType" value="SELECTIVE" />
5
+ </component>
6
+ <component name="ChangeListManager">
7
+ <list default="true" id="1bd4c817-c426-471c-bdf5-e80649db2ae2" name="更改" comment="">
8
+ <change afterPath="$PROJECT_DIR$/.github/workflows/main.yml" afterDir="false" />
9
+ <change afterPath="$PROJECT_DIR$/.gitignore" afterDir="false" />
10
+ <change afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
11
+ <change afterPath="$PROJECT_DIR$/.rubocop.yml" afterDir="false" />
12
+ <change afterPath="$PROJECT_DIR$/CHANGELOG.md" afterDir="false" />
13
+ <change afterPath="$PROJECT_DIR$/CODE_OF_CONDUCT.md" afterDir="false" />
14
+ <change afterPath="$PROJECT_DIR$/Gemfile" afterDir="false" />
15
+ <change afterPath="$PROJECT_DIR$/LICENSE.txt" afterDir="false" />
16
+ <change afterPath="$PROJECT_DIR$/README.md" afterDir="false" />
17
+ <change afterPath="$PROJECT_DIR$/Rakefile" afterDir="false" />
18
+ <change afterPath="$PROJECT_DIR$/appcask.gemspec" afterDir="false" />
19
+ <change afterPath="$PROJECT_DIR$/bin/console" afterDir="false" />
20
+ <change afterPath="$PROJECT_DIR$/bin/setup" afterDir="false" />
21
+ <change afterPath="$PROJECT_DIR$/examples/batch_download.rb" afterDir="false" />
22
+ <change afterPath="$PROJECT_DIR$/lib/appcask.rb" afterDir="false" />
23
+ <change afterPath="$PROJECT_DIR$/lib/appcask/version.rb" afterDir="false" />
24
+ <change afterPath="$PROJECT_DIR$/sig/appcask.rbs" afterDir="false" />
25
+ <change afterPath="$PROJECT_DIR$/test/test_appcask.rb" afterDir="false" />
26
+ <change afterPath="$PROJECT_DIR$/test/test_helper.rb" afterDir="false" />
27
+ </list>
28
+ <option name="SHOW_DIALOG" value="false" />
29
+ <option name="HIGHLIGHT_CONFLICTS" value="true" />
30
+ <option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
31
+ <option name="LAST_RESOLUTION" value="IGNORE" />
32
+ </component>
33
+ <component name="Git.Settings">
34
+ <option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
35
+ </component>
36
+ <component name="ProjectColorInfo"><![CDATA[{
37
+ "associatedIndex": 3
38
+ }]]></component>
39
+ <component name="ProjectId" id="38w4u8bpa7evnl9Tft1pg9mpNsJ" />
40
+ <component name="ProjectViewState">
41
+ <option name="hideEmptyMiddlePackages" value="true" />
42
+ <option name="showLibraryContents" value="true" />
43
+ </component>
44
+ <component name="PropertiesComponent"><![CDATA[{
45
+ "keyToString": {
46
+ "ASKED_SHARE_PROJECT_CONFIGURATION_FILES": "true",
47
+ "ModuleVcsDetector.initialDetectionPerformed": "true",
48
+ "RunOnceActivity.ShowReadmeOnStart": "true",
49
+ "RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
50
+ "RunOnceActivity.git.unshallow": "true",
51
+ "RunOnceActivity.typescript.service.memoryLimit.init": "true",
52
+ "git-widget-placeholder": "main",
53
+ "node.js.detected.package.eslint": "true",
54
+ "node.js.detected.package.tslint": "true",
55
+ "node.js.selected.package.eslint": "(autodetect)",
56
+ "node.js.selected.package.tslint": "(autodetect)",
57
+ "nodejs_package_manager_path": "npm",
58
+ "ruby.structure.view.model.defaults.configured": "true",
59
+ "settings.editor.selected.configurable": "preferences.lookFeel",
60
+ "vue.rearranger.settings.migration": "true"
61
+ }
62
+ }]]></component>
63
+ <component name="SharedIndexes">
64
+ <attachedChunks>
65
+ <set>
66
+ <option value="bundled-js-predefined-d6986cc7102b-9b0f141eb926-JavaScript-RM-253.30387.79" />
67
+ </set>
68
+ </attachedChunks>
69
+ </component>
70
+ <component name="SpringUtil" SPRING_PRE_LOADER_OPTION="true" RAKE_SPRING_PRE_LOADER_OPTION="true" RAILS_SPRING_PRE_LOADER_OPTION="true" />
71
+ <component name="TaskManager">
72
+ <task active="true" id="Default" summary="Default task">
73
+ <changelist id="1bd4c817-c426-471c-bdf5-e80649db2ae2" name="更改" comment="" />
74
+ <created>1767270112011</created>
75
+ <option name="number" value="Default" />
76
+ <option name="presentableId" value="Default" />
77
+ <updated>1767270112011</updated>
78
+ <workItem from="1767270116395" duration="20000" />
79
+ <workItem from="1769697520161" duration="3786000" />
80
+ </task>
81
+ <servers />
82
+ </component>
83
+ <component name="TypeScriptGeneratedFilesManager">
84
+ <option name="version" value="3" />
85
+ </component>
86
+ </project>
data/CHANGELOG.md ADDED
@@ -0,0 +1,29 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ ## [0.1.0] - 2026-01-29
6
+
7
+ ### 🎉 AppCask 首次发布
8
+
9
+ #### 🆕 下载模式
10
+ - ✨ **图标下载** - 4 种尺寸可选 (60x60 到 1024x1024)
11
+ - 📸 **截图下载** - 支持 iPhone 和 iPad 截图批量下载
12
+ - 📝 **应用信息导出** - 3 种格式 (TXT/JSON/Markdown)
13
+ - 📦 **完整包下载** - 一键下载所有资源
14
+
15
+ #### 📋 应用信息字段
16
+ - 基本信息:名称、ID、Bundle ID、开发者
17
+ - 版本信息:版本号、文件大小、系统要求、支持设备
18
+ - 评分数据:平均评分、评分数量
19
+ - 价格信息:价格、货币
20
+ - 分类信息:主分类、所有分类
21
+ - 内容信息:应用描述、版本更新说明
22
+ - 链接信息:App Store 链接、开发者网站
23
+
24
+ #### 🌍 更多区域
25
+ 新增支持:
26
+ - 🇹🇼 台湾 (tw)
27
+ - 🇬🇧 英国 (gb)
28
+ - 🇩🇪 德国 (de)
29
+ - 🇫🇷 法国 (fr)
@@ -0,0 +1,10 @@
1
+ # Code of Conduct
2
+
3
+ "appcask" follows [The Ruby Community Conduct Guideline](https://www.ruby-lang.org/en/conduct) in all "collaborative space", which is defined as community communications channels (such as mailing lists, submitted patches, commit comments, etc.):
4
+
5
+ * Participants will be tolerant of opposing views.
6
+ * Participants must ensure that their language and actions are free of personal attacks and disparaging personal remarks.
7
+ * When interpreting the words and actions of others, participants should always assume good intentions.
8
+ * Behaviour which can be reasonably considered harassment will not be tolerated.
9
+
10
+ If you have any concerns about behaviour within this project, please contact us at ["TODO: Write your email address"](mailto:"TODO: Write your email address").
data/LICENSE.txt ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2026 Billow Wang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,249 @@
1
+ # AppCask - App Store 资源下载工具
2
+
3
+ <div align="center">
4
+
5
+ ![Version](https://img.shields.io/badge/version-0.1.0-blue.svg)
6
+ ![Ruby](https://img.shields.io/badge/ruby-%3E%3D%202.5.0-red.svg)
7
+ ![License](https://img.shields.io/badge/license-MIT-green.svg)
8
+
9
+ 一个全能的命令行工具,用于下载 iOS App Store 的应用资源
10
+ **图标 · 截图 · 应用简介 · 一键打包**
11
+
12
+ [功能特性](#-功能特性) • [安装](#-安装) • [使用指南](#-使用指南) • [示例](#-使用示例)
13
+
14
+ </div>
15
+
16
+ ---
17
+
18
+ ## ✨ 功能特性
19
+
20
+ ### 🎨 图标下载
21
+ - 支持 4 种尺寸:60x60、100x100、512x512、1024x1024
22
+ - 自动检测图片格式 (PNG/JPG/GIF/WEBP)
23
+ - 智能文件命名
24
+
25
+ ### 📸 截图下载
26
+ - iPhone 截图
27
+ - iPad 截图
28
+ - 批量下载所有截图
29
+ - 自动分类保存
30
+
31
+ ### 📝 应用信息导出
32
+ - **TXT 格式** - 易读的文本文件
33
+ - **JSON 格式** - 结构化数据
34
+ - **Markdown 格式** - 精美的文档
35
+
36
+ 包含信息:
37
+ - 基本信息(名称、开发者、Bundle ID)
38
+ - 版本信息(当前版本、文件大小、系统要求)
39
+ - 评分统计(平均分、评分数量)
40
+ - 价格信息
41
+ - 应用描述
42
+ - 版本更新说明
43
+ - 相关链接
44
+
45
+ ### 📦 一键完整包
46
+ 下载应用的所有资源,包括:
47
+ - 所有尺寸的图标
48
+ - 所有设备的截图
49
+ - 完整的应用信息(3种格式)
50
+
51
+ ### 🌍 多区域支持
52
+ - 🇺🇸 美国 (us)
53
+ - 🇨🇳 中国 (cn)
54
+ - 🇯🇵 日本 (jp)
55
+ - 🇰🇷 韩国 (kr)
56
+ - 🇭🇰 香港 (hk)
57
+ - 🇹🇼 台湾 (tw)
58
+ - 🇬🇧 英国 (gb)
59
+ - 🇩🇪 德国 (de)
60
+ - 🇫🇷 法国 (fr)
61
+
62
+ ---
63
+
64
+ ## 📦 安装
65
+
66
+ ```bash
67
+ gem install appcask
68
+ ```
69
+
70
+ 或从源码安装:
71
+
72
+ ```bash
73
+ git clone https://github.com/yourusername/appcask.git
74
+ cd appcask
75
+ bundle install
76
+ rake build
77
+ gem install pkg/appcask-1.0.0.gem
78
+ ```
79
+
80
+ ---
81
+
82
+ ## 🚀 快速开始
83
+
84
+ ```bash
85
+ # 交互模式
86
+ appcask
87
+
88
+ # 快速搜索
89
+ appcask "Instagram"
90
+
91
+ # 指定区域
92
+ appcask "微信" cn
93
+ ```
94
+
95
+ ### 完整演示
96
+
97
+ ```
98
+ $ appcask "Twitter"
99
+
100
+ ╔═══════════════════════════════════════════╗
101
+ ║ AppCask - App 资源下载工具 ║
102
+ ║ v1.0.0 ║
103
+ ╚═══════════════════════════════════════════╝
104
+
105
+ 🔍 正在搜索 "Twitter"...
106
+
107
+ 📋 找到 3 个结果:
108
+
109
+ [0] X
110
+ 开发者: X Corp. | 版本: 10.31
111
+ 价格: Free | 评分: ⭐ 4.2
112
+
113
+ 请选择 (0-2, 或按q退出): 0
114
+
115
+ ✅ 已选择: X
116
+
117
+ 📦 选择下载内容:
118
+ [1] 图标
119
+ [2] 截图
120
+ [3] 简介信息
121
+ [4] 完整包(图标+截图+简介)
122
+
123
+ 请选择 (1-4): 4
124
+
125
+ ✨ 下载完成!
126
+ 📁 ~/Desktop/AppCask Downloads/X
127
+ 📊 统计: 15 个文件, 总大小 8.45 MB
128
+ ```
129
+
130
+ ---
131
+
132
+ ## 📖 使用示例
133
+
134
+ ### 仅下载图标
135
+
136
+ ```bash
137
+ appcask "Instagram"
138
+ # 选择: [1] 图标 → [3] 1024x1024
139
+ ```
140
+
141
+ ### 下载所有截图
142
+
143
+ ```bash
144
+ appcask "王者荣耀" cn
145
+ # 选择: [2] 截图 → all
146
+ ```
147
+
148
+ ### 导出应用信息
149
+
150
+ ```bash
151
+ appcask "Notion"
152
+ # 选择: [3] 简介信息
153
+ # 输出: TXT + JSON + Markdown
154
+ ```
155
+
156
+ ### 批量下载
157
+
158
+ ```ruby
159
+ #!/usr/bin/env ruby
160
+
161
+ apps = ['Instagram', 'Twitter', 'Facebook']
162
+
163
+ apps.each do |app|
164
+ system("appcask '#{app}'")
165
+ sleep 2
166
+ end
167
+ ```
168
+
169
+ ---
170
+
171
+ ## 📂 文件结构
172
+
173
+ ```
174
+ AppCask Downloads/
175
+ └── Instagram/
176
+ ├── icons/
177
+ │ ├── icon-60x60.png
178
+ │ ├── icon-100x100.png
179
+ │ ├── icon-512x512.png
180
+ │ └── icon-1024x1024.png
181
+ ├── screenshots/
182
+ │ ├── iPhone/
183
+ │ └── iPad/
184
+ ├── app_info.txt
185
+ ├── app_info.json
186
+ └── README.md
187
+ ```
188
+
189
+ ---
190
+
191
+ ## 🔧 高级功能
192
+
193
+ ### Debug 模式
194
+
195
+ ```bash
196
+ DEBUG=1 appcask "AppName"
197
+ ```
198
+
199
+ ### 快捷操作
200
+
201
+ - **回车** - 默认选项
202
+ - **q** - 退出
203
+ - **Ctrl+C** - 中断
204
+
205
+ ---
206
+
207
+ ## 🐛 故障排除
208
+
209
+ **Q: 搜索不到应用?**
210
+ A: 确认拼写、尝试切换区域
211
+
212
+ **Q: 图标尺寸不对?**
213
+ A: 部分应用不支持 1024x1024
214
+
215
+ **Q: 网络超时?**
216
+ A: 检查网络、使用 VPN
217
+
218
+ **Q: 文件保存在哪?**
219
+ A: `~/Desktop/AppCask Downloads/`
220
+
221
+ ---
222
+
223
+ ## 🎯 路线图
224
+
225
+ - [ ] macOS App Store 支持
226
+ - [ ] 应用评论下载
227
+ - [ ] 批量下载模式
228
+ - [ ] Web 界面
229
+
230
+ ---
231
+
232
+ ## 📄 许可证
233
+
234
+ MIT License
235
+
236
+ ---
237
+
238
+ ## 📮 联系
239
+
240
+ - GitHub: [@yourusername](https://github.com/yourusername)
241
+ - Issues: [反馈问题](https://github.com/yourusername/appcask/issues)
242
+
243
+ ---
244
+
245
+ <div align="center">
246
+
247
+ **Made with ❤️ and Ruby**
248
+
249
+ </div>
data/Rakefile ADDED
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "bundler/gem_tasks"
4
+ require "minitest/test_task"
5
+
6
+ Minitest::TestTask.create
7
+
8
+ require "rubocop/rake_task"
9
+
10
+ RuboCop::RakeTask.new
11
+
12
+ task default: %i[test rubocop]
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env ruby
2
+ # AppCask batch download example
3
+ # Usage: ruby examples/batch_download.rb
4
+
5
+ require 'appcask'
6
+
7
+ # Define the list of apps to download
8
+ apps = [
9
+ { name: 'Instagram', region: 'us', description: 'Social media app' },
10
+ { name: 'Twitter', region: 'us', description: 'Microblogging platform' },
11
+ { name: 'WeChat', region: 'cn', description: 'Instant messaging app' },
12
+ { name: 'LINE', region: 'jp', description: 'Messaging app popular in Japan' },
13
+ { name: 'KakaoTalk', region: 'kr', description: 'Messaging app popular in Korea' }
14
+ ]
15
+
16
+ puts '=' * 50
17
+ puts 'AppCask Batch Download Script'
18
+ puts '=' * 50
19
+ puts "\nPreparing to download resources for #{apps.size} apps...\n\n"
20
+
21
+ apps.each_with_index do |app, index|
22
+ puts "\n[#{index + 1}/#{apps.size}] Processing: #{app[:name]} (#{app[:description]})"
23
+ puts '-' * 50
24
+
25
+ # Set command-line arguments
26
+ ARGV.clear
27
+ ARGV << app[:name] << app[:region]
28
+
29
+ begin
30
+ # Run AppCask
31
+ AppCask.main
32
+ puts "✅ #{app[:name]} downloaded successfully."
33
+ rescue StandardError => e
34
+ puts "❌ Failed to download #{app[:name]}: #{e.message}"
35
+ end
36
+
37
+ # Pause to avoid sending requests too frequently
38
+ sleep 2 unless index == apps.size - 1
39
+ end
40
+
41
+ puts "\n" + '=' * 50
42
+ puts 'Batch download completed!'
43
+ puts '=' * 50
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Appcask
4
+ VERSION = "0.1.0"
5
+ end
data/lib/appcask.rb ADDED
@@ -0,0 +1,627 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'appcask/version'
4
+ require 'net/https'
5
+ require 'open-uri'
6
+ require 'json'
7
+ require 'fileutils'
8
+
9
+ module AppCask
10
+ class Error < StandardError; end
11
+
12
+ ICON_SIZES = {
13
+ '0' => { display: '60x60', key: 'artworkUrl60' },
14
+ '1' => { display: '100x100', key: 'artworkUrl100' },
15
+ '2' => { display: '512x512', key: 'artworkUrl512' },
16
+ '3' => { display: '1024x1024', key: 'artworkUrl512' }
17
+ }.freeze
18
+
19
+ SCREENSHOT_DEVICES = {
20
+ 'iphone' => 'iPhone screenshots',
21
+ 'ipad' => 'iPad screenshots'
22
+ }.freeze
23
+
24
+ COUNTRIES = {
25
+ 'us' => 'United States',
26
+ 'cn' => 'China',
27
+ 'jp' => 'Japan',
28
+ 'kr' => 'South Korea',
29
+ 'hk' => 'Hong Kong',
30
+ 'tw' => 'Taiwan',
31
+ 'gb' => 'United Kingdom',
32
+ 'de' => 'Germany',
33
+ 'fr' => 'France'
34
+ }.freeze
35
+
36
+ DOWNLOAD_MODES = {
37
+ '1' => { name: 'Icon Only', method: :download_icon },
38
+ '2' => { name: 'Screenshots Only', method: :download_screenshots },
39
+ '3' => { name: 'Description Only', method: :download_description },
40
+ '4' => { name: 'All Assets', method: :download_all }
41
+ }.freeze
42
+
43
+ class << self
44
+ def main
45
+ begin
46
+ get
47
+ rescue Interrupt
48
+ puts "\n\n👋 Goodbye!"
49
+ exit 0
50
+ rescue StandardError => e
51
+ warn "\n❌ Error: #{e.message}"
52
+ warn e.backtrace if ENV['DEBUG']
53
+ exit 1
54
+ end
55
+ end
56
+
57
+ def get
58
+ show_banner
59
+
60
+ app_name = get_app_name
61
+ country = get_country
62
+
63
+ puts "\n🔍 Searching for \"#{app_name}\"..."
64
+
65
+ results = search_app(app_name, country)
66
+
67
+ if results.nil? || results['resultCount'].to_i.zero?
68
+ warn "❌ No apps found for \"#{app_name}\"."
69
+ return
70
+ end
71
+
72
+ selected_app = select_app(results)
73
+ return unless selected_app
74
+
75
+ mode = select_download_mode
76
+ return unless mode
77
+
78
+ send(mode[:method], selected_app, country)
79
+ end
80
+
81
+ private
82
+
83
+ def show_banner
84
+ puts <<~BANNER
85
+ ▄▖ ▄▖ ▌
86
+ ▌▌▛▌▛▌▌ ▀▌▛▘▙▘
87
+ ▛▌▙▌▙▌▙▖█▌▄▌▛▖
88
+ ▌ ▌
89
+
90
+ v#{AppCask::VERSION}
91
+ BANNER
92
+ end
93
+
94
+ def get_app_name
95
+ if ARGV.count.positive?
96
+ ARGV[0]
97
+ else
98
+ print '📱 Enter the app name to search: '
99
+ $stdin.gets.chomp.strip
100
+ end
101
+ end
102
+
103
+ def get_country
104
+ return ARGV[1] if ARGV.count > 1 && COUNTRIES.key?(ARGV[1])
105
+
106
+ puts "\n🌍 Select App Store region:"
107
+ COUNTRIES.each_slice(3) do |slice|
108
+ puts " " + slice.map { |code, name| "#{code.ljust(4)}- #{name}" }.join(" ")
109
+ end
110
+ print "Choose one (default: us): "
111
+
112
+ input = $stdin.gets.chomp.strip.downcase
113
+ input.empty? ? 'us' : (COUNTRIES.key?(input) ? input : 'us')
114
+ end
115
+ def search_app(app_name, country)
116
+ url = URI('https://itunes.apple.com/search')
117
+ params = {
118
+ term: app_name,
119
+ country: country,
120
+ media: 'software',
121
+ entity: 'software',
122
+ limit: '20'
123
+ }
124
+
125
+ http = Net::HTTP.new(url.host, url.port)
126
+ http.use_ssl = true
127
+ http.open_timeout = 10
128
+ http.read_timeout = 10
129
+
130
+ request = Net::HTTP::Post.new(url)
131
+ request.set_form_data(params)
132
+
133
+ response = http.request(request)
134
+
135
+ unless response.is_a?(Net::HTTPSuccess)
136
+ warn "❌ Search failed: HTTP #{response.code}"
137
+ return nil
138
+ end
139
+
140
+ json = JSON.parse(response.body)
141
+
142
+ if json['resultCount'].zero?
143
+ warn '😕 No apps found. Please try a different keyword.'
144
+ return nil
145
+ end
146
+
147
+ json
148
+
149
+ rescue JSON::ParserError => e
150
+ warn "❌ Failed to parse response: #{e.message}"
151
+ nil
152
+ rescue Net::OpenTimeout, Net::ReadTimeout
153
+ warn '❌ Network timeout. Please check your connection.'
154
+ nil
155
+ rescue StandardError => e
156
+ warn "❌ Search error: #{e.message}"
157
+ nil
158
+ end
159
+
160
+ def select_app(results)
161
+ puts "\n📋 Found #{results['resultCount']} result(s):\n\n"
162
+
163
+ results['results'].each_with_index do |item, index|
164
+ developer = item['artistName'] || 'Unknown Developer'
165
+ price = item['formattedPrice'] || item['price']
166
+ version = item['version'] || 'N/A'
167
+ rating = item['averageUserRating'] ? "⭐ #{item['averageUserRating'].round(1)}" : 'No Rating'
168
+
169
+ puts " [#{index}] #{item['trackCensoredName']}"
170
+ puts " Developer: #{developer} | Version: #{version}"
171
+ puts " Price: #{price} | Rating: #{rating}"
172
+ puts
173
+ end
174
+
175
+ print "Select an app (0-#{results['resultCount'] - 1}, or press q to quit): "
176
+ input = $stdin.gets.chomp.strip
177
+
178
+ return nil if input.downcase == 'q'
179
+
180
+ index = input.to_i
181
+
182
+ unless valid_index?(index, results['resultCount'])
183
+ warn "❌ Invalid selection."
184
+ return nil
185
+ end
186
+
187
+ selected = results['results'][index]
188
+ puts "\n✅ Selected: #{selected['trackCensoredName']}"
189
+ selected
190
+ end
191
+
192
+ def select_download_mode
193
+ puts "\n📦 Select download content:\n"
194
+ DOWNLOAD_MODES.each do |key, value|
195
+ puts " [#{key}] #{value[:name]}"
196
+ end
197
+
198
+ print "\nChoose an option (1-4): "
199
+ input = $stdin.gets.chomp.strip
200
+
201
+ unless DOWNLOAD_MODES.key?(input)
202
+ warn "❌ Invalid selection."
203
+ return nil
204
+ end
205
+
206
+ DOWNLOAD_MODES[input]
207
+ end
208
+
209
+ def download_icon(app_info, country)
210
+ puts "\n🎨 Downloading app icon\n"
211
+
212
+ size_info = select_icon_size
213
+ return unless size_info
214
+
215
+ artwork_url = app_info[size_info[:key]]
216
+
217
+ unless artwork_url
218
+ puts "❌ This app does not provide an icon at the selected size."
219
+ return
220
+ end
221
+
222
+ # If 1024x1024 is selected, replace 512 with 1024 in the URL
223
+ if size_info[:display].include?('1024')
224
+ artwork_url = artwork_url.gsub('512x512', '1024x1024')
225
+ end
226
+
227
+ puts "\n⬇️ Downloading icon..."
228
+
229
+ download_dir = create_app_directory(app_info, 'icons')
230
+ file_name = "icon-#{size_info[:display].split(' ').first}"
231
+
232
+ download_image(artwork_url, download_dir, file_name)
233
+ end
234
+
235
+ def download_screenshots(app_info, country)
236
+ puts "\n📸 Downloading app screenshots\n"
237
+
238
+ iphone_urls = app_info['screenshotUrls'] || []
239
+ ipad_urls = app_info['ipadScreenshotUrls'] || []
240
+
241
+ if iphone_urls.empty? && ipad_urls.empty?
242
+ puts "❌ This app does not provide any screenshots."
243
+ return
244
+ end
245
+
246
+ puts "Available screenshots:"
247
+ puts " iPhone: #{iphone_urls.count}" unless iphone_urls.empty?
248
+ puts " iPad: #{ipad_urls.count}" unless ipad_urls.empty?
249
+
250
+ print "\nWhich device screenshots would you like to download? (iphone/ipad/all, default: all): "
251
+ device = $stdin.gets.chomp.strip.downcase
252
+ device = 'all' if device.empty?
253
+
254
+ download_dir = create_app_directory(app_info, 'screenshots')
255
+
256
+ case device
257
+ when 'iphone'
258
+ download_screenshot_set(iphone_urls, download_dir, 'iPhone')
259
+ when 'ipad'
260
+ download_screenshot_set(ipad_urls, download_dir, 'iPad')
261
+ else
262
+ download_screenshot_set(iphone_urls, download_dir, 'iPhone') unless iphone_urls.empty?
263
+ download_screenshot_set(ipad_urls, download_dir, 'iPad') unless ipad_urls.empty?
264
+ end
265
+ end
266
+
267
+ def download_description(app_info, country)
268
+ puts "\n📝 Saving app information\n"
269
+
270
+ download_dir = create_app_directory(app_info, 'info')
271
+
272
+ # Generate detailed text information
273
+ info_text = generate_app_info_text(app_info)
274
+
275
+ # Generate JSON data
276
+ info_json = generate_app_info_json(app_info)
277
+
278
+ # Save as TXT
279
+ txt_file = File.join(download_dir, "app_info.txt")
280
+ File.open(txt_file, 'w:UTF-8') { |f| f.write(info_text) }
281
+
282
+ # Save as JSON
283
+ json_file = File.join(download_dir, "app_info.json")
284
+ File.open(json_file, 'w:UTF-8') { |f| f.write(JSON.pretty_generate(info_json)) }
285
+
286
+ # Save as Markdown
287
+ md_file = File.join(download_dir, "README.md")
288
+ md_text = generate_app_info_markdown(app_info)
289
+ File.open(md_file, 'w:UTF-8') { |f| f.write(md_text) }
290
+
291
+ puts "✨ App information saved successfully!"
292
+ puts "📁 Directory: #{download_dir}"
293
+ puts " - app_info.txt (Plain text)"
294
+ puts " - app_info.json (JSON format)"
295
+ puts " - README.md (Markdown format)"
296
+
297
+ # On macOS, ask whether to open the directory
298
+ if RUBY_PLATFORM.include?('darwin')
299
+ print "\nOpen the folder now? (y/n): "
300
+ response = $stdin.gets.chomp.strip.downcase
301
+ system("open '#{download_dir}'") if response == 'y'
302
+ end
303
+ end
304
+
305
+ def download_all(app_info, country)
306
+ puts "\n📦 Downloading full package (icons + screenshots + app info)\n"
307
+
308
+ base_dir = create_app_directory(app_info)
309
+
310
+ # 1. Download all icon sizes
311
+ puts "\n[1/3] 📥 Downloading icons..."
312
+ icon_dir = File.join(base_dir, 'icons')
313
+ FileUtils.mkdir_p(icon_dir)
314
+
315
+ ICON_SIZES.each do |_key, size_info|
316
+ artwork_url = app_info[size_info[:key]]
317
+ next unless artwork_url
318
+
319
+ url =
320
+ if size_info[:display].include?('1024')
321
+ artwork_url.gsub('512x512', '1024x1024')
322
+ else
323
+ artwork_url
324
+ end
325
+
326
+ filename = "icon-#{size_info[:display].split(' ').first}"
327
+ download_image(url, icon_dir, filename, silent: true)
328
+ end
329
+
330
+ # 2. Download all screenshots
331
+ puts "\n[2/3] 📥 Downloading screenshots..."
332
+ screenshot_dir = File.join(base_dir, 'screenshots')
333
+ FileUtils.mkdir_p(screenshot_dir)
334
+
335
+ iphone_urls = app_info['screenshotUrls'] || []
336
+ ipad_urls = app_info['ipadScreenshotUrls'] || []
337
+
338
+ download_screenshot_set(iphone_urls, screenshot_dir, 'iPhone', silent: true) unless iphone_urls.empty?
339
+ download_screenshot_set(ipad_urls, screenshot_dir, 'iPad', silent: true) unless ipad_urls.empty?
340
+
341
+ # 3. Save app information
342
+ puts "\n[3/3] 📥 Saving app information..."
343
+
344
+ info_text = generate_app_info_text(app_info)
345
+ info_json = generate_app_info_json(app_info)
346
+ info_md = generate_app_info_markdown(app_info)
347
+
348
+ File.open(File.join(base_dir, 'app_info.txt'), 'w:UTF-8') { |f| f.write(info_text) }
349
+ File.open(File.join(base_dir, 'app_info.json'), 'w:UTF-8') { |f| f.write(JSON.pretty_generate(info_json)) }
350
+ File.open(File.join(base_dir, 'README.md'), 'w:UTF-8') { |f| f.write(info_md) }
351
+
352
+ puts "\n✨ Download completed!"
353
+ puts "📁 All files saved to: #{base_dir}"
354
+
355
+ puts "\nDirectory structure:"
356
+ puts " ├── icons/ (App icons)"
357
+ puts " ├── screenshots/ (App screenshots)"
358
+ puts " ├── app_info.txt (Plain text)"
359
+ puts " ├── app_info.json (JSON format)"
360
+ puts " └── README.md (Markdown)"
361
+
362
+ # Statistics
363
+ files = Dir.glob(File.join(base_dir, '**', '*')).select { |f| File.file?(f) }
364
+ total_files = files.count
365
+ total_size = (files.sum { |f| File.size(f) } / 1024.0 / 1024.0).round(2)
366
+
367
+ puts "\n📊 Summary: #{total_files} files, total size #{total_size} MB"
368
+
369
+ # On macOS, ask whether to open the directory
370
+ if RUBY_PLATFORM.include?('darwin')
371
+ print "\nOpen the folder now? (y/n): "
372
+ response = $stdin.gets.chomp.strip.downcase
373
+ system("open '#{base_dir}'") if response == 'y'
374
+ end
375
+ end
376
+
377
+ def select_icon_size
378
+ puts "\n📐 Select icon size:\n"
379
+ ICON_SIZES.each { |key, value| puts " [#{key}] #{value[:display]}" }
380
+
381
+ print "\nSelect (0-3, default: 2): "
382
+ input = $stdin.gets.chomp.strip
383
+ input = '2' if input.empty?
384
+
385
+ unless ICON_SIZES.key?(input)
386
+ warn "❌ Invalid selection."
387
+ return nil
388
+ end
389
+
390
+ ICON_SIZES[input]
391
+ end
392
+
393
+ def download_screenshot_set(urls, base_dir, device_name, silent: false)
394
+ return if urls.empty?
395
+
396
+ puts "\nDownloading #{device_name} screenshots (#{urls.count})..." unless silent
397
+
398
+ device_dir = File.join(base_dir, device_name)
399
+ FileUtils.mkdir_p(device_dir)
400
+
401
+ urls.each_with_index do |url, index|
402
+ filename = "screenshot-#{device_name}-#{index + 1}"
403
+ download_image(url, device_dir, filename, silent: silent)
404
+ end
405
+
406
+ puts "\n✅ #{device_name} screenshots downloaded" unless silent
407
+ end
408
+
409
+ def download_image(url, dir, basename, silent: false)
410
+ uri = URI(url)
411
+
412
+ URI.open(uri, ssl_verify_mode: OpenSSL::SSL::VERIFY_NONE) do |image|
413
+ content = image.read
414
+ ext = detect_image_extension(content)
415
+
416
+ filename = File.join(dir, "#{basename}.#{ext}")
417
+ filename = get_unique_filename(filename)
418
+
419
+ File.open(filename, 'wb') { |f| f.write(content) }
420
+
421
+ unless silent
422
+ file_size = (File.size(filename) / 1024.0).round(2)
423
+ puts "\n✅ Saved: #{File.basename(filename)} (#{file_size} KB)"
424
+ end
425
+ end
426
+ rescue StandardError => e
427
+ warn "❌ Download failed: #{e.message}" unless silent
428
+ end
429
+
430
+ def generate_app_info_text(app_info)
431
+ <<~INFO
432
+ ===================================================
433
+ APPLICATION DETAILS
434
+ ===================================================
435
+
436
+ [Basic Information]
437
+ App Name: #{app_info['trackCensoredName']}
438
+ App ID: #{app_info['trackId']}
439
+ Bundle ID: #{app_info['bundleId']}
440
+ Developer: #{app_info['artistName']}
441
+ Developer ID: #{app_info['artistId']}
442
+
443
+ [Version Information]
444
+ Current Version: #{app_info['version']}
445
+ File Size: #{(app_info['fileSizeBytes'].to_i / 1024.0 / 1024.0).round(2)} MB
446
+ Minimum OS Requirement: iOS #{app_info['minimumOsVersion']}
447
+ Supported Devices: #{app_info['supportedDevices']&.join(', ') || 'N/A'}
448
+
449
+ [Pricing & Ratings]
450
+ Price: #{app_info['formattedPrice'] || app_info['price']}
451
+ Currency: #{app_info['currency']}
452
+ Rating: #{app_info['averageUserRating'] || 'N/A'} (#{app_info['userRatingCount'] || 0} ratings)
453
+
454
+ [Categories]
455
+ Primary Category: #{app_info['primaryGenreName']}
456
+ All Categories: #{app_info['genres']&.join(', ')}
457
+
458
+ [Release Information]
459
+ First Released: #{app_info['releaseDate']}
460
+ Last Updated: #{app_info['currentVersionReleaseDate']}
461
+ Content Rating: #{app_info['contentAdvisoryRating']}
462
+
463
+ [Description]
464
+ #{app_info['description']}
465
+
466
+ [Release Notes]
467
+ #{app_info['releaseNotes'] || 'No release notes provided.'}
468
+
469
+ [Developer Information]
470
+ Developer Website: #{app_info['sellerUrl']}
471
+
472
+ [Store Link]
473
+ App Store: #{app_info['trackViewUrl']}
474
+
475
+ ===================================================
476
+ Exported At: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}
477
+ ===================================================
478
+ INFO
479
+ end
480
+
481
+ def generate_app_info_json(app_info)
482
+ {
483
+ basic: {
484
+ name: app_info['trackCensoredName'],
485
+ app_id: app_info['trackId'],
486
+ bundle_id: app_info['bundleId'],
487
+ developer: app_info['artistName'],
488
+ developer_id: app_info['artistId']
489
+ },
490
+ version: {
491
+ current_version: app_info['version'],
492
+ file_size_bytes: app_info['fileSizeBytes'],
493
+ file_size_mb: (app_info['fileSizeBytes'].to_i / 1024.0 / 1024.0).round(2),
494
+ minimum_os_version: app_info['minimumOsVersion'],
495
+ supported_devices: app_info['supportedDevices']
496
+ },
497
+ pricing: {
498
+ price: app_info['price'],
499
+ formatted_price: app_info['formattedPrice'],
500
+ currency: app_info['currency']
501
+ },
502
+ ratings: {
503
+ average_rating: app_info['averageUserRating'],
504
+ rating_count: app_info['userRatingCount'],
505
+ rating_count_current_version: app_info['userRatingCountForCurrentVersion']
506
+ },
507
+ categories: {
508
+ primary_genre: app_info['primaryGenreName'],
509
+ all_genres: app_info['genres']
510
+ },
511
+ release: {
512
+ release_date: app_info['releaseDate'],
513
+ current_version_release_date: app_info['currentVersionReleaseDate'],
514
+ content_rating: app_info['contentAdvisoryRating']
515
+ },
516
+ description: app_info['description'],
517
+ release_notes: app_info['releaseNotes'],
518
+ urls: {
519
+ app_store: app_info['trackViewUrl'],
520
+ developer_website: app_info['sellerUrl']
521
+ },
522
+ screenshots: {
523
+ iphone: app_info['screenshotUrls'],
524
+ ipad: app_info['ipadScreenshotUrls']
525
+ },
526
+ exported_at: Time.now.iso8601
527
+ }
528
+ end
529
+
530
+ def generate_app_info_markdown(app_info)
531
+ <<~MARKDOWN
532
+ # #{app_info['trackCensoredName']}
533
+
534
+ > Developer: #{app_info['artistName']}
535
+
536
+ ![Rating](https://img.shields.io/badge/Rating-#{app_info['averageUserRating'] || 'N/A'}-blue)
537
+ ![Version](https://img.shields.io/badge/Version-#{app_info['version']}-green)
538
+ ![Price](https://img.shields.io/badge/Price-#{(app_info['formattedPrice'] || app_info['price']).gsub('-', '--')}-orange)
539
+
540
+ ## 📱 Basic Information
541
+
542
+ | Item | Value |
543
+ |------|-------|
544
+ | App ID | #{app_info['trackId']} |
545
+ | Bundle ID | #{app_info['bundleId']} |
546
+ | Developer | #{app_info['artistName']} |
547
+ | Primary Category | #{app_info['primaryGenreName']} |
548
+ | Content Rating | #{app_info['contentAdvisoryRating']} |
549
+
550
+ ## 📊 Version Information
551
+
552
+ - **Current Version**: #{app_info['version']}
553
+ - **File Size**: #{(app_info['fileSizeBytes'].to_i / 1024.0 / 1024.0).round(2)} MB
554
+ - **Minimum OS**: iOS #{app_info['minimumOsVersion']}
555
+ - **Release Date**: #{app_info['currentVersionReleaseDate']}
556
+
557
+ ## ⭐ Ratings
558
+
559
+ - **Average Rating**: #{app_info['averageUserRating'] || 'N/A'} / 5.0
560
+ - **Total Ratings**: #{app_info['userRatingCount'] || 0}
561
+
562
+ ## 📝 Description
563
+
564
+ #{app_info['description']}
565
+
566
+ ## 🆕 What's New
567
+
568
+ #{app_info['releaseNotes'] || 'No release notes provided.'}
569
+
570
+ ## 🔗 Links
571
+
572
+ - [App Store](#{app_info['trackViewUrl']})
573
+ - [Developer Website](#{app_info['sellerUrl']})
574
+
575
+ ---
576
+
577
+ *Exported at: #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}*
578
+ MARKDOWN
579
+ end
580
+
581
+ def detect_image_extension(content)
582
+ return 'png' if content[0..7] == "\x89PNG\r\n\x1A\n".b
583
+ return 'jpg' if content[0..1] == "\xFF\xD8".b
584
+ return 'gif' if content[0..2] == "GIF".b
585
+ return 'webp' if content[8..11] == "WEBP".b
586
+ 'jpg'
587
+ end
588
+
589
+ def sanitize_filename(filename)
590
+ filename.gsub(/[\/\\:*?"<>|]/, '_').strip
591
+ end
592
+
593
+ def create_app_directory(app_info, subdir = nil)
594
+ app_name = sanitize_filename(app_info['trackCensoredName'])
595
+
596
+ desktop = File.join(Dir.home, 'Desktop')
597
+ if Dir.exist?(desktop)
598
+ base_dir = File.join(desktop, 'AppCask Downloads', app_name)
599
+ else
600
+ base_dir = File.join(Dir.pwd, 'AppCask Downloads', app_name)
601
+ end
602
+
603
+ final_dir = subdir ? File.join(base_dir, subdir) : base_dir
604
+ FileUtils.mkdir_p(final_dir)
605
+ final_dir
606
+ end
607
+
608
+ def get_unique_filename(filename)
609
+ return filename unless File.exist?(filename)
610
+
611
+ dir = File.dirname(filename)
612
+ basename = File.basename(filename, '.*')
613
+ ext = File.extname(filename)
614
+
615
+ counter = 1
616
+ loop do
617
+ new_filename = File.join(dir, "#{basename}_#{counter}#{ext}")
618
+ return new_filename unless File.exist?(new_filename)
619
+ counter += 1
620
+ end
621
+ end
622
+
623
+ def valid_index?(index, max)
624
+ index >= 0 && index < max
625
+ end
626
+ end
627
+ end
data/sig/appcask.rbs ADDED
@@ -0,0 +1,4 @@
1
+ module Appcask
2
+ VERSION: String
3
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
4
+ end
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: appcask
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Billow Wang
8
+ bindir: exe
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: json
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - ">="
17
+ - !ruby/object:Gem::Version
18
+ version: '0'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - ">="
24
+ - !ruby/object:Gem::Version
25
+ version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: net-http
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - ">="
31
+ - !ruby/object:Gem::Version
32
+ version: '0'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - ">="
38
+ - !ruby/object:Gem::Version
39
+ version: '0'
40
+ description: AppCask is a CLI tool for downloading App Store icons, screenshots and
41
+ metadata.
42
+ email:
43
+ - netheadonline@gmail.com
44
+ executables: []
45
+ extensions: []
46
+ extra_rdoc_files: []
47
+ files:
48
+ - ".idea/workspace.xml"
49
+ - CHANGELOG.md
50
+ - CODE_OF_CONDUCT.md
51
+ - LICENSE.txt
52
+ - README.md
53
+ - Rakefile
54
+ - examples/batch_download.rb
55
+ - lib/appcask.rb
56
+ - lib/appcask/version.rb
57
+ - sig/appcask.rbs
58
+ homepage: https://github.com/gamepunk/appcask
59
+ licenses:
60
+ - MIT
61
+ metadata:
62
+ allowed_push_host: https://rubygems.org/
63
+ homepage_uri: https://github.com/gamepunk/appcask
64
+ source_code_uri: https://github.com/gamepunk/appcask
65
+ changelog_uri: https://github.com/gamepunk/appcask
66
+ rdoc_options: []
67
+ require_paths:
68
+ - lib
69
+ required_ruby_version: !ruby/object:Gem::Requirement
70
+ requirements:
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 3.2.0
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ requirements:
76
+ - - ">="
77
+ - !ruby/object:Gem::Version
78
+ version: '0'
79
+ requirements: []
80
+ rubygems_version: 4.0.3
81
+ specification_version: 4
82
+ summary: Download App Store app assets easily
83
+ test_files: []