VRS: Use endpoint eID=tx_ekap_here to query nearby locations because eID=tx_vrsinfo_ass2_timetable is no longer supported. Fixes #401.

This commit is contained in:
Michael Dyrna 2021-09-27 22:07:51 +02:00 committed by Andreas Schildbach
parent 5f159e4e3b
commit a0207b3ba7
2 changed files with 102 additions and 148 deletions

View file

@ -25,14 +25,11 @@ import java.text.SimpleDateFormat;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Arrays; import java.util.Arrays;
import java.util.Calendar; import java.util.Calendar;
import java.util.Collections;
import java.util.Comparator;
import java.util.Currency; import java.util.Currency;
import java.util.Date; import java.util.Date;
import java.util.EnumSet; import java.util.EnumSet;
import java.util.GregorianCalendar; import java.util.GregorianCalendar;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator; import java.util.Iterator;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
@ -47,9 +44,6 @@ import javax.annotation.Nullable;
import org.json.JSONArray; import org.json.JSONArray;
import org.json.JSONException; import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.common.base.Strings; import com.google.common.base.Strings;
import de.schildbach.pte.dto.Departure; import de.schildbach.pte.dto.Departure;
@ -90,8 +84,6 @@ public class VrsProvider extends AbstractNetworkProvider {
Capability.TRIPS_VIA Capability.TRIPS_VIA
); );
private static final Logger log = LoggerFactory.getLogger(VrsProvider.class);
@SuppressWarnings("serial") @SuppressWarnings("serial")
private static class Context implements QueryTripsContext { private static class Context implements QueryTripsContext {
private boolean canQueryLater = true; private boolean canQueryLater = true;
@ -156,8 +148,7 @@ public class VrsProvider extends AbstractNetworkProvider {
} }
// valid host names: www.vrsinfo.de, android.vrsinfo.de, ios.vrsinfo.de, ekap.vrsinfo.de (only SSL // valid host names: www.vrsinfo.de, android.vrsinfo.de, ios.vrsinfo.de, ekap.vrsinfo.de (only SSL
// encrypted with // encrypted with client certificate)
// client certificate)
// performance comparison March 2015 showed www.vrsinfo.de to be fastest for trips // performance comparison March 2015 showed www.vrsinfo.de to be fastest for trips
protected static final HttpUrl API_BASE = HttpUrl.parse("http://android.vrsinfo.de/index.php"); protected static final HttpUrl API_BASE = HttpUrl.parse("http://android.vrsinfo.de/index.php");
protected static final String SERVER_PRODUCT = "vrs"; protected static final String SERVER_PRODUCT = "vrs";
@ -354,68 +345,72 @@ public class VrsProvider extends AbstractNetworkProvider {
return CAPABILITIES.contains(capability); return CAPABILITIES.contains(capability);
} }
// only stations supported
@Override @Override
public NearbyLocationsResult queryNearbyLocations(Set<LocationType> types /* only STATION supported */, public NearbyLocationsResult queryNearbyLocations(Set<LocationType> types, Location location,
Location location, int maxDistance, int maxLocations) throws IOException { int maxDistance, int maxLocations) throws IOException {
// g=p means group by product; not used here final Point queryCoord;
final HttpUrl.Builder url = API_BASE.newBuilder();
url.addQueryParameter("eID", "tx_vrsinfo_ass2_timetable");
if (location.hasCoord()) { if (location.hasCoord()) {
url.addQueryParameter("r", queryCoord = location.coord;
String.format(Locale.ENGLISH, "%.6f,%.6f", location.getLatAsDouble(), location.getLonAsDouble()));
} else if (location.type == LocationType.STATION && location.hasId()) { } else if (location.type == LocationType.STATION && location.hasId()) {
url.addQueryParameter("i", location.id); queryCoord = stationToCoord(location.id);
} else { } else {
throw new IllegalArgumentException("at least one of stationId or lat/lon must be given"); throw new IllegalArgumentException("at least one of stationId or lat/lon must be given");
} }
// limits the departures at each stop - we need that to guess available products
url.addQueryParameter("c", "8");
if (maxLocations > 0) {
// s=number of stops, artificially limited by server
url.addQueryParameter("s", Integer.toString(Math.min(16, maxLocations)));
}
final HttpUrl.Builder url = API_BASE.newBuilder();
url.addQueryParameter("eID", "tx_ekap_here");
url.addQueryParameter("ta", "vrs");
url.addQueryParameter("lat", String.format(Locale.ENGLISH, "%.6f", queryCoord.getLatAsDouble()));
url.addQueryParameter("lon", String.format(Locale.ENGLISH, "%.6f", queryCoord.getLonAsDouble()));
final CharSequence page = httpClient.get(url.build()); final CharSequence page = httpClient.get(url.build());
try { try {
int num = 0;
final List<Location> locations = new ArrayList<>(); final List<Location> locations = new ArrayList<>();
final JSONObject head = new JSONObject(page.toString()); final JSONObject head = new JSONObject(page.toString());
final String error = Strings.emptyToNull(head.optString("error", "").trim()); final JSONArray objects = head.getJSONArray("objects");
if (error != null) { for (int i = 0; i < objects.length(); i++) {
if (error.equals("Leere Koordinate.") || error.equals("Leere ASS-ID und leere Koordinate") || error.equals("Keine Abfahrten gefunden.")) final JSONObject entry = objects.getJSONObject(i);
return new NearbyLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), locations); final LocationType type = parseLocationType(entry.getString("type"));
else if (error.equals("ASS2-Server lieferte leere Antwort.")) if (!(types.contains(type) || types.contains(LocationType.ANY))) {
return new NearbyLocationsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), continue;
NearbyLocationsResult.Status.SERVICE_DOWN);
else
throw new IllegalStateException("unknown error: " + error);
}
final JSONArray timetable = head.getJSONArray("timetable");
long serverTime = 0;
for (int i = 0; i < timetable.length(); i++) {
final JSONObject entry = timetable.getJSONObject(i);
final JSONObject stop = entry.getJSONObject("stop");
final JSONArray events = entry.getJSONArray("events");
final Location loc = parseLocationAndPosition(stop, events).location;
int distance = stop.getInt("distance");
if (maxDistance > 0 && distance > maxDistance) {
break; // we rely on the server side sorting by distance
} }
if (types.contains(loc.type) || types.contains(LocationType.ANY)) { final Point coord = Point.fromDouble(entry.getDouble("lat"), entry.getDouble("lon"));
locations.add(loc); if (maxDistance > 0 && entry.optInt("distance") > maxDistance) {
continue;
}
// TODO "distance" is only given for stops. For other location types, calculate distance from coordinates
String id = entry.optString("id");
if (id == null || id.isEmpty()) {
id = entry.getString("ifopt");
}
String place = entry.getString("municipality");
final String locality = entry.optString("locality");
if (locality != null && !locality.isEmpty()) {
place += "-" + locality;
}
String name = entry.getString("name");
if (entry.getString("type").equals("parkandride")) {
name = "P+R " + name;
}
final JSONArray lines = entry.optJSONArray("lines");
final EnumSet<Product> products = EnumSet.noneOf(Product.class);
for (int j = 0; lines != null && j < lines.length(); j++) {
final JSONObject line = lines.getJSONObject(j);
products.add(parseProduct(line.getString("productCode"), line.getString("name")));
}
locations.add(new Location(type, id, coord, place, name, products));
if (maxLocations > 0 && ++num >= maxLocations) {
break;
} }
serverTime = parseDateTime(entry.getString("generated")).getTime();
} }
final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT, null, null, serverTime, null); final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT, null, null, new Date().getTime(), null);
return new NearbyLocationsResult(header, locations); return new NearbyLocationsResult(header, locations);
} catch (final JSONException | ParseException x) { } catch (final JSONException x) {
throw new RuntimeException("cannot parse: '" + page + "' on " + url, x); throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
} }
} }
// VRS does not show LongDistanceTrains departures. Parameter p for product
// filter is supported, but LongDistanceTrains filter seems to be ignored.
// TODO equivs not supported; JSON result would support multiple timetables // TODO equivs not supported; JSON result would support multiple timetables
@Override @Override
public QueryDeparturesResult queryDepartures(final String stationId, @Nullable Date time, int maxDepartures, public QueryDeparturesResult queryDepartures(final String stationId, @Nullable Date time, int maxDepartures,
@ -431,6 +426,7 @@ public class VrsProvider extends AbstractNetworkProvider {
if (time != null) { if (time != null) {
url.addQueryParameter("t", formatDate(time)); url.addQueryParameter("t", formatDate(time));
} }
url.addQueryParameter("p", "LongDistanceTrains,RegionalTrains,SuburbanTrains,Underground,LightRail,Bus,CommunityBus,RailReplacementServices,Boat,OnDemandServices");
final CharSequence page = httpClient.get(url.build()); final CharSequence page = httpClient.get(url.build());
try { try {
@ -452,10 +448,10 @@ public class VrsProvider extends AbstractNetworkProvider {
final JSONArray timetable = head.getJSONArray("timetable"); final JSONArray timetable = head.getJSONArray("timetable");
final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT); final ResultHeader header = new ResultHeader(NetworkId.VRS, SERVER_PRODUCT);
final QueryDeparturesResult result = new QueryDeparturesResult(header); final QueryDeparturesResult result = new QueryDeparturesResult(header);
// for all stations
if (timetable.length() == 0) { if (timetable.length() == 0) {
return new QueryDeparturesResult(header, QueryDeparturesResult.Status.INVALID_STATION); return new QueryDeparturesResult(header, QueryDeparturesResult.Status.INVALID_STATION);
} }
// for all stations
for (int iStation = 0; iStation < timetable.length(); iStation++) { for (int iStation = 0; iStation < timetable.length(); iStation++) {
final List<Departure> departures = new ArrayList<>(); final List<Departure> departures = new ArrayList<>();
final JSONObject station = timetable.getJSONObject(iStation); final JSONObject station = timetable.getJSONObject(iStation);
@ -478,7 +474,7 @@ public class VrsProvider extends AbstractNetworkProvider {
Position position = null; Position position = null;
final JSONObject post = event.optJSONObject("post"); final JSONObject post = event.optJSONObject("post");
if (post != null) { if (post != null) {
final String postName = post.getString("name"); String postName = post.getString("name");
for (Pattern pattern : NAME_WITH_POSITION_PATTERNS) { for (Pattern pattern : NAME_WITH_POSITION_PATTERNS) {
Matcher matcher = pattern.matcher(postName); Matcher matcher = pattern.matcher(postName);
if (matcher.matches()) { if (matcher.matches()) {
@ -486,8 +482,11 @@ public class VrsProvider extends AbstractNetworkProvider {
break; break;
} }
} }
if (position == null) if (position == null) {
log.info("Could not extract position from '{}'", postName); if (postName.startsWith("(") && postName.endsWith(")"))
postName = postName.substring(1, postName.length() - 1);
position = new Position(postName);
}
} }
final Location destination = new Location(LocationType.STATION, null /* id */, null /* place */, final Location destination = new Location(LocationType.STATION, null /* id */, null /* place */,
lineObj.getString("direction")); lineObj.getString("direction"));
@ -501,8 +500,6 @@ public class VrsProvider extends AbstractNetworkProvider {
departures.add(d); departures.add(d);
} }
queryLinesForStation(location.id, lines);
result.stationDepartures.add(new StationDepartures(location, departures, lines)); result.stationDepartures.add(new StationDepartures(location, departures, lines));
} }
@ -512,62 +509,6 @@ public class VrsProvider extends AbstractNetworkProvider {
} }
} }
private void queryLinesForStation(String stationId, List<LineDestination> lineDestinations) throws IOException {
Set<String> lineNumbersAlreadyKnown = new HashSet<>();
for (LineDestination lineDestionation : lineDestinations) {
lineNumbersAlreadyKnown.add(lineDestionation.line.label);
}
final HttpUrl.Builder url = API_BASE.newBuilder();
url.addQueryParameter("eID", "tx_vrsinfo_his_info");
url.addQueryParameter("i", stationId);
final CharSequence page = httpClient.get(url.build());
try {
final JSONObject head = new JSONObject(page.toString());
final JSONObject his = head.optJSONObject("his");
if (his != null) {
final JSONArray lines = his.optJSONArray("lines");
if (lines != null) {
for (int iLine = 0; iLine < lines.length(); iLine++) {
final JSONObject line = lines.getJSONObject(iLine);
final String number = processLineNumber(line.getString("number"));
if (lineNumbersAlreadyKnown.contains(number)) {
continue;
}
final Product product = productFromLineNumber(number);
String direction = null;
final JSONArray postings = line.optJSONArray("postings");
if (postings != null) {
for (int iPosting = 0; iPosting < postings.length(); iPosting++) {
final JSONObject posting = (JSONObject) postings.get(iPosting);
direction = posting.getString("direction");
lineDestinations.add(new LineDestination(
new Line(null /* id */, NetworkId.VRS.toString(), product, number,
lineStyle("vrs", product, number)),
new Location(LocationType.STATION, null /* id */, null /* place */,
direction)));
}
} else {
lineDestinations.add(new LineDestination(new Line(null /* id */, NetworkId.VRS.toString(),
product, number, lineStyle("vrs", product, number)), null /* direction */));
}
}
}
}
} catch (final JSONException x) {
throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
}
Collections.sort(lineDestinations, new LineDestinationComparator());
}
private static class LineDestinationComparator implements Comparator<LineDestination> {
@Override
public int compare(LineDestination o1, LineDestination o2) {
return o1.line.compareTo(o2.line);
}
}
@Override @Override
public SuggestLocationsResult suggestLocations(final CharSequence constraint, public SuggestLocationsResult suggestLocations(final CharSequence constraint,
final @Nullable Set<LocationType> types, final int maxLocations) throws IOException { final @Nullable Set<LocationType> types, final int maxLocations) throws IOException {
@ -718,6 +659,9 @@ public class VrsProvider extends AbstractNetworkProvider {
else if (error.equals("Keine Verbindungen gefunden.")) else if (error.equals("Keine Verbindungen gefunden."))
return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
QueryTripsResult.Status.NO_TRIPS); QueryTripsResult.Status.NO_TRIPS);
else if (error.equals("Es wurden keine gültigen Verbindungen für diese Anfrage gefunden."))
return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
QueryTripsResult.Status.NO_TRIPS);
else if (error.startsWith("Keine Verbindung gefunden.")) else if (error.startsWith("Keine Verbindung gefunden."))
return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT), return new QueryTripsResult(new ResultHeader(NetworkId.VRS, SERVER_PRODUCT),
QueryTripsResult.Status.NO_TRIPS); QueryTripsResult.Status.NO_TRIPS);
@ -969,22 +913,6 @@ public class VrsProvider extends AbstractNetworkProvider {
return new Point[] { Point.from1E6(50937531, 6960279) }; return new Point[] { Point.from1E6(50937531, 6960279) };
} }
private static Product productFromLineNumber(String number) {
if (number.startsWith("I") || number.startsWith("E")) {
return Product.HIGH_SPEED_TRAIN;
} else if (number.startsWith("R") || number.startsWith("MRB") || number.startsWith("DPN")) {
return Product.REGIONAL_TRAIN;
} else if (number.startsWith("S") && !number.startsWith("SB") && !number.startsWith("SEV")) {
return Product.SUBURBAN_TRAIN;
} else if (number.startsWith("U")) {
return Product.SUBWAY;
} else if (number.length() <= 2 && !number.startsWith("N")) {
return Product.TRAM;
} else {
return Product.BUS;
}
}
private Line parseLine(JSONObject line) throws JSONException { private Line parseLine(JSONObject line) throws JSONException {
final String number = processLineNumber(line.getString("number")); final String number = processLineNumber(line.getString("number"));
final Product productObj = parseProduct(line.getString("product"), number); final Product productObj = parseProduct(line.getString("product"), number);
@ -1160,4 +1088,36 @@ public class VrsProvider extends AbstractNetworkProvider {
return new SimpleDateFormat("yyyy-MM-dd'T'kk:mm:ssZ") return new SimpleDateFormat("yyyy-MM-dd'T'kk:mm:ssZ")
.parse(dateTimeStr.substring(0, dateTimeStr.lastIndexOf(':')) + "00"); .parse(dateTimeStr.substring(0, dateTimeStr.lastIndexOf(':')) + "00");
} }
private final Point stationToCoord(String id) throws IOException {
final HttpUrl.Builder url = API_BASE.newBuilder();
url.addQueryParameter("eID", "tx_vrsinfo_ass2_timetable");
url.addQueryParameter("i", id);
final CharSequence page = httpClient.get(url.build());
try {
final JSONObject head = new JSONObject(page.toString());
final String error = Strings.emptyToNull(head.optString("error", "").trim());
if (error != null) {
throw new IllegalStateException(error);
}
final JSONArray timetable = head.getJSONArray("timetable");
final JSONObject entry = timetable.getJSONObject(0);
final JSONObject stop = entry.getJSONObject("stop");
return Point.fromDouble(stop.getDouble("x"), stop.getDouble("y"));
} catch (final JSONException x) {
throw new RuntimeException("cannot parse: '" + page + "' on " + url, x);
}
}
private final static LocationType parseLocationType(String type) {
if (type.equals("stop")) {
return LocationType.STATION;
} else if (type.equals("poi") || type.equals("parkandride")) {
return LocationType.POI;
} else {
return LocationType.ANY;
}
}
} }

View file

@ -53,7 +53,6 @@ import de.schildbach.pte.dto.StationDepartures;
import de.schildbach.pte.dto.Style; import de.schildbach.pte.dto.Style;
import de.schildbach.pte.dto.SuggestLocationsResult; import de.schildbach.pte.dto.SuggestLocationsResult;
import de.schildbach.pte.dto.TripOptions; import de.schildbach.pte.dto.TripOptions;
import de.schildbach.pte.util.Iso8601Format;
/** /**
* @author Michael Dyrna * @author Michael Dyrna
@ -71,7 +70,7 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
@Test @Test
public void nearbyStationsByCoordinate() throws Exception { public void nearbyStationsByCoordinate() throws Exception {
final NearbyLocationsResult result = queryNearbyStations(Location.coord(51218693, 6777785)); final NearbyLocationsResult result = queryNearbyStations(Location.coord(50942970, 6958570));
print(result); print(result);
final NearbyLocationsResult result2 = queryNearbyStations(Location.coord(51719648, 8754330)); final NearbyLocationsResult result2 = queryNearbyStations(Location.coord(51719648, 8754330));
@ -121,18 +120,18 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
print(result); print(result);
final NearbyLocationsResult result2 = queryNearbyLocations(EnumSet.of(LocationType.STATION), final NearbyLocationsResult result2 = queryNearbyLocations(EnumSet.of(LocationType.STATION),
Location.coord(50732100, 7096820), 0, 50); Location.coord(50732100, 7096820), 0, 1);
print(result2); print(result2);
final NearbyLocationsResult result3 = queryNearbyLocations(EnumSet.of(LocationType.STATION), final NearbyLocationsResult result3 = queryNearbyLocations(EnumSet.of(LocationType.STATION),
Location.coord(50732100, 7096820), 1000, 50); Location.coord(50732100, 7096820), 100, 0);
print(result3); print(result3);
} }
@Test @Test
public void nearbyLocationsEmpty() throws Exception { public void nearbyLocationsEmpty() throws Exception {
final NearbyLocationsResult result = queryNearbyLocations(EnumSet.allOf(LocationType.class), final NearbyLocationsResult result = queryNearbyLocations(EnumSet.allOf(LocationType.class),
Location.coord(1, 0), 0, 0); Location.coord(1, 1), 1000, 0);
print(result); print(result);
assertEquals(0, result.locations.size()); assertEquals(0, result.locations.size());
} }
@ -165,7 +164,6 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
@Test @Test
public void queryDeparturesGaussstr() throws Exception { public void queryDeparturesGaussstr() throws Exception {
final QueryDeparturesResult result = queryDepartures("8984", false); final QueryDeparturesResult result = queryDepartures("8984", false);
// will return {"error": "Keine Abfahrten gefunden."}
print(result); print(result);
printLineDestinations(result); printLineDestinations(result);
} }
@ -319,9 +317,9 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
} }
@Test @Test
public void tripEarlierLaterCologneBerlin() throws Exception { public void tripEarlierLaterCologneBonn() throws Exception {
final QueryTripsResult result = queryTrips(new Location(LocationType.STATION, "1"), null, final QueryTripsResult result = queryTrips(new Location(LocationType.STATION, "8"), null,
new Location(LocationType.STATION, "11458"), new Date(), true, null); new Location(LocationType.STATION, "687"), new Date(), true, null);
assertEquals(QueryTripsResult.Status.OK, result.status); assertEquals(QueryTripsResult.Status.OK, result.status);
assertTrue(result.trips.size() > 0); assertTrue(result.trips.size() > 0);
print(result); print(result);
@ -426,8 +424,7 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
"Kerpen-Sindorf", "Erftstraße 43"); "Kerpen-Sindorf", "Erftstraße 43");
final Location to = new Location(LocationType.ADDRESS, null /* id */, Point.from1E6(50923000, 6818440), final Location to = new Location(LocationType.ADDRESS, null /* id */, Point.from1E6(50923000, 6818440),
"Frechen", "Zedernweg 1"); "Frechen", "Zedernweg 1");
final QueryTripsResult result = queryTrips(from, null, to, Iso8601Format.parseDateTime("2015-03-17 21:11:18"), final QueryTripsResult result = queryTrips(from, null, to, new Date(), true, null);
true, null);
print(result); print(result);
assertEquals(QueryTripsResult.Status.OK, result.status); assertEquals(QueryTripsResult.Status.OK, result.status);
assertTrue(result.trips.size() > 0); assertTrue(result.trips.size() > 0);
@ -529,8 +526,7 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
} }
Set<Line> lines = new TreeSet<>(); Set<Line> lines = new TreeSet<>();
for (Location station : stations) { for (Location station : stations) {
QueryDeparturesResult qdr = provider.queryDepartures(station.id, QueryDeparturesResult qdr = provider.queryDepartures(station.id, new Date(), 100, false);
Iso8601Format.parseDateTime("2015-03-16 02:00:00"), 100, false);
if (qdr.status == QueryDeparturesResult.Status.OK) { if (qdr.status == QueryDeparturesResult.Status.OK) {
for (StationDepartures stationDepartures : qdr.stationDepartures) { for (StationDepartures stationDepartures : qdr.stationDepartures) {
final List<LineDestination> stationDeparturesLines = stationDepartures.lines; final List<LineDestination> stationDeparturesLines = stationDepartures.lines;
@ -549,11 +545,9 @@ public class VrsProviderLiveTest extends AbstractProviderLiveTest {
for (Line line : lines) { for (Line line : lines) {
final Product product = line.product; final Product product = line.product;
if (product != null) { if (product != null) {
if (product.equals(Product.BUS)) { final Style style = line.style;
final Style style = line.style; if (style != null) {
if (style != null) { System.out.printf("%s %s %6x\n", product, line.label, style.backgroundColor);
System.out.printf("%s %6x\n", line.label, style.backgroundColor);
}
} }
} }
} }