#!/usr/bin/env ruby # Sales, still wet, not yet dry #make nice colors for terminal strings class String def red; colorize(self, "\e[1m\e[31m"); end def green; colorize(self, "\e[1m\e[32m"); end def dark_green; colorize(self, "\e[32m"); end def yellow; colorize(self, "\e[1m\e[33m"); end def blue; colorize(self, "\e[1m\e[34m"); end def dark_blue; colorize(self, "\e[34m"); end def pur; colorize(self, "\e[1m\e[35m"); end def colorize(text, color_code) "#{color_code}#{text}\e[0m" end def clean; self.gsub(/\n|\t|\r/, ' ').gsub(/[\(\)\/_-]/, ' ').squeeze(' ').strip end end #the usage of this program USAGE = <<-EGASU getting daily reports for the last 14 days: #{'sale get:daily'.green} getting daily reports for the last 90 days: #{'sale get:weekly'.green} compute alltime reports #{'sale daily'.green} #{'sale weekly'.green} get and present the last daily summary report #{'sale'.green} get and present a daily summary report for a date #{'sale YYYYMMDD'.green} EGASU #require all dependent gems require 'rubygems' require 'open-uri' require 'json' require 'date' require 'yaml' #load the version file V = YAML.load_file(File.join(File.dirname(__FILE__), %w[.. VERSION.yml])) VERSION = "#{V[:major]}.#{V[:minor]}.#{V[:patch]}" #load the iTunes Connect credentials from the sales.yml example_sales_yml_file = File.join(File.dirname(__FILE__), %w[.. sales.yml]) dest_sales_yml_file = "sales.yml" if File.exists? dest_sales_yml_file S = YAML.load_file("sales.yml") @username = S[:username] @password = S[:password] @vendorId = S[:vendorId] @convertTo = S[:convertTo] @beVerbose = S[:beVerbose] else puts "Please fill out Your iTunes Connect credentials in the".red + " sales.yml ".green + "file in the same dir where You run 'sale'.".red `cp #{example_sales_yml_file} sales.yml; ls -al` unless File.exists? dest_sales_yml_file abort end #prepare the java runtime classpath for the Autoingestion.class @classpath = File.join(File.dirname(__FILE__), %w[..]) #identifiers PRODUCT_TYPE_IDENTIFIER = { "1" => "Free or Paid Apps, iPhone and iPod Touch", "7" => "Updates, iPhone and iPod Touch", "IA1" => "In Apps Purchase", "IA9" => "In Apps Subscription", "IAY" => "Auto-Renewable Subscription", "1F" => "Free or Paid Apps (Universal)", "7F" => "Updates (Universal)", "1T" => "Free or Paid Apps, iPad", "7T" => "Updates, iPad", "F1" => "Free or Paid Apps, Mac OS", "F7" => "Updates, Mac OS", "FI1" => "In Apps Purchase, Mac OS", "1E" => "Custome iPhone and iPod Touch", "1EP" => "Custome iPad", "1EU" => "Custome Universal" } SALE_IDENTS = ["1", "1F", "1T", "F1"] INAPP_SALE_IDENTS = ["IA1", "IA9", "IAY", "FI1"] UPDATE_IDENTS = ["7", "7F", "7T", "F7"] # Parse sales report # the parser was copied and tweaked from github, https://github.com/siuying/itunes-auto-ingestion/blob/master/lib/itunes_ingestion/sales_report_parser.rb, thanks # the class was thrown away though, a simple method is so much cleaner in a simple program like this # # report - text based report form itunesconnect # # Returns array of hash, each hash contains one line of sales report def parse(report) lines = report.split("\n") #puts "lines: #{lines}" header = lines.shift # remove first line lines.collect do |line| #puts "line: #{line}" provider, country, sku, developer, title, version, product_type_id, units, developer_proceeds, begin_date, end_date, currency, country_code, currency_of_proceeds, apple_id, customer_price, promo_code, parent_id, subscription, period = line.split("\t") p = { :provider => provider.strip, :country => country.strip, :sku => sku.strip, :developer => developer.strip, :title => title.strip, :version => version.strip, :product_type_id => product_type_id.strip, :units => units.to_i, :developer_proceeds => developer_proceeds.to_f, :begin_date => Date.strptime(begin_date.strip, '%m/%d/%Y'), :end_date => Date.strptime(end_date.strip, '%m/%d/%Y'), :currency => currency.strip, :country_code => country_code.strip, :currency_of_proceeds => currency_of_proceeds.strip, :apple_id => apple_id.to_i, :customer_price => customer_price.to_f, :promo_code => promo_code.strip, :parent_id => parent_id.strip, :subscription => subscription.strip, :period => period } puts "parsing failed".red if p==nil p end #lines collect end #parse # Computes and presents reports # # reports - array of report files to compute # # def compute_and_present(reports) alltime_proceeds_per_currency = {} #currency is the key, value is the proceeds alltime_renewables = 0 alltime_apps = {} alltime_payed_units = 0 alltime_inapp_units = 0 alltime_free_units = 0 alltime_updated_units = 0 first_date = reports[0].split('_').last.split('.').first reports.each do |alltime_filename| puts "Processing #{alltime_filename}".green if @beVerbose #get the date from the filename date = alltime_filename.split('_').last.split('.').first #filename example: S_D_80076793_20120706.txt report_data = File.open(alltime_filename, "rb").read report = parse(report_data) #puts report.class if report #report parsed apps = {} total_payed_units = 0 total_inapp_units = 0 total_free_units = 0 total_updated_units = 0 report.each do |item| #report is a hash if item sku = item[:sku] #group data by app sku if apps.has_key? sku #app is already cached app = apps[sku] else #initially insert app app = {:sku=>sku, :title=>item[:title], :sold_units=>0, :updated_units=>0} apps[sku] = app end #ensure currency sum alltime_proceeds_per_currency[item[:currency_of_proceeds]] = 0.0 unless alltime_proceeds_per_currency[item[:currency_of_proceeds]] #count units if SALE_IDENTS.include? item[:product_type_id] #count sales app[:sold_units] += item[:units] if item[:customer_price]==0 #a free app total_free_units += item[:units] else total_payed_units += item[:units] alltime_proceeds_per_currency[item[:currency_of_proceeds]] += item[:developer_proceeds] end elsif INAPP_SALE_IDENTS.include? item[:product_type_id] app[:sold_units] += item[:units] total_inapp_units += item[:units] alltime_proceeds_per_currency[item[:currency_of_proceeds]] += item[:developer_proceeds] if item[:product_type_id] == "IAY" #InAppPurchase alltime_renewables += item[:units] end elsif UPDATE_IDENTS.include? item[:product_type_id] #count updates app[:updated_units] += item[:units] total_updated_units += item[:units] end else # only if item puts "null report".red end end #add to the alltime stats alltime_payed_units += total_payed_units alltime_inapp_units += total_inapp_units alltime_free_units += total_free_units alltime_updated_units += total_updated_units apps.each do |alltime_sku, apps_app| #select the app if alltime_apps.has_key? alltime_sku #already cached alltime_app = alltime_apps[alltime_sku] else #insert for the first time alltime_app = {:sku=>alltime_sku, :title=>apps_app[:title], :sold_units=>0, :updated_units=>0} alltime_apps[alltime_sku] = alltime_app end #add stats alltime_app[:sold_units] += apps_app[:sold_units] alltime_app[:updated_units] += apps_app[:updated_units] end if @beVerbose && reports.size>1 #report for date puts "\n\n______________________________________________________________".blue puts "Report for #{date}" puts "\n" + "Product".ljust(40).blue + ": " +"Downloads".green + " / " + "Updates".green puts "______________________________________________________________".yellow apps.each do |app_sku,apps_app| puts "#{apps_app[:title].ljust(40).blue}: #{apps_app[:sold_units].to_s.ljust(10).green} / #{apps_app[:updated_units].to_s.rjust(7).dark_green}" end puts "______________________________________________________________".yellow puts "#{'InApp Purchases'.ljust(40).green}: #{total_inapp_units}" puts "#{'Payed Downloads'.ljust(40).green}: #{total_payed_units}" puts "#{'Free Downloads'.ljust(40).dark_green}: #{total_free_units}" puts "#{'Updates'.ljust(40).dark_green}: #{total_updated_units}" puts "______________________________________________________________".blue puts "\n\n" end #if @beVerbose else puts "null report parsed".red end #if report parsed end #reports.each #report alltime puts "\n\n______________________________________________________________".blue from = Date.strptime first_date, '%Y%m%d' age = Date.today - from formatted_from = from.strftime("%b %d %Y") puts "Report" + (ARGV[0]? " #{ARGV[0]}":" daily") + ", from #{formatted_from}, #{age.to_i} days" puts "\n" + "Product".ljust(40).blue + ": " +"Downloads".green + " / " + "Updates".green puts "______________________________________________________________".yellow alltime_apps.each do |app_sku, aapp| puts "#{aapp[:title].ljust(40).blue}: #{aapp[:sold_units].to_s.ljust(10).green} / #{aapp[:updated_units].to_s.rjust(7).dark_green}" end puts "______________________________________________________________".yellow puts "#{'InApp Purchases'.ljust(40).green}: #{alltime_inapp_units}" + ( alltime_renewables > 0.0 ? " / #{alltime_renewables} Auto-Renewed" : "") puts "#{'Payed Downloads'.ljust(40).green}: #{alltime_payed_units}" puts "#{'Free Downloads'.ljust(40).dark_green}: #{alltime_free_units}" puts "#{'Updates'.ljust(40).dark_green}: #{alltime_updated_units}" puts "\n#{'Proceeds'.red}:\n\n" total_proceeds = 0.0 alltime_proceeds_per_currency.each do |proceed_key, proceed| formatted_sum = proceed > 0.0 ? "#{proceed}".green : "#{proceed}".red if proceed > 0.0 if proceed_key == @convertTo total_proceeds += proceed puts "#{proceed_key} : #{formatted_sum}" else #convert using google data = open("http://www.google.com/ig/calculator?q=#{proceed}#{proceed_key}=?#{@convertTo}").read #fix broken json data.gsub!(/lhs:/, '"lhs":') data.gsub!(/rhs:/, '"rhs":') data.gsub!(/error:/, '"error":') data.gsub!(/icc:/, '"icc":') data.gsub!(Regexp.new("(\\\\x..|\\\\240)"), '') #puts data converted = JSON.parse data converted_proceed = converted["rhs"].split(' ').first.to_f total_proceeds += converted_proceed puts "#{proceed_key} : #{formatted_sum} / #{converted['rhs']}" end end end puts "\n#{'Total'.green}: #{total_proceeds} #{@convertTo}" puts "______________________________________________________________".blue puts "\n\n" end # Download a report with the autoingest java tool # # def autoingest(username, password, vendorId, options = "Sales Daily Summary") #call the java program and fetch the file e = `java -cp #{@classpath} Autoingestion #{@username} #{@password} #{@vendorId} #{options}` report_file = e.split("\n").first if File.exists? report_file f = `gzip -df #{report_file}` else puts "#{e}\n".red end end # Downloads daily reports # # def get_daily_reports # Daily reports are available only for past 14 days, please enter a date within past 14 days. first_date = Date.today (1..14).each do |i| date = (first_date - i).to_s.gsub('-', '') puts "\nGetting Daily Sales Report for #{date}\n" filename = "S_D_#{@vendorId}_#{date}.txt" unless File.exists? filename #download unless there already is a file autoingest(@username, @password, @vendorId, "Sales Daily Summary #{date}") end end # 1..14.each end # Downloads weekly reports # # def get_weekly_reports # Weekly reports are available only for past 13 weeks, please enter a weekend date within past 13 weeks. first_date = Date.today (1..13).each do |i| #13 weeks date = (first_date - i*7) day_increment = (0 - date.cwday) % 7 day_increment = 7 if day_increment == 0 next_sunday = date + day_increment formatted_date = next_sunday.to_s.gsub('-', '') puts "\nGetting Weekly Sales Report for #{formatted_date}\n" filename = "S_W_#{@vendorId}_#{formatted_date}.txt" unless File.exists? filename #download unless there already is a file autoingest(@username, @password, @vendorId, "Sales Weekly Summary #{formatted_date}") end end # 1..13.each end #------------------------------------------------------------------------------------------------------------------------------- #begin to show visible light of this program by displaying its name, version and creator puts "\nSales v#{VERSION}".red + " created by Your Headless Standup Programmer http://kitschmaster.com".dark_blue if @beVerbose if ARGV[0] == "-h" || ARGV[0] == "help" || ARGV[0] == "h" #----------------------------------------------------------------------------------------------------------------------------- #show help puts USAGE #----------------------------------------------------------------------------------------------------------------------------- elsif ARGV[0] == "daily" || ARGV[0] == "weekly" #----------------------------------------------------------------------------------------------------------------------------- #compute alltime stats for daily or weekly reports #collect the report files dir_filter = ARGV[0] == "daily" ? "D" : "W" #the daily or the weekly files reports = Dir["S_#{dir_filter}_*.txt"].uniq.compact if reports.empty? || reports.size<2 #no reports, try downloding them first puts "\nDownloading reports first...".red method("get_#{ARGV[0]}_reports".to_sym).call #dynamically instantiating a method and calling on it reports = Dir["S_#{dir_filter}_*.txt"].uniq.compact #reload reports list end if reports.empty? #recheck #no reports puts "\nDownload reports first. Use command:".red puts "sale get:#{ARGV[0].split(':').last}\n".green else #compute and present the collected reports compute_and_present(reports) end #----------------------------------------------------------------------------------------------------------------------------- elsif ARGV[0] == "get:daily" #----------------------------------------------------------------------------------------------------------------------------- get_daily_reports #----------------------------------------------------------------------------------------------------------------------------- elsif ARGV[0] == "get:weekly" #----------------------------------------------------------------------------------------------------------------------------- get_weekly_reports #----------------------------------------------------------------------------------------------------------------------------- else #----------------------------------------------------------------------------------------------------------------------------- # no argument or date in format YYYYMMDD #get sales report for default date @date = ARGV[0] date = (Date.today - 1).to_s.gsub('-', '') date = @date if ARGV[0] puts "\nDaily Sales Report for #{date}\n" filename = "S_D_#{@vendorId}_#{date}.txt" unless File.exists? filename #download unless there already is a file autoingest(@username, @password, @vendorId, "Sales Daily Summary #{date}") end #compute and present the report compute_and_present([filename]) if File.exists? filename #----------------------------------------------------------------------------------------------------------------------------- end #-------------------------------------------------------------------------------------------------------------------------------