diff --git a/modules/voyagessncf/__init__.py b/modules/voyagessncf/__init__.py
new file mode 100644
index 00000000..509b9e95
--- /dev/null
+++ b/modules/voyagessncf/__init__.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Romain Bignon
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+
+from .backend import VoyagesSNCFBackend
+
+
+__all__ = ['VoyagesSNCFBackend']
diff --git a/modules/voyagessncf/backend.py b/modules/voyagessncf/backend.py
new file mode 100644
index 00000000..6220278a
--- /dev/null
+++ b/modules/voyagessncf/backend.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Romain Bignon
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+
+from weboob.tools.backend import BaseBackend
+from weboob.capabilities.travel import ICapTravel, Station, Departure
+from weboob.capabilities import UserError
+
+from .browser import VoyagesSNCFBrowser
+
+
+__all__ = ['VoyagesSNCFBackend']
+
+
+class VoyagesSNCFBackend(BaseBackend, ICapTravel):
+ NAME = 'voyagessncf'
+ DESCRIPTION = u'Voyages SNCF'
+ MAINTAINER = u'Romain Bignon'
+ EMAIL = 'romain@weboob.org'
+ LICENSE = 'AGPLv3+'
+ VERSION = '0.h'
+
+ BROWSER = VoyagesSNCFBrowser
+ STATIONS = []
+
+ def _populate_stations(self):
+ if len(self.STATIONS) == 0:
+ with self.browser:
+ self.STATIONS = self.browser.get_stations()
+
+ def iter_station_search(self, pattern):
+ self._populate_stations()
+
+ pattern = pattern.lower()
+ for _id, name in enumerate(self.STATIONS):
+ if name.lower().startswith(pattern):
+ yield Station(_id, unicode(name))
+
+ def iter_station_departures(self, station_id, arrival_id=None, date=None):
+ self._populate_stations()
+
+ if arrival_id is None:
+ raise UserError('The arrival station is required')
+
+ try:
+ station = self.STATIONS[int(station_id)]
+ arrival = self.STATIONS[int(arrival_id)]
+ except (IndexError, ValueError):
+ raise UserError('Unknown station')
+
+ with self.browser:
+ for i, d in enumerate(self.browser.iter_departures(station, arrival, date)):
+ departure = Departure(i, d['type'], d['time'])
+ departure.departure_station = d['departure']
+ departure.arrival_station = d['arrival']
+ departure.arrival_time = d['arrival_time']
+ departure.price = d['price']
+ departure.currency = d['currency']
+ yield departure
diff --git a/modules/voyagessncf/browser.py b/modules/voyagessncf/browser.py
new file mode 100644
index 00000000..d12aebc2
--- /dev/null
+++ b/modules/voyagessncf/browser.py
@@ -0,0 +1,51 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Romain Bignon
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+
+from weboob.tools.browser import BaseBrowser
+
+from .pages import CitiesPage, SearchPage, SearchErrorPage, \
+ SearchInProgressPage, ResultsPage
+
+
+__all__ = ['VoyagesSNCFBrowser']
+
+
+class VoyagesSNCFBrowser(BaseBrowser):
+ PROTOCOL = 'http'
+ DOMAIN = 'www.voyages-sncf.com'
+ ENCODING = 'utf-8'
+
+ PAGES = {
+ 'http://www.voyages-sncf.com/completion/VSC/FR/fr/cityList.js': (CitiesPage, 'raw'),
+ 'http://www.voyages-sncf.com/billet-train': SearchPage,
+ 'http://www.voyages-sncf.com/billet-train\?.+': SearchErrorPage,
+ 'http://www.voyages-sncf.com/billet-train/recherche-en-cours.*': SearchInProgressPage,
+ 'http://www.voyages-sncf.com/billet-train/resultat.*': ResultsPage,
+ }
+
+ def get_stations(self):
+ self.location('/completion/VSC/FR/fr/cityList.js')
+ return self.page.get_stations()
+
+ def iter_departures(self, departure, arrival, date):
+ self.location('/billet-train')
+ self.page.search(departure, arrival, date)
+
+ return self.page.iter_results()
diff --git a/modules/voyagessncf/pages.py b/modules/voyagessncf/pages.py
new file mode 100644
index 00000000..d27bb4ac
--- /dev/null
+++ b/modules/voyagessncf/pages.py
@@ -0,0 +1,103 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Romain Bignon
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+
+import re
+from decimal import Decimal
+from datetime import time, datetime
+
+from weboob.tools.browser import BasePage
+from weboob.tools.json import json
+from weboob.tools.mech import ClientForm
+from weboob.capabilities.base import UserError, Currency
+
+
+__all__ = ['CitiesPage', 'SearchPage']
+
+
+class CitiesPage(BasePage):
+ def get_stations(self):
+ result = json.loads(self.document[self.document.find('{'):-2])
+ return result['CITIES']
+
+class SearchPage(BasePage):
+ def search(self, departure, arrival, date):
+ self.browser.select_form(name='saisie')
+ self.browser['ORIGIN_CITY'] = departure.encode(self.browser.ENCODING)
+ self.browser['DESTINATION_CITY'] = arrival.encode(self.browser.ENCODING)
+
+ if date is None:
+ date = datetime.now()
+
+ self.browser['OUTWARD_DATE'] = date.strftime('%d/%m/%y')
+ self.browser['OUTWARD_TIME'] = [str(date.hour + 1)]
+ self.browser['PASSENGER_1'] = ['ADULT']
+ self.browser.controls.append(ClientForm.TextControl('text', 'nbAnimalsForTravel', {'value': ''}))
+ self.browser['nbAnimalsForTravel'] = '0'
+ self.browser.submit()
+
+class SearchErrorPage(BasePage):
+ def on_loaded(self):
+ p = self.document.getroot().cssselect('div.messagesError p')
+ if len(p) > 0:
+ message = p[0].text.strip()
+ raise UserError(message)
+
+class SearchInProgressPage(BasePage):
+ def on_loaded(self):
+ link = self.document.xpath('//a[@id="url_redirect_proposals"]')[0]
+ self.browser.location(link.attrib['href'])
+
+class ResultsPage(BasePage):
+ def get_value(self, div, name):
+ p = div.cssselect(name)[0]
+ sub = p.find('p')
+ if sub is not None:
+ txt = sub.tail.strip()
+ if txt == '':
+ p.remove(sub)
+ else:
+ return unicode(txt)
+
+ return unicode(self.parser.tocleanstring(p))
+
+ def parse_hour(self, div, name):
+ txt = self.get_value(div, name)
+ hour, minute = map(int, txt.split('h'))
+ return time(hour, minute)
+
+ def iter_results(self):
+ for div in self.document.getroot().cssselect('div.train_info'):
+ price = None
+ currency = None
+ for td in div.cssselect('td.price'):
+ txt = self.parser.tocleanstring(td)
+ p = Decimal(re.sub('([^\d\.]+)', '', txt))
+ currency = Currency.get_currency(txt)
+ if price is None or p < price:
+ price = p
+
+ yield {'type': self.get_value(div, 'div.transporteur-txt'),
+ 'time': self.parse_hour(div, 'div.departure div.hour'),
+ 'departure': self.get_value(div, 'div.departure div.station'),
+ 'arrival': self.get_value(div, 'div.arrival div.station'),
+ 'arrival_time': self.parse_hour(div, 'div.arrival div.hour'),
+ 'price': price,
+ 'currency': currency,
+ }
diff --git a/modules/voyagessncf/test.py b/modules/voyagessncf/test.py
new file mode 100644
index 00000000..f22103ed
--- /dev/null
+++ b/modules/voyagessncf/test.py
@@ -0,0 +1,37 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Romain Bignon
+#
+# This file is part of weboob.
+#
+# weboob is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# weboob is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with weboob. If not, see .
+
+
+from weboob.tools.test import BackendTest
+
+
+class VoyagesSNCFTest(BackendTest):
+ BACKEND = 'voyagessncf'
+
+ def test_stations(self):
+ stations = list(self.backend.iter_station_search('paris'))
+ self.assertTrue(len(stations) > 0)
+ self.assertTrue('Paris Massy' in stations[-1].name)
+
+ def test_departures(self):
+ departure = list(self.backend.iter_station_search('paris'))[0]
+ arrival = list(self.backend.iter_station_search('lyon'))[0]
+
+ prices = list(self.backend.iter_station_departures(departure.id, arrival.id))
+ self.assertTrue(len(prices) > 0)