chiark / gitweb /
Fix new downloads-per-version stats
[fdroidserver.git] / fdroidserver / stats.py
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3 #
4 # stats.py - part of the FDroid server tools
5 # Copyright (C) 2010-13, Ciaran Gultnieks, ciaran@ciarang.com
6 #
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11 #
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 # GNU Affero General Public License for more details.
16 #
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
19
20 import sys
21 import os
22 import re
23 import time
24 import traceback
25 import glob
26 from optparse import OptionParser
27 import paramiko
28 import common, metadata
29 import socket
30 import subprocess
31
32 def carbon_send(key, value):
33     s = socket.socket()
34     s.connect((config['carbon_host'], config['carbon_port']))
35     msg = '%s %d %d\n' % (key, value, int(time.time()))
36     s.sendall(msg)
37     s.close()
38
39 options = None
40 config = None
41
42 def main():
43
44     global options, config
45
46     # Parse command line...
47     parser = OptionParser()
48     parser.add_option("-v", "--verbose", action="store_true", default=False,
49                       help="Spew out even more information than normal")
50     parser.add_option("-d", "--download", action="store_true", default=False,
51                       help="Download logs we don't have")
52     parser.add_option("--nologs", action="store_true", default=False,
53                       help="Don't do anything logs-related")
54     (options, args) = parser.parse_args()
55
56     config = common.read_config(options)
57
58     if not config['update_stats']:
59         print "Stats are disabled - check your configuration"
60         sys.exit(1)
61
62     # Get all metadata-defined apps...
63     metaapps = metadata.read_metadata(options.verbose)
64
65     statsdir = 'stats'
66     logsdir = os.path.join(statsdir, 'logs')
67     datadir = os.path.join(statsdir, 'data')
68     if not os.path.exists(statsdir):
69         os.mkdir(statsdir)
70     if not os.path.exists(logsdir):
71         os.mkdir(logsdir)
72     if not os.path.exists(datadir):
73         os.mkdir(datadir)
74
75     if options.download:
76         # Get any access logs we don't have...
77         ssh = None
78         ftp = None
79         try:
80             print 'Retrieving logs'
81             ssh = paramiko.SSHClient()
82             ssh.load_system_host_keys()
83             ssh.connect('f-droid.org', username='fdroid', timeout=10,
84                     key_filename=config['webserver_keyfile'])
85             ftp = ssh.open_sftp()
86             ftp.get_channel().settimeout(60)
87             print "...connected"
88
89             ftp.chdir('logs')
90             files = ftp.listdir()
91             for f in files:
92                 if f.startswith('access-') and f.endswith('.log.gz'):
93
94                     destpath = os.path.join(logsdir, f)
95                     destsize = ftp.stat(f).st_size
96                     if (not os.path.exists(destpath) or
97                             os.path.getsize(destpath) != destsize):
98                         print "...retrieving " + f
99                         ftp.get(f, destpath)
100         except Exception:
101             traceback.print_exc()
102             sys.exit(1)
103         finally:
104             #Disconnect
105             if ftp is not None:
106                 ftp.close()
107             if ssh is not None:
108                 ssh.close()
109
110     knownapks = common.KnownApks()
111     unknownapks = []
112
113     if not options.nologs:
114         # Process logs
115         if options.verbose:
116             print 'Processing logs...'
117         apps = {}
118         appsVer = {}
119         logexpr = '(?P<ip>[.:0-9a-fA-F]+) - - \[(?P<time>.*?)\] "GET (?P<uri>.*?) HTTP/1.\d" (?P<statuscode>\d+) \d+ "(?P<referral>.*?)" "(?P<useragent>.*?)"'
120         logsearch = re.compile(logexpr).search
121         for logfile in glob.glob(os.path.join(logsdir,'access-*.log.gz')):
122             if options.verbose:
123                 print '...' + logfile
124             p = subprocess.Popen(["zcat", logfile], stdout = subprocess.PIPE)
125             matches = (logsearch(line) for line in p.stdout)
126             for match in matches:
127                 if match and match.group('statuscode') == '200':
128                     uri = match.group('uri')
129                     if uri.endswith('.apk'):
130                         _, apkname = os.path.split(uri)
131                         app = knownapks.getapp(apkname)
132                         if app:
133                             appid, _ = app
134                             if appid in apps:
135                                 apps[appid] += 1
136                             else:
137                                 apps[appid] = 1
138                             # Strip the '.apk' from apkname
139                             appVer = apkname[:-4]
140                             if appVer in appsVer:
141                                 appsVer[appVer] += 1
142                             else:
143                                 appsVer[appVer] = 1
144                         else:
145                             if not apkname in unknownapks:
146                                 unknownapks.append(apkname)
147
148         # Calculate and write stats for total downloads...
149         lst = []
150         alldownloads = 0
151         for app, count in apps.iteritems():
152             lst.append(app + " " + str(count))
153             if config['stats_to_carbon']:
154                 carbon_send('fdroid.download.' + app.replace('.', '_'), count)
155             alldownloads += count
156         lst.append("ALL " + str(alldownloads))
157         f = open('stats/total_downloads_app.txt', 'w')
158         f.write('# Total downloads by application, since October 2011\n')
159         for line in sorted(lst):
160             f.write(line + '\n')
161         f.close()
162
163         f = open('stats/total_downloads_app_version.txt', 'w')
164         f.write('# Total downloads by application and version, since October 2011\n')
165         lst = []
166         for appver, count in appsVer.iteritems():
167             lst.append(appver + " " + str(count))
168         for line in sorted(lst):
169             f.write(line + "\n")
170         f.close()
171
172     # Calculate and write stats for repo types...
173     if options.verbose:
174         print "Processing repo types..."
175     repotypes = {}
176     for app in metaapps:
177         if len(app['Repo Type']) == 0:
178             rtype = 'none'
179         else:
180             if app['Repo Type'] == 'srclib':
181                 rtype = common.getsrclibvcs(app['Repo'])
182             else:
183                 rtype = app['Repo Type']
184         if rtype in repotypes:
185             repotypes[rtype] += 1;
186         else:
187             repotypes[rtype] = 1
188     f = open('stats/repotypes.txt', 'w')
189     for rtype, count in repotypes.iteritems():
190         f.write(rtype + ' ' + str(count) + '\n')
191     f.close()
192
193     # Calculate and write stats for update check modes...
194     if options.verbose:
195         print "Processing update check modes..."
196     ucms = {}
197     for app in metaapps:
198         checkmode = app['Update Check Mode'].split('/')[0]
199         if checkmode in ucms:
200             ucms[checkmode] += 1;
201         else:
202             ucms[checkmode] = 1
203     f = open('stats/update_check_modes.txt', 'w')
204     for checkmode, count in ucms.iteritems():
205         f.write(checkmode + ' ' + str(count) + '\n')
206     f.close()
207
208     if options.verbose:
209         print "Processing categories..."
210     ctgs = {}
211     for app in metaapps:
212         if app['Categories'] is None:
213             continue
214         categories = [c.strip() for c in app['Categories'].split(',')]
215         for category in categories:
216             if category in ctgs:
217                 ctgs[category] += 1;
218             else:
219                 ctgs[category] = 1
220     f = open('stats/categories.txt', 'w')
221     for category, count in ctgs.iteritems():
222         f.write(category + ' ' + str(count) + '\n')
223     f.close()
224
225     if options.verbose:
226         print "Processing antifeatures..."
227     afs = {}
228     for app in metaapps:
229         if app['AntiFeatures'] is None:
230             continue
231         antifeatures = [a.strip() for a in app['AntiFeatures'].split(',')]
232         for antifeature in antifeatures:
233             if antifeature in afs:
234                 afs[antifeature] += 1;
235             else:
236                 afs[antifeature] = 1
237     f = open('stats/antifeatures.txt', 'w')
238     for antifeature, count in afs.iteritems():
239         f.write(antifeature + ' ' + str(count) + '\n')
240     f.close()
241
242     # Calculate and write stats for licenses...
243     if options.verbose:
244         print "Processing licenses..."
245     licenses = {}
246     for app in metaapps:
247         license = app['License']
248         if license in licenses:
249             licenses[license] += 1;
250         else:
251             licenses[license] = 1
252     f = open('stats/licenses.txt', 'w')
253     for license, count in licenses.iteritems():
254         f.write(license + ' ' + str(count) + '\n')
255     f.close()
256
257     # Write list of latest apps added to the repo...
258     if options.verbose:
259         print "Processing latest apps..."
260     latest = knownapks.getlatest(10)
261     f = open('stats/latestapps.txt', 'w')
262     for app in latest:
263         f.write(app + '\n')
264     f.close()
265
266     if unknownapks:
267         print '\nUnknown apks:'
268         for apk in unknownapks:
269             print apk
270
271     print "Finished."
272
273 if __name__ == "__main__":
274     main()
275