random-streetview 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ dist/
6
+ build/
7
+ *.egg-info/
8
+ .pytest_cache/
9
+ .ruff_cache/
10
+ .env
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AngLaboratory
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,98 @@
1
+ Metadata-Version: 2.5
2
+ Name: random-streetview
3
+ Version: 0.1.0
4
+ Summary: One call, one random Google Street View panorama
5
+ Project-URL: Homepage, https://github.com/AngLaboratory/random-streetview
6
+ Project-URL: Source, https://github.com/AngLaboratory/random-streetview
7
+ Project-URL: Issues, https://github.com/AngLaboratory/random-streetview/issues
8
+ Author-email: AngLaboratory <anglaboratory@gmail.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: geoguessr,google-street-view,panoid,panorama,random,street-view,streetview
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Topic :: Multimedia :: Graphics
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.9
25
+ Requires-Dist: requests>=2.25
26
+ Description-Content-Type: text/markdown
27
+
28
+ # random-streetview
29
+
30
+ [![PyPI](https://img.shields.io/pypi/v/random-streetview)](https://pypi.org/project/random-streetview/)
31
+ [![Python](https://img.shields.io/pypi/pyversions/random-streetview)](https://pypi.org/project/random-streetview/)
32
+
33
+ One call, one random Google Street View panorama.
34
+
35
+ ```bash
36
+ pip install random-streetview
37
+ ```
38
+
39
+ ```python
40
+ from random_streetview import random_panorama
41
+
42
+ pano = random_panorama()
43
+
44
+ pano.id # 'ptFCsB5aFyaWDDlpbCliVw'
45
+ pano.lon # 2.2954822
46
+ pano.lat # 48.8583758
47
+ pano.url # 'https://www.google.com/maps/@?api=1&map_action=pano&pano=...'
48
+ ```
49
+
50
+ `Panorama` is a named tuple, so `panoid, lon, lat = random_panorama()` works too.
51
+
52
+ Nothing raises on a network or parsing failure — you get `None` once the
53
+ attempts are used up.
54
+
55
+ ## Timeouts
56
+
57
+ ```python
58
+ random_panorama(max_retry=10, timeout=3.0) # the defaults
59
+ ```
60
+
61
+ `timeout` is per request, in seconds, and is raised to **0.5** if you ask for
62
+ less. A single call budgets at most **30 seconds** of timeout across its
63
+ attempts, so `max_retry` is an upper bound rather than a promise: at
64
+ `timeout=10.0` you get 3 attempts, not 10. The default pairing is sized to
65
+ reach all 10. Time actually spent on the wire does not count against the
66
+ budget — only the timeouts do.
67
+
68
+ ## How it works, and why it can break
69
+
70
+ Picking a coordinate at random mostly lands you in the ocean, so candidates
71
+ come from [randomstreetview.com][rsv] and [wandery.it][wandery], which keep
72
+ lists of places known to have coverage. Each candidate is then resolved to a
73
+ panorama id through `GeoPhotoService.SingleImageSearch`, an **undocumented
74
+ internal Google Maps endpoint**.
75
+
76
+ That means no API key — and no guarantees. The scraped sites will change their
77
+ HTML, and Google can alter or start refusing that endpoint at any time; when
78
+ that happens this library returns `None` rather than breaking your program. If
79
+ you need something dependable, use the official
80
+ [Street View Static API metadata endpoint][meta] with a key instead (metadata
81
+ requests are not billed).
82
+
83
+ Requests are not rate-limited for you. Space out your calls.
84
+
85
+ ## Development
86
+
87
+ ```bash
88
+ uv sync
89
+ uv run pytest # fully offline; network calls are monkeypatched
90
+ ```
91
+
92
+ ## License
93
+
94
+ MIT
95
+
96
+ [rsv]: https://randomstreetview.com/
97
+ [wandery]: https://www.wandery.it/
98
+ [meta]: https://developers.google.com/maps/documentation/streetview/metadata
@@ -0,0 +1,71 @@
1
+ # random-streetview
2
+
3
+ [![PyPI](https://img.shields.io/pypi/v/random-streetview)](https://pypi.org/project/random-streetview/)
4
+ [![Python](https://img.shields.io/pypi/pyversions/random-streetview)](https://pypi.org/project/random-streetview/)
5
+
6
+ One call, one random Google Street View panorama.
7
+
8
+ ```bash
9
+ pip install random-streetview
10
+ ```
11
+
12
+ ```python
13
+ from random_streetview import random_panorama
14
+
15
+ pano = random_panorama()
16
+
17
+ pano.id # 'ptFCsB5aFyaWDDlpbCliVw'
18
+ pano.lon # 2.2954822
19
+ pano.lat # 48.8583758
20
+ pano.url # 'https://www.google.com/maps/@?api=1&map_action=pano&pano=...'
21
+ ```
22
+
23
+ `Panorama` is a named tuple, so `panoid, lon, lat = random_panorama()` works too.
24
+
25
+ Nothing raises on a network or parsing failure — you get `None` once the
26
+ attempts are used up.
27
+
28
+ ## Timeouts
29
+
30
+ ```python
31
+ random_panorama(max_retry=10, timeout=3.0) # the defaults
32
+ ```
33
+
34
+ `timeout` is per request, in seconds, and is raised to **0.5** if you ask for
35
+ less. A single call budgets at most **30 seconds** of timeout across its
36
+ attempts, so `max_retry` is an upper bound rather than a promise: at
37
+ `timeout=10.0` you get 3 attempts, not 10. The default pairing is sized to
38
+ reach all 10. Time actually spent on the wire does not count against the
39
+ budget — only the timeouts do.
40
+
41
+ ## How it works, and why it can break
42
+
43
+ Picking a coordinate at random mostly lands you in the ocean, so candidates
44
+ come from [randomstreetview.com][rsv] and [wandery.it][wandery], which keep
45
+ lists of places known to have coverage. Each candidate is then resolved to a
46
+ panorama id through `GeoPhotoService.SingleImageSearch`, an **undocumented
47
+ internal Google Maps endpoint**.
48
+
49
+ That means no API key — and no guarantees. The scraped sites will change their
50
+ HTML, and Google can alter or start refusing that endpoint at any time; when
51
+ that happens this library returns `None` rather than breaking your program. If
52
+ you need something dependable, use the official
53
+ [Street View Static API metadata endpoint][meta] with a key instead (metadata
54
+ requests are not billed).
55
+
56
+ Requests are not rate-limited for you. Space out your calls.
57
+
58
+ ## Development
59
+
60
+ ```bash
61
+ uv sync
62
+ uv run pytest # fully offline; network calls are monkeypatched
63
+ ```
64
+
65
+ ## License
66
+
67
+ MIT
68
+
69
+ [rsv]: https://randomstreetview.com/
70
+ [wandery]: https://www.wandery.it/
71
+ [meta]: https://developers.google.com/maps/documentation/streetview/metadata
@@ -0,0 +1,54 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "random-streetview"
7
+ version = "0.1.0"
8
+ description = "One call, one random Google Street View panorama"
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "AngLaboratory", email = "anglaboratory@gmail.com" }]
14
+ keywords = [
15
+ "streetview",
16
+ "street-view",
17
+ "google-street-view",
18
+ "random",
19
+ "panorama",
20
+ "panoid",
21
+ "geoguessr",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "Operating System :: OS Independent",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3.9",
29
+ "Programming Language :: Python :: 3.10",
30
+ "Programming Language :: Python :: 3.11",
31
+ "Programming Language :: Python :: 3.12",
32
+ "Programming Language :: Python :: 3.13",
33
+ "Programming Language :: Python :: 3.14",
34
+ "Topic :: Multimedia :: Graphics",
35
+ "Typing :: Typed",
36
+ ]
37
+ dependencies = ["requests>=2.25"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://github.com/AngLaboratory/random-streetview"
41
+ Source = "https://github.com/AngLaboratory/random-streetview"
42
+ Issues = "https://github.com/AngLaboratory/random-streetview/issues"
43
+
44
+ [dependency-groups]
45
+ dev = ["pytest>=8.0"]
46
+
47
+ [tool.hatch.build.targets.wheel]
48
+ packages = ["src/random_streetview"]
49
+
50
+ [tool.hatch.build.targets.sdist]
51
+ include = ["src/", "tests/", "README.md", "LICENSE"]
52
+
53
+ [tool.pytest.ini_options]
54
+ testpaths = ["tests"]
@@ -0,0 +1,180 @@
1
+ """One call, one random Google Street View panorama.
2
+
3
+ >>> from random_streetview import random_panorama
4
+ >>> random_panorama()
5
+ Panorama(id='ptFCsB5aFyaWDDlpbCliVw', lon=2.2954822, lat=48.8583758)
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import random
12
+ import re
13
+ from typing import NamedTuple, Optional, Tuple
14
+
15
+ import requests
16
+
17
+ __version__ = "0.1.0"
18
+ __all__ = ["Panorama", "random_panorama"]
19
+
20
+ # Per-request timeout: the default, and the floor any caller is held to.
21
+ _DEFAULT_TIMEOUT = 3.0
22
+ _MIN_TIMEOUT = 0.5
23
+
24
+ # Total timeout a single call may budget across its attempts. Measured in
25
+ # timeout units rather than wall clock, so time actually spent on the wire
26
+ # does not count against it.
27
+ _TIMEOUT_BUDGET = 30.0
28
+
29
+ _USER_AGENT = (
30
+ "random-streetview/0.1.0 "
31
+ "(+https://github.com/AngLaboratory/random-streetview)"
32
+ )
33
+
34
+ # Google's panorama ids are always this long; anything else is a miss.
35
+ _PANOID_LENGTH = 22
36
+
37
+ # Undocumented internal Maps endpoint -- no API key, no guarantees.
38
+ _SEARCH_URL = (
39
+ "https://maps.googleapis.com/maps/api/js/GeoPhotoService.SingleImageSearch"
40
+ )
41
+
42
+ # Picking a point at random mostly lands you in the ocean, so candidates come
43
+ # from sites that keep their own lists of places known to have coverage.
44
+ _RSV_URL = "https://randomstreetview.com/"
45
+ _RSV_RE = re.compile(r"randomLocations\.all\s*=\s*(\[\{.*?\}\]);", re.S)
46
+
47
+ _WANDERY_URL = (
48
+ "https://www.wandery.it/random-street/random-streetview-lateral-thinking.php"
49
+ )
50
+ _WANDERY_RE = re.compile(r"!2m2!1d([-\d.]+)!2d([-\d.]+)", re.S)
51
+
52
+ _LonLat = Tuple[Optional[float], Optional[float]]
53
+
54
+
55
+ class Panorama(NamedTuple):
56
+ """A Street View panorama and where it is."""
57
+
58
+ id: str
59
+ lon: float
60
+ lat: float
61
+
62
+ @property
63
+ def url(self) -> str:
64
+ """A google.com/maps link that opens this panorama."""
65
+ return (
66
+ "https://www.google.com/maps/@"
67
+ f"?api=1&map_action=pano&pano={self.id}"
68
+ )
69
+
70
+
71
+ def _get(url: str, timeout: float) -> Optional[str]:
72
+ try:
73
+ response = requests.get(
74
+ url, headers={"User-Agent": _USER_AGENT}, timeout=timeout
75
+ )
76
+ response.raise_for_status()
77
+ return response.text
78
+ except Exception:
79
+ return None
80
+
81
+
82
+ def _from_randomstreetview(timeout: float) -> _LonLat:
83
+ body = _get(_RSV_URL, timeout)
84
+ if body is None:
85
+ return None, None
86
+
87
+ match = _RSV_RE.search(body)
88
+ if not match:
89
+ return None, None
90
+ try:
91
+ locations = json.loads(match.group(1))
92
+ point = random.choice(locations)
93
+ return float(point["lng"]), float(point["lat"])
94
+ except (ValueError, KeyError, TypeError, IndexError):
95
+ return None, None
96
+
97
+
98
+ def _from_wandery(timeout: float) -> _LonLat:
99
+ body = _get(_WANDERY_URL, timeout)
100
+ if body is None:
101
+ return None, None
102
+
103
+ match = _WANDERY_RE.search(body)
104
+ if not match:
105
+ return None, None
106
+ try:
107
+ return float(match.group(2)), float(match.group(1))
108
+ except ValueError:
109
+ return None, None
110
+
111
+
112
+ def _random_lonlat(timeout: float) -> _LonLat:
113
+ for source in (_from_randomstreetview, _from_wandery):
114
+ lon, lat = source(timeout)
115
+ if lon is not None and lat is not None:
116
+ return lon, lat
117
+ return None, None
118
+
119
+
120
+ def _panoid_at(lon: float, lat: float, timeout: float) -> Optional[str]:
121
+ body = _get(
122
+ f"{_SEARCH_URL}?pb=!1m5!1sapiv3!5sUS!11m2!1m1!1b0"
123
+ f"!2m4!1m2!3d{lat}!4d{lon}!2d50!3m10"
124
+ "!2m2!1sen!2sGB!9m1!1e2!11m4!1m3!1e2!2b1!3e2!4m10!1e1!1e2!1e3!1e4"
125
+ "!1e8!1e6!5m1!1e2!6m1!1e2&callback=callbackfunc",
126
+ timeout,
127
+ )
128
+ if body is None:
129
+ return None
130
+
131
+ # The id is the first quoted string in the JSONP payload.
132
+ start = body.find('"')
133
+ end = body.find('"', start + 1) if start != -1 else -1
134
+ if start == -1 or end == -1:
135
+ return None
136
+
137
+ panoid = body[start + 1 : end]
138
+ # "generic" is what the endpoint returns when it has nothing to offer.
139
+ if panoid == "generic" or len(panoid) != _PANOID_LENGTH:
140
+ return None
141
+ return panoid
142
+
143
+
144
+ def random_panorama(
145
+ *, max_retry: int = 10, timeout: float = _DEFAULT_TIMEOUT
146
+ ) -> Optional[Panorama]:
147
+ """A random Street View panorama from somewhere on Earth.
148
+
149
+ ``timeout`` is the per-request timeout in seconds, raised to
150
+ :data:`_MIN_TIMEOUT` if you ask for less. Attempts stop early once
151
+ ``timeout`` times the attempts made would exceed the 30 second budget, so
152
+ ``max_retry`` is an upper bound rather than a promise -- the defaults are
153
+ sized to reach all 10.
154
+
155
+ Returns ``None`` when the attempts are used up without a hit. Nothing here
156
+ raises on a network or parsing failure.
157
+ """
158
+ if max_retry < 1:
159
+ raise ValueError("max_retry must be at least 1")
160
+
161
+ timeout = max(timeout, _MIN_TIMEOUT)
162
+ if timeout > _TIMEOUT_BUDGET:
163
+ raise ValueError(
164
+ "timeout must not exceed the {:.0f}s budget".format(_TIMEOUT_BUDGET)
165
+ )
166
+
167
+ budgeted = 0.0
168
+ for _ in range(max_retry):
169
+ if budgeted + timeout > _TIMEOUT_BUDGET:
170
+ break
171
+ budgeted += timeout
172
+
173
+ lon, lat = _random_lonlat(timeout)
174
+ if lon is None or lat is None:
175
+ continue
176
+ panoid = _panoid_at(lon, lat, timeout)
177
+ if panoid is not None:
178
+ return Panorama(panoid, lon, lat)
179
+
180
+ return None
File without changes
@@ -0,0 +1,126 @@
1
+ """Offline tests -- every network call is monkeypatched."""
2
+
3
+ import pytest
4
+
5
+ import random_streetview as rsv
6
+ from random_streetview import Panorama, random_panorama
7
+
8
+ _RSV_BODY = 'var x; randomLocations.all = [{"lat":48.8584,"lng":2.2945}];'
9
+ _WANDERY_BODY = "?pb=!2m2!1d48.8584!2d2.2945!3m1"
10
+ _JSONP = 'callbackfunc([1,"ptFCsB5aFyaWDDlpbCliVw",[null]])'
11
+
12
+
13
+ def test_panorama_unpacks_and_builds_a_url():
14
+ pano = Panorama("a" * 22, 2.2945, 48.8584)
15
+ assert (pano.id, pano.lon, pano.lat) == tuple(pano)
16
+ assert pano.url.endswith("pano=" + "a" * 22)
17
+
18
+
19
+ def test_max_retry_must_be_positive():
20
+ with pytest.raises(ValueError):
21
+ random_panorama(max_retry=0)
22
+
23
+
24
+ def test_timeout_is_raised_to_the_floor(monkeypatch):
25
+ seen = []
26
+
27
+ def fake_get(url, timeout):
28
+ seen.append(timeout)
29
+ return None
30
+
31
+ monkeypatch.setattr(rsv, "_get", fake_get)
32
+ random_panorama(max_retry=1, timeout=0.01)
33
+ assert seen and all(t == rsv._MIN_TIMEOUT for t in seen)
34
+
35
+
36
+ def test_timeout_larger_than_the_budget_is_rejected():
37
+ with pytest.raises(ValueError):
38
+ random_panorama(timeout=rsv._TIMEOUT_BUDGET + 1)
39
+
40
+
41
+ def test_attempts_stop_at_the_budget(monkeypatch):
42
+ attempts = []
43
+
44
+ def fake_get(url, timeout):
45
+ if url.startswith("https://randomstreetview"):
46
+ attempts.append(url)
47
+ return None
48
+
49
+ monkeypatch.setattr(rsv, "_get", fake_get)
50
+ # 10s per request leaves room for 3 attempts inside the 30s budget,
51
+ # not the 10 that max_retry asks for.
52
+ assert random_panorama(max_retry=10, timeout=10.0) is None
53
+ assert len(attempts) == 3
54
+
55
+
56
+ def test_defaults_reach_every_retry(monkeypatch):
57
+ attempts = []
58
+
59
+ def fake_get(url, timeout):
60
+ if url.startswith("https://randomstreetview"):
61
+ attempts.append(url)
62
+ return None
63
+
64
+ monkeypatch.setattr(rsv, "_get", fake_get)
65
+ assert random_panorama() is None
66
+ assert len(attempts) == 10
67
+
68
+
69
+ def test_happy_path(monkeypatch):
70
+ def fake_get(url, timeout):
71
+ return _RSV_BODY if url.startswith("https://randomstreetview") else _JSONP
72
+
73
+ monkeypatch.setattr(rsv, "_get", fake_get)
74
+ assert random_panorama() == Panorama("ptFCsB5aFyaWDDlpbCliVw", 2.2945, 48.8584)
75
+
76
+
77
+ def test_falls_back_to_the_second_source(monkeypatch):
78
+ def fake_get(url, timeout):
79
+ if url.startswith("https://randomstreetview"):
80
+ return None
81
+ if url.startswith("https://www.wandery"):
82
+ return _WANDERY_BODY
83
+ return _JSONP
84
+
85
+ monkeypatch.setattr(rsv, "_get", fake_get)
86
+ pano = random_panorama()
87
+ assert pano is not None
88
+ assert (pano.lon, pano.lat) == (2.2945, 48.8584)
89
+
90
+
91
+ def test_returns_none_when_everything_fails(monkeypatch):
92
+ monkeypatch.setattr(rsv, "_get", lambda url, timeout: None)
93
+ assert random_panorama(max_retry=3) is None
94
+
95
+
96
+ def test_rejects_a_generic_panoid(monkeypatch):
97
+ def fake_get(url, timeout):
98
+ if url.startswith("https://randomstreetview"):
99
+ return _RSV_BODY
100
+ return 'callbackfunc([1,"generic",[null]])'
101
+
102
+ monkeypatch.setattr(rsv, "_get", fake_get)
103
+ assert random_panorama(max_retry=2) is None
104
+
105
+
106
+ def test_rejects_a_wrong_length_panoid(monkeypatch):
107
+ def fake_get(url, timeout):
108
+ if url.startswith("https://randomstreetview"):
109
+ return _RSV_BODY
110
+ return 'callbackfunc([1,"tooshort",[null]])'
111
+
112
+ monkeypatch.setattr(rsv, "_get", fake_get)
113
+ assert random_panorama(max_retry=2) is None
114
+
115
+
116
+ def test_survives_an_unparseable_source(monkeypatch):
117
+ monkeypatch.setattr(rsv, "_get", lambda url, timeout: "not what we expected")
118
+ assert random_panorama(max_retry=2) is None
119
+
120
+
121
+ def test_get_swallows_network_errors(monkeypatch):
122
+ def boom(*args, **kwargs):
123
+ raise OSError("no network")
124
+
125
+ monkeypatch.setattr(rsv.requests, "get", boom)
126
+ assert rsv._get("https://example.com", 1.0) is None