diff --git a/modules/jvmalin/__init__.py b/modules/jvmalin/__init__.py
new file mode 100644
index 00000000..87a9e5e5
--- /dev/null
+++ b/modules/jvmalin/__init__.py
@@ -0,0 +1,23 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Alexandre Lissy
+#
+# 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 JVMalinBackend
+
+__all__ = ['JVMalinBackend']
diff --git a/modules/jvmalin/backend.py b/modules/jvmalin/backend.py
new file mode 100644
index 00000000..c1b1071d
--- /dev/null
+++ b/modules/jvmalin/backend.py
@@ -0,0 +1,50 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Alexandre Lissy
+#
+# 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.capabilities.travel import ICapTravel, RoadStep
+from weboob.tools.backend import BaseBackend
+
+from .browser import JVMalin
+
+
+__all__ = ['JVMalinBackend']
+
+
+class JVMalinBackend(BaseBackend, ICapTravel):
+ NAME = 'jvmalin'
+ MAINTAINER = u'Alexandre Lissy'
+ EMAIL = 'github@lissy.me'
+ VERSION = '0.h'
+ LICENSE = 'AGPLv3+'
+ DESCRIPTION = "Multimodal public transportation for whole Région Centre, France"
+ BROWSER = JVMalin
+
+ def iter_roadmap(self, departure, arrival, filters):
+ with self.browser:
+ roadmap = self.browser.get_roadmap(departure, arrival, filters)
+
+ for s in roadmap['steps']:
+ step = RoadStep(s['id'])
+ step.line = s['line']
+ step.start_time = s['start_time']
+ step.end_time = s['end_time']
+ step.departure = s['departure']
+ step.arrival = s['arrival']
+ step.duration = s['duration']
+ yield step
diff --git a/modules/jvmalin/browser.py b/modules/jvmalin/browser.py
new file mode 100644
index 00000000..4db4cd38
--- /dev/null
+++ b/modules/jvmalin/browser.py
@@ -0,0 +1,67 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Alexandre Lissy
+#
+# 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 datetime import datetime, date, time
+
+from weboob.tools.browser import BaseBrowser
+from weboob.tools.misc import to_unicode
+from weboob.tools.browser import BrokenPageError
+from .pages import RoadmapSearchPage, RoadmapResultsPage, RoadmapPage, RoadmapAmbiguity
+
+
+__all__ = ['JVMalin']
+
+
+class JVMalin(BaseBrowser):
+ DOMAIN = 'www.jvmalin.fr'
+ PAGES = {
+ 'http://www\.jvmalin\.fr/Itineraires/Recherche.*': RoadmapSearchPage,
+ 'http://www\.jvmalin\.fr/Itineraires/Precision.*': RoadmapResultsPage,
+ 'http://www\.jvmalin\.fr/route/vuesearch/result.*': RoadmapPage
+ }
+
+ def __init__(self, **kwargs):
+ BaseBrowser.__init__(self, '', **kwargs)
+
+ def get_roadmap(self, departure, arrival, filters):
+ self.location('/Itineraires/Recherche')
+
+ assert self.is_on_page(RoadmapSearchPage)
+ self.page.search(departure, arrival, filters.departure_time, filters.arrival_time)
+
+ assert self.is_on_page(RoadmapResultsPage)
+
+ dest = ''
+ try:
+ dest = self.page.find_best()
+ except RoadmapAmbiguity as am:
+ self.page.resubmit_best_form()
+ assert self.is_on_page(RoadmapResultsPage)
+ dest = self.page.find_best()
+
+ self.location(dest)
+
+ roadmap = {}
+ roadmap['steps'] = list(self.page.get_steps())
+ return roadmap
+
+ def is_logged(self):
+ """ Do not need to be logged """
+ return True
diff --git a/modules/jvmalin/pages.py b/modules/jvmalin/pages.py
new file mode 100644
index 00000000..9f441cac
--- /dev/null
+++ b/modules/jvmalin/pages.py
@@ -0,0 +1,207 @@
+# -*- coding: utf-8 -*-
+
+import re
+import datetime
+
+from weboob.capabilities.travel import RoadmapError
+from weboob.tools.misc import to_unicode
+from weboob.tools.mech import ClientForm
+from weboob.tools.browser import BasePage
+
+__all__ = ['RoadmapSearchPage', 'RoadmapResultsPage', 'RoadmapPage', 'RoadmapAmbiguity']
+
+class RoadmapAmbiguity(RoadmapError):
+ def __init__(self, error):
+ RoadmapError.__init__(self, error)
+
+class RoadmapSearchPage(BasePage):
+ def search(self, departure, arrival, departure_time, arrival_time):
+ match = -1
+ i = 0
+ for form in self.browser.forms():
+ try:
+ if form.attrs['id'] == 'rech-iti':
+ match = i
+ except KeyError as e:
+ pass
+ i += 1
+
+ self.browser.select_form(nr=match)
+ self.browser['Departure'] = departure
+ self.browser['Destination'] = arrival
+
+ time = None
+ if departure_time:
+ self.browser['sens'] = ['1']
+ time = departure_time
+ elif arrival_time:
+ self.browser['sens'] = ['-1']
+ time = arrival_time
+
+ if time:
+ try:
+ self.browser['dateFull'] = '%02d/%02d/%d' % (time.day, time.month, time.year)
+ self.browser['hour'] = ['%02d' % time.hour]
+ self.browser['minute'] = ['%02d' % (time.minute - (time.minute%5))]
+ except ClientForm.ItemNotFoundError:
+ raise RoadmapError('Unable to establish a roadmap with %s time at "%s"' % ('departure' if departure_time else 'arrival', time))
+ self.browser.submit()
+
+class RoadmapResultsPage(BasePage):
+ def html_br_strip(self, text):
+ return "".join([l.strip() for l in text.split("\n")]).strip().replace(' ', '%20')
+
+ def find_best(self):
+ if len(self.parser.select(self.document.getroot(), 'img.img-error')) > 0:
+ if len(self.parser.select(self.document.getroot(), 'form#iti-ambi')) > 0:
+ raise RoadmapAmbiguity('Ambigious stop name')
+ else:
+ raise RoadmapError('Error when submitting form')
+
+ best = self.parser.select(self.document.getroot(), 'div.alerte-bloc-important div.bloc-iti')
+ if len(best) == 0:
+ best = self.parser.select(self.document.getroot(), 'div.bloc-iti')
+ if len(best) == 0:
+ raise RoadmapError('Unable to get the best roadmap');
+
+ link = self.parser.select(best[0], 'a.btn-submit')
+ if len(link) == 0:
+ raise RoadmapError('Unable to get a link to best roadmap')
+
+ return self.html_br_strip(link[0].attrib['href'])
+
+ def resubmit_best_form(self):
+ if len(self.parser.select(self.document.getroot(), 'img.img-error')) == 0:
+ raise RoadmapError('No error reported!')
+
+ ambi = None
+ i = 0
+ for form in self.parser.select(self.document.getroot(), 'form'):
+ if 'id' in form.attrib and form.attrib['id'] == 'iti-ambi':
+ ambi = form
+ break
+ i += 1
+
+ if ambi is None:
+ raise RoadmapError('No ambigous form!')
+
+ props = self.parser.select(ambi, 'span.precision-arret input')
+ if len(props) == 0:
+ props = self.parser.select(ambi, 'span.precision-adresse input')
+ if len(props) == 0:
+ raise RoadmapError('Nothing to select to get a roadmap')
+
+ self.browser.select_form(nr=i)
+ propname = props[0].attrib['name']
+ propvalue = props[0].attrib['value'].encode('utf-8')
+ self.browser[propname] = [ propvalue ]
+ self.browser.submit()
+
+class RoadmapPage(BasePage):
+ def get_steps(self):
+ errors = []
+ # for p in self.parser.select(self.document.getroot(), 'p.errors'):
+ # if p.text:
+ # errors.append(p.text.strip())
+
+ if len(errors) > 0:
+ raise RoadmapError('Unable to establish a roadmap: %s' % ', '.join(errors))
+
+ current_step = None
+ i = 0
+ for tr in self.parser.select(self.document.getroot(), 'table.itineraire-detail tr'):
+ if current_step is None:
+ current_step = {
+ 'id': i,
+ 'start_time': datetime.datetime.now(),
+ 'end_time': datetime.datetime.now(),
+ 'line': '',
+ 'departure': '',
+ 'arrival': '',
+ 'duration': datetime.timedelta()
+ }
+
+ if 'class' in tr.attrib:
+ if 'bg-ligne' in tr.attrib['class']:
+ continue
+
+ if 'iti-map' in tr.attrib['class']:
+ continue
+
+ for td in self.parser.select(tr, 'td'):
+ if not 'class' in td.attrib:
+ continue
+
+ if 'iti-inner' in td.attrib['class']:
+ continue
+
+ if 'cell-infos' in td.attrib['class']:
+ if 'id' in td.attrib:
+ if td.attrib['id'].find('MapOpenLink') >= 0:
+ hasA = self.parser.select(td, 'a')
+ if len(hasA) == 0:
+ if len(current_step['line']) > 0 and \
+ len(current_step['departure']) > 0 and \
+ len(current_step['arrival']) > 0:
+ current_step['line'] = to_unicode("%s : %s" % \
+ (current_step['mode'], current_step['line']))
+ del current_step['mode']
+ yield current_step
+ i += 1
+ current_step = None
+ continue
+
+ if 'cell-horaires' in td.attrib['class']:
+ # real start
+ for heure in self.parser.select(td, 'span.heure'):
+ if heure.attrib['id'].find('FromTime') >= 0:
+ current_step['start_time'] = self.parse_time(heure.text)
+ if heure.attrib['id'].find('ToTime') >= 0:
+ current_step['end_time'] = self.parse_time(heure.text)
+ for mode in self.parser.select(td, 'span.mode-locomotion img'):
+ current_step['mode'] = mode.attrib['title']
+
+ if 'cell-details' in td.attrib['class']:
+ # If we get a span, it's a line indication,
+ # otherwise check for id containing LibDeparture or
+ # LibDestination
+ spans = self.parser.select(td, 'span.itineraire-ligne')
+ if len(spans) == 1:
+ line = self.html_br_strip(spans[0].text, " ").replace('Ligne ', '')
+ if line.index('- ') == 0:
+ line = re.sub(r'^- ', '', line)
+ current_step['line'] = line
+
+ elif 'id' in td.attrib:
+ stops = self.parser.select(td, 'strong')
+ stop = self.html_br_strip(stops[0].text, " ")
+
+ if td.attrib['id'].find('LibDeparture') >= 0:
+ current_step['departure'] = to_unicode(stop)
+
+ if td.attrib['id'].find('LibDestination') >= 0:
+ current_step['arrival'] = to_unicode(stop)
+
+ duree = self.parser.select(td, 'span.duree strong')
+ if len(duree) == 1:
+ current_step['duration'] = self.parse_duration(duree[0].text)
+
+ def html_br_strip(self, text, joining=""):
+ return joining.join([l.strip() for l in text.split("\n")]).strip()
+
+ def parse_time(self, time):
+ time = self.html_br_strip(time)
+ h, m = time.split('h')
+ return datetime.time(int(h), int(m))
+
+ def parse_duration(self, dur):
+ dur = self.html_br_strip(dur)
+ m = re.match('(\d+)min', dur)
+ if m:
+ return datetime.timedelta(minutes=int(m.group(1)))
+ m = re.match('(\d+)h(\d+)', dur)
+ if m:
+ return datetime.timedelta(hours=int(m.group(1)),
+ minutes=int(m.group(2)))
+
+
diff --git a/modules/jvmalin/test.py b/modules/jvmalin/test.py
new file mode 100644
index 00000000..43c69302
--- /dev/null
+++ b/modules/jvmalin/test.py
@@ -0,0 +1,69 @@
+# -*- coding: utf-8 -*-
+
+# Copyright(C) 2013 Alexandre Lissy
+#
+# 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 datetime
+
+from weboob.capabilities.travel import RoadmapFilters
+from weboob.tools.test import BackendTest
+
+
+class JVMalinTest(BackendTest):
+ BACKEND = 'jvmalin'
+
+ def test_roadmap_cities(self):
+ filters = RoadmapFilters()
+ roadmap = list(self.backend.iter_roadmap('Tours', 'Orléans', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_stop_intercity(self):
+ filters = RoadmapFilters()
+ roadmap = list(self.backend.iter_roadmap('Tours Jean-Jaurès', 'Orléans', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_stop_intracity(self):
+ filters = RoadmapFilters()
+ roadmap = list(self.backend.iter_roadmap('Tours Jean-Jaurès', 'Polytech Tours', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_stop_intracity2(self):
+ filters = RoadmapFilters()
+ roadmap = list(self.backend.iter_roadmap('J.P.Rameau', 'Polytech Tours', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_names(self):
+ filters = RoadmapFilters()
+ roadmap = list(self.backend.iter_roadmap('Artannes Mairie', 'Château de Blois', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_long(self):
+ filters = RoadmapFilters()
+ roadmap = list(self.backend.iter_roadmap('Chartres', 'Ballan-Miré', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_departure(self):
+ filters = RoadmapFilters()
+ filters.departure_time = datetime.datetime.now() + datetime.timedelta(days=1)
+ roadmap = list(self.backend.iter_roadmap('Chartres', 'Ballan-Miré', filters))
+ self.assertTrue(len(roadmap) > 0)
+
+ def test_roadmap_arrival(self):
+ filters = RoadmapFilters()
+ filters.arrival_time = datetime.datetime.now() + datetime.timedelta(days=1)
+ roadmap = list(self.backend.iter_roadmap('Chartres', 'Ballan-Miré', filters))
+ self.assertTrue(len(roadmap) > 0)