]>
code.delx.au - monosys/blob - opal-card-tool
19 CACHE_DIR
= os
.environ
.get("XDG_CACHE_HOME", os
.path
.expanduser("~/.cache/opal-card-tool"))
20 PICKLE_FILE
= os
.path
.join(CACHE_DIR
, "pickle")
22 USER_AGENT
= "Mozilla/5.0 (X11; Linux x86_64; rv:59.0) Gecko/20100101 Firefox/59.0"
23 OPAL_BASE
= "https://www.opal.com.au"
24 LOGIN_URL
= OPAL_BASE
+ "/login/registeredUserUsernameAndPasswordLogin"
25 CARD_DETAILS_URL
= OPAL_BASE
+ "/registered/getJsonCardDetailsArray"
26 TRANSACTION_LIST_URL
= OPAL_BASE
+ "/registered/opal-card-transactions/opal-card-activities-list?AMonth=-1&AYear=-1&cardIndex=%d&pageIndex=%d"
31 return " ".join(t
.strip() for t
in el
.itertext()).strip()
38 return d
.isoweekday() <= 5
41 d
= datetime
.datetime
.now() - datetime
.timedelta(days
=days
)
42 d
= d
.replace(hour
=0, minute
=0, second
=0, microsecond
=0)
46 class FatalError(Exception):
49 class Transaction(object):
54 self
.transaction_list
= []
56 def get_max_transaction(self
):
57 if self
.transaction_list
:
58 return self
.transaction_list
[0].number
62 def add_transactions(self
, l
):
63 self
.transaction_list
= l
+ self
.transaction_list
66 def __init__(self
, username
, password
):
67 self
.version
= VERSION
68 self
.username
= username
69 self
.password
= password
75 self
.session
= requests
.Session()
76 self
.session
.headers
["User-Agent"] = USER_AGENT
79 r
= self
.session
.post(LOGIN_URL
, {
80 "h_username": self
.username
,
81 "h_password": self
.password
,
84 raise Exception("Failed to login, error code: %d" % r
.status_code
)
87 if json
["errorMessage"]:
88 raise Exception("Failed to login: %s" % json
["errorMessage"])
92 for card
in self
.cards
:
93 self
.load_transactions(card
)
95 def resolve_card_number(self
, card_number
):
96 if int(card_number
) < len(self
.cards
):
97 return self
.cards
[int(card_number
)].number
101 def get_transaction_list_for_card(self
, card_number
):
102 for card
in self
.cards
:
103 if card
.number
== card_number
:
104 return card
.transaction_list
108 def load_cards(self
):
109 r
= self
.session
.get(CARD_DETAILS_URL
)
111 raise Exception("Failed to login, error code: %d" % r
.status_code
)
113 for index
, card_json
in enumerate(r
.json()):
114 card_number
= card_json
["cardNumber"]
116 for card
in self
.cards
:
117 if card
.number
== card_number
:
121 self
.cards
.append(card
)
123 card
.number
= card_number
124 card
.name
= card_json
["cardNickName"]
127 def load_transactions(self
, card
):
128 print("Loading transactions for", card
.number
, "", end
="", flush
=True)
129 max_transaction
= card
.get_max_transaction()
130 transaction_list
= []
132 for page
in itertools
.count(1):
133 print(".", end
="", flush
=True)
134 transaction_page
= self
.fetch_transaction_page(card
.index
, page
)
135 continue_paging
= False
137 for transaction
in transaction_page
:
138 if transaction
.number
<= max_transaction
:
139 continue_paging
= False
142 transaction_list
.append(transaction
)
143 continue_paging
= True
145 if not continue_paging
:
149 card
.add_transactions(transaction_list
)
151 def parse_transaction(self
, cells
):
153 t
.number
= int(stringify(cells
["transaction number"]))
154 t
.timestamp
= datetime
.datetime
.strptime(stringify(cells
["date/time"]), "%a %d/%m/%Y %H:%M")
155 t
.mode
= get_first(cells
["mode"].xpath("img/@alt"))
156 t
.details
= stringify(cells
["details"])
157 t
.journey_number
= stringify(cells
["journey number"])
158 t
.fare_applied
= stringify(cells
["fare applied"])
159 t
.fare
= stringify(cells
["fare"])
160 t
.fare_discount
= stringify(cells
["discount"])
161 t
.amount
= stringify(cells
["amount"])
164 def fetch_transaction_page(self
, card
, page
):
165 url
= TRANSACTION_LIST_URL
% (card
, page
)
166 r
= self
.session
.get(url
)
168 raise Exception("Failed to fetch transactions, error code: %d" % r
.status_code
)
170 doc
= lxml
.html
.fromstring(r
.text
)
171 headers
= [stringify(th
).lower() for th
in doc
.xpath("//table/thead//th")]
176 for tr
in doc
.xpath("//table/tbody/tr"):
178 yield self
.parse_transaction(dict(zip(headers
, tr
.getchildren())))
180 print("Failed to parse:", headers
, lxml
.html
.tostring(tr
), file=sys
.stderr
)
184 class CommuterGraph(object):
185 class gnuplot_dialect(csv
.excel
):
189 self
.data_am_csv
, self
.data_am_file
= self
.new_csv()
190 self
.data_pm_csv
, self
.data_pm_file
= self
.new_csv()
191 self
.plot_file
= self
.new_tempfile()
192 self
.files
= [self
.data_am_file
, self
.data_pm_file
, self
.plot_file
]
194 self
.xrange_start
= None
195 self
.xrange_end
= None
197 def is_plottable(self
):
198 return self
.xrange_start
is not None and self
.xrange_end
is not None
200 def graph(self
, transaction_list
):
202 self
.write_points(transaction_list
)
203 if not self
.is_plottable():
204 print("No transactions!", file=sys
.stderr
)
206 self
.write_plot_command()
212 def new_tempfile(self
):
213 return tempfile
.NamedTemporaryFile(
216 prefix
="opal-card-tool-",
221 f
= self
.new_tempfile()
222 out
= csv
.writer(f
, dialect
=self
.gnuplot_dialect
)
225 def update_xrange(self
, ts
):
226 if self
.xrange_start
is None or ts
< self
.xrange_start
:
227 self
.xrange_start
= ts
228 if self
.xrange_end
is None or ts
> self
.xrange_end
:
231 def generate_point(self
, transaction
):
232 ts
= transaction
.timestamp
233 x_date
= ts
.strftime("%Y-%m-%dT00:00:00")
234 y_time
= ts
.strftime("2000-01-01T%H:%M:00")
235 y_label
= ts
.strftime("%H:%M")
236 return [x_date
, y_time
, y_label
]
238 def write_point(self
, ts
, point
):
239 if ts
.time() < datetime
.time(12):
240 out_csv
= self
.data_am_csv
242 out_csv
= self
.data_pm_csv
244 out_csv
.writerow(point
)
246 def write_points(self
, transaction_list
):
247 for transaction
in transaction_list
:
248 if not self
.is_commuter_transaction(transaction
):
251 self
.update_xrange(transaction
.timestamp
)
252 point
= self
.generate_point(transaction
)
253 self
.write_point(transaction
.timestamp
, point
)
255 def is_commuter_transaction(self
, transaction
):
256 if not is_weekday(transaction
.timestamp
):
258 if transaction
.details
.startswith("Auto top up"):
262 def write_plot_command(self
):
264 "data_am_filename": self
.data_am_file
.name
,
265 "data_pm_filename": self
.data_pm_file
.name
,
266 "xrange_start": self
.xrange_start
- datetime
.timedelta(hours
=24),
267 "xrange_end": self
.xrange_end
+ datetime
.timedelta(hours
=24),
269 self
.plot_file
.write(R
"""
270 set timefmt '%Y-%m-%dT%H:%M:%S'
275 set xtics 86400 scale 1.0,0.0
276 set xrange [ '{xrange_start}' : '{xrange_end}' ]
281 set yrange [ '2000-01-01T06:00:00' : '2000-01-01T23:00:00' ]
286 title 'opal-card-tool graph' \
292 '{data_pm_filename}' \
295 title 'Afternoon departure time' \
297 '{data_pm_filename}' \
303 '{data_am_filename}' \
306 title 'Morning departure time' \
308 '{data_am_filename}' \
315 def flush_files(self
):
326 def run_gnuplot(self
):
327 subprocess
.check_call([
332 def restrict_days(transaction_list
, num_days
):
333 oldest_date
= n_days_ago(num_days
)
334 for transaction
in transaction_list
:
335 if transaction
.timestamp
< oldest_date
:
339 def graph_commuter(transaction_list
):
341 g
.graph(transaction_list
)
343 def print_transaction_list(transaction_list
):
345 headers
.extend(["number", "timestamp"])
346 headers
.extend(h
for h
in sorted(transaction_list
[0].__dict
__.keys()) if h
not in headers
)
348 out
= csv
.DictWriter(sys
.stdout
, headers
)
350 for transaction
in transaction_list
:
351 out
.writerow(transaction
.__dict
__)
353 def print_cards(opal
):
354 for i
, card
in enumerate(opal
.cards
):
356 print(" number:", card
.number
)
357 print(" name:", card
.name
)
358 print(" transactions:", len(card
.transaction_list
))
362 if not os
.path
.isfile(PICKLE_FILE
):
365 with
open(PICKLE_FILE
, "rb") as f
:
366 return pickle
.load(f
)
368 def save_pickle(opal
):
369 if not os
.path
.isdir(CACHE_DIR
):
370 os
.makedirs(CACHE_DIR
)
371 with
open(PICKLE_FILE
, "wb") as f
:
376 def upgrade_opal_v2(opal
):
380 def upgrade_opal(opal
):
381 while opal
.version
< VERSION
:
382 print("Upgrading from version", opal
.version
, file=sys
.stderr
)
383 upgrade_func
= globals()["upgrade_opal_v%d" % opal
.version
]
389 opal
= try_unpickle()
395 username
= input("Username: ")
396 password
= getpass
.getpass()
397 opal
= Opal(username
, password
)
417 card_number
= args
.card_number
420 card_number
= opal
.resolve_card_number(card_number
)
423 num_days
= int(args
.num_days
)
424 elif args
.graph_commuter
:
429 transaction_list
= opal
.get_transaction_list_for_card(card_number
)
430 transaction_list
= list(restrict_days(transaction_list
, num_days
))
432 if not transaction_list
:
433 print("No transactions!", file=sys
.stderr
)
436 if args
.show_transactions
:
437 print_transaction_list(transaction_list
)
438 elif args
.graph_commuter
:
439 graph_commuter(transaction_list
)
441 print("Missing display function!", file=sys
.stderr
)
444 parser
= argparse
.ArgumentParser(description
="Opal card activity fetcher")
446 parser
.add_argument("--num-days",
447 help="restrict to NUM_DAYS of output"
449 parser
.add_argument("--card-number",
450 help="Opal card number or index (eg: 0,1,etc"
453 group
= parser
.add_mutually_exclusive_group(required
=True)
454 group
.add_argument("--load", action
="store_true",
455 help="load any new data from the Opal website"
457 group
.add_argument("--show-cards", action
="store_true",
458 help="show a list of cards"
460 group
.add_argument("--show-transactions", action
="store_true",
461 help="show transactions for card"
463 group
.add_argument("--graph-commuter", action
="store_true",
464 help="draw commuter graph for card with gnuplot"
467 args
= parser
.parse_args()
478 elif args
.show_cards
:
485 if __name__
== "__main__":
488 except (KeyboardInterrupt, BrokenPipeError
) as e
:
489 print("Exiting:", e
, file=sys
.stderr
)