/* * Copyright 2010 the original author or authors. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program 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 General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ package de.schildbach.pte; import java.io.IOException; import java.text.DateFormat; import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Calendar; import java.util.Date; import java.util.GregorianCalendar; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.regex.Matcher; import java.util.regex.Pattern; import de.schildbach.pte.dto.Connection; import de.schildbach.pte.dto.Departure; import de.schildbach.pte.dto.GetConnectionDetailsResult; import de.schildbach.pte.dto.Location; import de.schildbach.pte.dto.LocationType; import de.schildbach.pte.dto.QueryConnectionsResult; import de.schildbach.pte.dto.QueryDeparturesResult; import de.schildbach.pte.dto.QueryDeparturesResult.Status; import de.schildbach.pte.util.ParserUtils; /** * @author Andreas Schildbach */ public class RmvProvider extends AbstractHafasProvider { public static final String NETWORK_ID = "mobil.rmv.de"; public static final String NETWORK_ID_ALT = "www.rmv.de"; private static final String API_BASE = "http://www.rmv.de/auskunft/bin/jp/"; private static final long PARSER_DAY_ROLLOVER_THRESHOLD_MS = 12 * 60 * 60 * 1000; public RmvProvider() { super(null, null); } public boolean hasCapabilities(final Capability... capabilities) { for (final Capability capability : capabilities) if (capability == Capability.DEPARTURES || capability == Capability.CONNECTIONS) return true; return false; } private static final String NAME_URL = API_BASE + "stboard.exe/dox?input="; private static final Pattern P_SINGLE_NAME = Pattern.compile(".*.*", Pattern.DOTALL); private static final Pattern P_MULTI_NAME = Pattern.compile("\\s*(.*?)\\s*", Pattern.DOTALL); @Override public List autocompleteStations(final CharSequence constraint) throws IOException { final CharSequence page = ParserUtils.scrape(NAME_URL + ParserUtils.urlEncode(constraint.toString())); final List results = new ArrayList(); final Matcher mSingle = P_SINGLE_NAME.matcher(page); if (mSingle.matches()) { results.add(new Location(LocationType.STATION, Integer.parseInt(mSingle.group(2)), 0, 0, ParserUtils.resolveEntities(mSingle.group(1)))); } else { final Matcher mMulti = P_MULTI_NAME.matcher(page); while (mMulti.find()) results .add(new Location(LocationType.STATION, Integer.parseInt(mMulti.group(1)), 0, 0, ParserUtils.resolveEntities(mMulti.group(2)))); } return results; } private final String NEARBY_URI = API_BASE + "stboard.exe/dn?L=vs_rmv&distance=50&near&input=%s"; @Override protected String nearbyStationUri(final String stationId) { return String.format(NEARBY_URI, stationId); } private static final Map WALKSPEED_MAP = new HashMap(); static { WALKSPEED_MAP.put(WalkSpeed.SLOW, "115"); WALKSPEED_MAP.put(WalkSpeed.NORMAL, "100"); WALKSPEED_MAP.put(WalkSpeed.FAST, "85"); } private String connectionsQueryUri(final Location from, final Location via, final Location to, final Date date, final boolean dep, final String products, final WalkSpeed walkSpeed) { final DateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yy"); final DateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm"); final StringBuilder uri = new StringBuilder(); uri.append(API_BASE).append("query.exe/dox"); uri.append("?REQ0HafasInitialSelection=0"); uri.append("&REQ0HafasSearchForw=").append(dep ? "1" : "0"); uri.append("&REQ0JourneyDate=").append(ParserUtils.urlEncode(DATE_FORMAT.format(date))); uri.append("&REQ0JourneyTime=").append(ParserUtils.urlEncode(TIME_FORMAT.format(date))); uri.append("&REQ0JourneyStopsS0G=").append(ParserUtils.urlEncode(locationValue(from))); uri.append("&REQ0JourneyStopsS0A=").append(locationTypeValue(from)); uri.append("&REQ0JourneyStopsZ0G=").append(ParserUtils.urlEncode(locationValue(to))); uri.append("&REQ0JourneyStopsZ0A=").append(locationTypeValue(to)); if (via != null) { uri.append("&REQ0JourneyStops1.0G=").append(ParserUtils.urlEncode(locationValue(via))); uri.append("&REQ0JourneyStops1.0A=").append(locationTypeValue(via)); } uri.append("&REQ0JourneyDep_Foot_speed=").append(WALKSPEED_MAP.get(walkSpeed)); for (final char p : products.toCharArray()) { if (p == 'I') uri.append("&REQ0JourneyProduct_prod_list_1=1000000000000000"); if (p == 'R') uri.append("&REQ0JourneyProduct_prod_list_2=0110000000100000"); if (p == 'S') uri.append("&REQ0JourneyProduct_prod_list_3=0001000000000000"); if (p == 'U') uri.append("&REQ0JourneyProduct_prod_list_4=0000100000000000"); if (p == 'T') uri.append("&REQ0JourneyProduct_prod_list_5=0000010000000000"); if (p == 'B') uri.append("&REQ0JourneyProduct_prod_list_6=0000001101000000"); if (p == 'F') uri.append("&REQ0JourneyProduct_prod_list_7=0000000010000000"); // FIXME if (p == 'C') } uri.append("&start=Suchen"); return uri.toString(); } private static int locationTypeValue(final Location location) { final LocationType type = location.type; if (type == LocationType.STATION) return 1; if (type == LocationType.ADDRESS) return 2; if (type == LocationType.ANY) return 255; throw new IllegalArgumentException(type.toString()); } private static String locationValue(final Location location) { if (location.type == LocationType.STATION && location.id != 0) return Integer.toString(location.id); else return location.name; } private static final Pattern P_PRE_ADDRESS = Pattern.compile("(?:Geben Sie einen (Startort|Zielort) an.*?)?Bitte wählen Sie aus der Liste", Pattern.DOTALL); private static final Pattern P_ADDRESSES = Pattern.compile( ".*?\\s*(.*?)\\s*.*?", Pattern.DOTALL); private static final Pattern P_CHECK_CONNECTIONS_ERROR = Pattern.compile( "(mehrfach vorhanden oder identisch)|(keine Verbindung gefunden werden)|(derzeit nur Auskünfte vom)", Pattern.CASE_INSENSITIVE); @Override public QueryConnectionsResult queryConnections(final Location from, final Location via, final Location to, final Date date, final boolean dep, final String products, final WalkSpeed walkSpeed) throws IOException { final String uri = connectionsQueryUri(from, via, to, date, dep, products, walkSpeed); final CharSequence page = ParserUtils.scrape(uri); final Matcher mError = P_CHECK_CONNECTIONS_ERROR.matcher(page); if (mError.find()) { if (mError.group(1) != null) return QueryConnectionsResult.TOO_CLOSE; if (mError.group(2) != null) return QueryConnectionsResult.NO_CONNECTIONS; if (mError.group(3) != null) return QueryConnectionsResult.INVALID_DATE; } List fromAddresses = null; List viaAddresses = null; List toAddresses = null; final Matcher mPreAddress = P_PRE_ADDRESS.matcher(page); while (mPreAddress.find()) { final String type = mPreAddress.group(1); final Matcher mAddresses = P_ADDRESSES.matcher(page); final List addresses = new ArrayList(); while (mAddresses.find()) { final String address = ParserUtils.resolveEntities(mAddresses.group(1)).trim(); if (!addresses.contains(address)) addresses.add(new Location(LocationType.ANY, 0, 0, 0, address + "!")); } if (type == null) viaAddresses = addresses; else if (type.equals("Startort")) fromAddresses = addresses; else if (type.equals("Zielort")) toAddresses = addresses; else throw new IllegalStateException(type); } if (fromAddresses != null || viaAddresses != null || toAddresses != null) return new QueryConnectionsResult(fromAddresses, viaAddresses, toAddresses); else return queryConnections(uri, page); } private static final Pattern P_CONNECTIONS_HEAD = Pattern.compile(".*?" // + "Von: (.*?).*?" // from + "Nach: (.*?).*?" // to + "Datum: .., (\\d+\\..\\d+\\.\\d+).*?" // currentDate + "(?:(.+?)

", Pattern.DOTALL); private static final Pattern P_CONNECTIONS_FINE = Pattern.compile(".*?" // + "
" // link + "(\\d+:\\d+)-(\\d+:\\d+)" // + "(?: (.+?))?" // , Pattern.DOTALL); @Override public QueryConnectionsResult queryMoreConnections(final String uri) throws IOException { final CharSequence page = ParserUtils.scrape(uri); return queryConnections(uri, page); } private QueryConnectionsResult queryConnections(final String uri, final CharSequence page) throws IOException { final Matcher mHead = P_CONNECTIONS_HEAD.matcher(page); if (mHead.matches()) { final Location from = new Location(LocationType.ANY, 0, 0, 0, ParserUtils.resolveEntities(mHead.group(1))); final Location to = new Location(LocationType.ANY, 0, 0, 0, ParserUtils.resolveEntities(mHead.group(2))); final Date currentDate = ParserUtils.parseDate(mHead.group(3)); final String linkEarlier = mHead.group(4) != null ? ParserUtils.resolveEntities(mHead.group(4)) : null; final String linkLater = mHead.group(5) != null ? ParserUtils.resolveEntities(mHead.group(5)) : null; final List connections = new ArrayList(); final Matcher mConCoarse = P_CONNECTIONS_COARSE.matcher(page); while (mConCoarse.find()) { final Matcher mConFine = P_CONNECTIONS_FINE.matcher(mConCoarse.group(1)); if (mConFine.matches()) { final String link = ParserUtils.resolveEntities(mConFine.group(1)); Date departureTime = ParserUtils.joinDateTime(currentDate, ParserUtils.parseTime(mConFine.group(2))); if (!connections.isEmpty()) { final long diff = ParserUtils.timeDiff(departureTime, connections.get(connections.size() - 1).departureTime); if (diff > PARSER_DAY_ROLLOVER_THRESHOLD_MS) departureTime = ParserUtils.addDays(departureTime, -1); else if (diff < -PARSER_DAY_ROLLOVER_THRESHOLD_MS) departureTime = ParserUtils.addDays(departureTime, 1); } Date arrivalTime = ParserUtils.joinDateTime(currentDate, ParserUtils.parseTime(mConFine.group(3))); if (departureTime.after(arrivalTime)) arrivalTime = ParserUtils.addDays(arrivalTime, 1); String line = mConFine.group(4); if (line != null && !line.endsWith("Um.")) line = normalizeLine(line); else line = null; final Connection connection = new Connection(extractConnectionId(link), link, departureTime, arrivalTime, line, line != null ? lineColors(line) : null, 0, from.name, 0, to.name, null); connections.add(connection); } else { throw new IllegalArgumentException("cannot parse '" + mConCoarse.group(1) + "' on " + uri); } } return new QueryConnectionsResult(uri, from, null, to, linkLater, connections); } else { throw new IOException(page.toString()); } } private static final Pattern P_CONNECTION_DETAILS_HEAD = Pattern.compile(".*?

\n" // + "- (.*?) -.*?" // firstDeparture + "Abfahrt: (\\d{2}\\.\\d{2}\\.\\d{2})
\n"// date + "(?:Ankunft: \\d{2}\\.\\d{2}\\.\\d{2}
\n)?" // + "Dauer: (\\d{1,2}:\\d{2})
.*?" // duration , Pattern.DOTALL); private static final Pattern P_CONNECTION_DETAILS_COARSE = Pattern.compile("/b> -\n(.*?- [^<]*)<", Pattern.DOTALL); private static final Pattern P_CONNECTION_DETAILS_FINE = Pattern.compile("
\n" // + "(?:(.*?) nach (.*?)\n" // line, destination + "
\n" // + "ab (\\d{1,2}:\\d{2})\n" // departureTime + "(?:(.*?)\\s*\n)?" // departurePosition + "
\n" // + "an (\\d{1,2}:\\d{2})\n" // arrivalTime + "(?:(.*?)\\s*\n)?" // arrivalPosition + "
\n|" // + "]*>\n" // + "Fussweg\\s*\n" // + "\n" // + "(\\d+) Min.
\n)" // footway + "- (.*?)" // arrival , Pattern.DOTALL); @Override public GetConnectionDetailsResult getConnectionDetails(final String uri) throws IOException { final CharSequence page = ParserUtils.scrape(uri); final Matcher mHead = P_CONNECTION_DETAILS_HEAD.matcher(page); if (mHead.matches()) { final String firstDeparture = ParserUtils.resolveEntities(mHead.group(1)); final Date currentDate = ParserUtils.parseDate(mHead.group(2)); final List parts = new ArrayList(4); Date lastTime = currentDate; Date firstDepartureTime = null; Date lastArrivalTime = null; String lastArrival = null; Connection.Trip lastTrip = null; final Matcher mDetCoarse = P_CONNECTION_DETAILS_COARSE.matcher(page); while (mDetCoarse.find()) { final Matcher mDetFine = P_CONNECTION_DETAILS_FINE.matcher(mDetCoarse.group(1)); if (mDetFine.matches()) { final String departure = lastArrival != null ? lastArrival : firstDeparture; final String arrival = ParserUtils.resolveEntities(mDetFine.group(8)); lastArrival = arrival; final String min = mDetFine.group(7); if (min == null) { final String line = normalizeLine(ParserUtils.resolveEntities(mDetFine.group(1))); final String destination = ParserUtils.resolveEntities(mDetFine.group(2)); final Date departureTime = upTime(lastTime, ParserUtils.joinDateTime(currentDate, ParserUtils.parseTime(mDetFine.group(3)))); final String departurePosition = ParserUtils.resolveEntities(mDetFine.group(4)); final Date arrivalTime = upTime(lastTime, ParserUtils.joinDateTime(currentDate, ParserUtils.parseTime(mDetFine.group(5)))); final String arrivalPosition = ParserUtils.resolveEntities(mDetFine.group(6)); lastTrip = new Connection.Trip(line, line != null ? lineColors(line) : null, 0, destination, departureTime, departurePosition, 0, departure, arrivalTime, arrivalPosition, 0, arrival); parts.add(lastTrip); if (firstDepartureTime == null) firstDepartureTime = departureTime; lastArrivalTime = arrivalTime; } else { if (parts.size() > 0 && parts.get(parts.size() - 1) instanceof Connection.Footway) { final Connection.Footway lastFootway = (Connection.Footway) parts.remove(parts.size() - 1); parts.add(new Connection.Footway(lastFootway.min + Integer.parseInt(min), 0, lastFootway.departure, 0, arrival)); } else { parts.add(new Connection.Footway(Integer.parseInt(min), 0, departure, 0, arrival)); } } } else { throw new IllegalArgumentException("cannot parse '" + mDetCoarse.group(1) + "' on " + uri); } } return new GetConnectionDetailsResult(currentDate, new Connection(extractConnectionId(uri), uri, firstDepartureTime, lastArrivalTime, null, null, 0, firstDeparture, 0, lastArrival, parts)); } else { throw new IOException(page.toString()); } } private static Date upTime(final Date lastTime, Date time) { while (time.before(lastTime)) time = ParserUtils.addDays(time, 1); lastTime.setTime(time.getTime()); return time; } private String departuresQueryUri(final String stationId, final int maxDepartures) { final DateFormat DATE_FORMAT = new SimpleDateFormat("dd.MM.yy"); final DateFormat TIME_FORMAT = new SimpleDateFormat("HH:mm"); final Date now = new Date(); final StringBuilder uri = new StringBuilder(); uri.append(API_BASE).append("stboard.exe/dox"); uri.append("?input=").append(stationId); uri.append("&boardType=dep"); // show departures uri.append("&maxJourneys=").append(maxDepartures != 0 ? maxDepartures : 50); // maximum taken from RMV site uri.append("&time=").append(TIME_FORMAT.format(now)); uri.append("&date=").append(DATE_FORMAT.format(now)); uri.append("&disableEquivs=yes"); // don't use nearby stations uri.append("&start=yes"); return uri.toString(); } private static final Pattern P_DEPARTURES_HEAD_COARSE = Pattern.compile(".*?" // + "(?:" // + "

\n(.*?)

\n" // head + "(.*?)

.*?" // departures + "input=(\\d+).*?" // locationId + "|(Eingabe kann nicht interpretiert)" // messages + "|(Internal Error)" // messages + ").*?", Pattern.DOTALL); private static final Pattern P_DEPARTURES_HEAD_FINE = Pattern.compile("" // + "(.*?)
.*?" // + "Abfahrt (\\d+:\\d+).*?" // + "Uhr, (\\d+\\.\\d+\\.\\d+).*?" // , Pattern.DOTALL); private static final Pattern P_DEPARTURES_COARSE = Pattern.compile("

\n(.+?)

", Pattern.DOTALL); static final Pattern P_DEPARTURES_FINE = Pattern.compile("" // + "\\s*(.*?)\\s*.*?" // line + ">>\n" // + "(.*?)\n" // destination + "
\n" // + "(\\d{1,2}:\\d{2})\n" // plannedTime + "(?:keine Prognose verfügbar\n)?" // + "(?:ca\\. (\\d{1,2}:\\d{2})\n)?" // predictedTime + "(?:heute (Gl\\. " + ParserUtils.P_PLATFORM + ")
\n)?" // predictedPosition + "(?:(Gl\\. " + ParserUtils.P_PLATFORM + ")
\n)?" // position + "(?:([^>]*)\n)?" // message + "(?:\"\"\n[^<]*\n
\n)*" // (messages) , Pattern.DOTALL); public QueryDeparturesResult queryDepartures(final String stationId, final int maxDepartures) throws IOException { final CharSequence page = ParserUtils.scrape(departuresQueryUri(stationId, maxDepartures)); // parse page final Matcher mHeadCoarse = P_DEPARTURES_HEAD_COARSE.matcher(page); if (mHeadCoarse.matches()) { // messages if (mHeadCoarse.group(4) != null) return new QueryDeparturesResult(Status.INVALID_STATION, Integer.parseInt(stationId)); else if (mHeadCoarse.group(5) != null) return new QueryDeparturesResult(Status.SERVICE_DOWN, Integer.parseInt(stationId)); final int locationId = Integer.parseInt(mHeadCoarse.group(3)); final Matcher mHeadFine = P_DEPARTURES_HEAD_FINE.matcher(mHeadCoarse.group(1)); if (mHeadFine.matches()) { final String location = ParserUtils.resolveEntities(mHeadFine.group(1)); final Date currentTime = ParserUtils.joinDateTime(ParserUtils.parseDate(mHeadFine.group(3)), ParserUtils .parseTime(mHeadFine.group(2))); final List departures = new ArrayList(8); final Matcher mDepCoarse = P_DEPARTURES_COARSE.matcher(mHeadCoarse.group(2)); while (mDepCoarse.find()) { final Matcher mDepFine = P_DEPARTURES_FINE.matcher(mDepCoarse.group(1)); if (mDepFine.matches()) { final String line = normalizeLine(ParserUtils.resolveEntities(mDepFine.group(1))); final String destination = ParserUtils.resolveEntities(mDepFine.group(2)); final Calendar current = new GregorianCalendar(); current.setTime(currentTime); final Calendar parsed = new GregorianCalendar(); parsed.setTime(ParserUtils.parseTime(mDepFine.group(3))); parsed.set(Calendar.YEAR, current.get(Calendar.YEAR)); parsed.set(Calendar.MONTH, current.get(Calendar.MONTH)); parsed.set(Calendar.DAY_OF_MONTH, current.get(Calendar.DAY_OF_MONTH)); if (ParserUtils.timeDiff(parsed.getTime(), currentTime) < -PARSER_DAY_ROLLOVER_THRESHOLD_MS) parsed.add(Calendar.DAY_OF_MONTH, 1); final Date plannedTime = parsed.getTime(); Date predictedTime = null; if (mDepFine.group(4) != null) { parsed.setTime(ParserUtils.parseTime(mDepFine.group(4))); parsed.set(Calendar.YEAR, current.get(Calendar.YEAR)); parsed.set(Calendar.MONTH, current.get(Calendar.MONTH)); parsed.set(Calendar.DAY_OF_MONTH, current.get(Calendar.DAY_OF_MONTH)); if (ParserUtils.timeDiff(parsed.getTime(), currentTime) < -PARSER_DAY_ROLLOVER_THRESHOLD_MS) parsed.add(Calendar.DAY_OF_MONTH, 1); predictedTime = parsed.getTime(); } final String position = ParserUtils.resolveEntities(ParserUtils.selectNotNull(mDepFine.group(5), mDepFine.group(6))); final Departure dep = new Departure(plannedTime, predictedTime, line, line != null ? lineColors(line) : null, null, position, 0, destination, null); if (!departures.contains(dep)) departures.add(dep); } else { throw new IllegalArgumentException("cannot parse '" + mDepCoarse.group(1) + "' on " + stationId); } } return new QueryDeparturesResult(new Location(LocationType.STATION, locationId, 0, 0, location), departures); } else { throw new IllegalArgumentException("cannot parse '" + mHeadCoarse.group(1) + "' on " + stationId); } } else { throw new IllegalArgumentException("cannot parse '" + page + "' on " + stationId); } } private static String normalizeLine(final String line) { if (line == null || line.length() == 0) return null; final Matcher m = P_NORMALIZE_LINE.matcher(line); if (m.matches()) { final String type = m.group(1); final String number = m.group(2).replace(" ", ""); if (type.equals("ICE")) // InterCityExpress return "IICE" + number; if (type.equals("IC")) // InterCity return "IIC" + number; if (type.equals("EC")) // EuroCity return "IEC" + number; if (type.equals("EN")) // EuroNight return "IEN" + number; if (type.equals("CNL")) // CityNightLine return "ICNL" + number; if (type.equals("DNZ")) // Basel-Minsk, Nacht return "IDNZ" + number; if (type.equals("D")) // Prag-Fulda return "ID" + number; if (type.equals("RB")) // RegionalBahn return "RRB" + number; if (type.equals("RE")) // RegionalExpress return "RRE" + number; if (type.equals("SE")) // StadtExpress return "RSE" + number; if (type.equals("R")) return "R" + number; if (type.equals("S")) return "SS" + number; if (type.equals("U")) return "UU" + number; if (type.equals("Tram")) return "T" + number; if (type.equals("RT")) // RegioTram return "TRT" + number; if (type.startsWith("Bus")) return "B" + type.substring(3) + number; if (type.startsWith("AST")) // Anruf-Sammel-Taxi return "BAST" + type.substring(3) + number; if (type.startsWith("ALT")) // Anruf-Linien-Taxi return "BALT" + type.substring(3) + number; if (type.equals("LTaxi")) return "BLTaxi" + number; if (type.equals("AT")) // AnschlußSammelTaxi return "BAT" + number; if (type.equals("SCH")) return "FSCH" + number; throw new IllegalStateException("cannot normalize type " + type + " number " + number + " line " + line); } throw new IllegalStateException("cannot normalize line " + line); } @Override protected char normalizeType(final String type) { throw new UnsupportedOperationException(); } }