chiark / gitweb /
Start doing multiple categories as comma separated lists
[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
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 config = {}
40
41 def main():
42
43     # Read configuration...
44     common.read_config(config)
45
46     if not config['update_stats']:
47         print "Stats are disabled - check your configuration"
48         sys.exit(1)
49
50     # Parse command line...
51     parser = OptionParser()
52     parser.add_option("-v", "--verbose", action="store_true", default=False,
53                       help="Spew out even more information than normal")
54     parser.add_option("-d", "--download", action="store_true", default=False,
55                       help="Download logs we don't have")
56     (options, args) = parser.parse_args()
57
58     # Get all metadata-defined apps...
59     metaapps = common.read_metadata(options.verbose)
60
61     statsdir = 'stats'
62     logsdir = os.path.join(statsdir, 'logs')
63     datadir = os.path.join(statsdir, 'data')
64     if not os.path.exists(statsdir):
65         os.mkdir(statsdir)
66     if not os.path.exists(logsdir):
67         os.mkdir(logsdir)
68     if not os.path.exists(datadir):
69         os.mkdir(datadir)
70
71     if options.download:
72         # Get any access logs we don't have...
73         ssh = None
74         ftp = None
75         try:
76             print 'Retrieving logs'
77             ssh = paramiko.SSHClient()
78             ssh.load_system_host_keys()
79             ssh.connect('f-droid.org', username='fdroid', timeout=10,
80                     key_filename=config['webserver_keyfile'])
81             ftp = ssh.open_sftp()
82             ftp.get_channel().settimeout(60)
83             print "...connected"
84
85             ftp.chdir('logs')
86             files = ftp.listdir()
87             for f in files:
88                 if f.startswith('access-') and f.endswith('.log.gz'):
89
90                     destpath = os.path.join(logsdir, f)
91                     destsize = ftp.stat(f).st_size
92                     if (not os.path.exists(destpath) or
93                             os.path.getsize(destpath) != destsize):
94                         print "...retrieving " + f
95                         ftp.get(f, destpath)
96         except Exception:
97             traceback.print_exc()
98             sys.exit(1)
99         finally:
100             #Disconnect
101             if ftp is not None:
102                 ftp.close()
103             if ssh is not None:
104                 ssh.close()
105
106     # Process logs
107     if options.verbose:
108         print 'Processing logs...'
109     logexpr = '(?P<ip>[.:0-9a-fA-F]+) - - \[(?P<time>.*?)\] "GET (?P<uri>.*?) HTTP/1.\d" (?P<statuscode>\d+) \d+ "(?P<referral>.*?)" "(?P<useragent>.*?)"'
110     logsearch = re.compile(logexpr).search
111     apps = {}
112     unknownapks = []
113     knownapks = common.KnownApks()
114     for logfile in glob.glob(os.path.join(logsdir,'access-*.log.gz')):
115         if options.verbose:
116             print '...' + logfile
117         logdate = logfile[len(logsdir) + 1 + len('access-'):-7]
118         p = subprocess.Popen(["zcat", logfile], stdout = subprocess.PIPE)
119         matches = (logsearch(line) for line in p.stdout)
120         for match in matches:
121             if match and match.group('statuscode') == '200':
122                 uri = match.group('uri')
123                 if uri.endswith('.apk'):
124                     _, apkname = os.path.split(uri)
125                     app = knownapks.getapp(apkname)
126                     if app:
127                         appid, _ = app
128                         if appid in apps:
129                             apps[appid] += 1
130                         else:
131                             apps[appid] = 1
132                     else:
133                         if not apkname in unknownapks:
134                             unknownapks.append(apkname)
135
136     # Calculate and write stats for total downloads...
137     lst = []
138     alldownloads = 0
139     for app, count in apps.iteritems():
140         lst.append(app + " " + str(count))
141         if config['stats_to_carbon']:
142             carbon_send('fdroid.download.' + app.replace('.', '_'), count)
143         alldownloads += count
144     lst.append("ALL " + str(alldownloads))
145     f = open('stats/total_downloads_app.txt', 'w')
146     f.write('# Total downloads by application, since October 2011\n')
147     for line in sorted(lst):
148         f.write(line + '\n')
149     f.close()
150
151     # Calculate and write stats for repo types...
152     repotypes = {}
153     for app in metaapps:
154         if len(app['Repo Type']) == 0:
155             rtype = 'none'
156         else:
157             if app['Repo Type'] == 'srclib':
158                 rtype = common.getsrclibvcs(app['Repo'])
159             else:
160                 rtype = app['Repo Type']
161         if rtype in repotypes:
162             repotypes[rtype] += 1;
163         else:
164             repotypes[rtype] = 1
165     f = open('stats/repotypes.txt', 'w')
166     for rtype, count in repotypes.iteritems():
167         f.write(rtype + ' ' + str(count) + '\n')
168     f.close()
169
170     # Calculate and write stats for update check modes...
171     ucms = {}
172     for app in metaapps:
173         checkmode = app['Update Check Mode'].split('/')[0]
174         if checkmode in ucms:
175             ucms[checkmode] += 1;
176         else:
177             ucms[checkmode] = 1
178     f = open('stats/update_check_modes.txt', 'w')
179     for checkmode, count in ucms.iteritems():
180         f.write(checkmode + ' ' + str(count) + '\n')
181     f.close()
182
183     ctgs = {}
184     for app in metaapps:
185         if app['Categories'] is None:
186             continue
187         categories = [c.strip() for c in app['Categories'].split(',')]
188         for category in categories:
189             if category in ctgs:
190                 ctgs[category] += 1;
191             else:
192                 ctgs[category] = 1
193     f = open('stats/categories.txt', 'w')
194     for category, count in ctgs.iteritems():
195         f.write(category + ' ' + str(count) + '\n')
196     f.close()
197
198     afs = {}
199     for app in metaapps:
200         if app['AntiFeatures'] is None:
201             continue
202         antifeatures = [a.strip() for a in app['AntiFeatures'].split(',')]
203         for antifeature in antifeatures:
204             if antifeature in afs:
205                 afs[antifeature] += 1;
206             else:
207                 afs[antifeature] = 1
208     f = open('stats/antifeatures.txt', 'w')
209     for antifeature, count in afs.iteritems():
210         f.write(antifeature + ' ' + str(count) + '\n')
211     f.close()
212     return
213
214     # Calculate and write stats for licenses...
215     licenses = {}
216     for app in metaapps:
217         license = app['License']
218         if license in licenses:
219             licenses[license] += 1;
220         else:
221             licenses[license] = 1
222     f = open('stats/licenses.txt', 'w')
223     for license, count in licenses.iteritems():
224         f.write(license + ' ' + str(count) + '\n')
225     f.close()
226
227     # Write list of latest apps added to the repo...
228     latest = knownapks.getlatest(10)
229     f = open('stats/latestapps.txt', 'w')
230     for app in latest:
231         f.write(app + '\n')
232     f.close()
233
234     if len(unknownapks) > 0:
235         print '\nUnknown apks:'
236         for apk in unknownapks:
237             print apk
238
239     print "Finished."
240
241 if __name__ == "__main__":
242     main()
243