jlt-bus-schedule 1.0.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.
- jlt_bus_schedule-1.0.0/.gitignore +211 -0
- jlt_bus_schedule-1.0.0/LICENSE +21 -0
- jlt_bus_schedule-1.0.0/PKG-INFO +148 -0
- jlt_bus_schedule-1.0.0/README.md +118 -0
- jlt_bus_schedule-1.0.0/pyproject.toml +65 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/__init__.py +22 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/client.py +95 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/const.py +15 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/exceptions.py +9 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/models.py +30 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/parser.py +90 -0
- jlt_bus_schedule-1.0.0/src/jlt_bus_schedule/py.typed +0 -0
- jlt_bus_schedule-1.0.0/tests/__init__.py +1 -0
- jlt_bus_schedule-1.0.0/tests/conftest.py +87 -0
- jlt_bus_schedule-1.0.0/tests/fixtures/departures_6001350.html +522 -0
- jlt_bus_schedule-1.0.0/tests/fixtures/stop_search_jonkoping.html +58 -0
- jlt_bus_schedule-1.0.0/tests/test_client.py +154 -0
- jlt_bus_schedule-1.0.0/tests/test_parser.py +109 -0
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
# Byte-compiled / optimized / DLL files
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[codz]
|
|
4
|
+
*$py.class
|
|
5
|
+
|
|
6
|
+
# C extensions
|
|
7
|
+
*.so
|
|
8
|
+
|
|
9
|
+
# Distribution / packaging
|
|
10
|
+
.Python
|
|
11
|
+
build/
|
|
12
|
+
develop-eggs/
|
|
13
|
+
dist/
|
|
14
|
+
downloads/
|
|
15
|
+
eggs/
|
|
16
|
+
.eggs/
|
|
17
|
+
lib/
|
|
18
|
+
lib64/
|
|
19
|
+
parts/
|
|
20
|
+
sdist/
|
|
21
|
+
var/
|
|
22
|
+
wheels/
|
|
23
|
+
share/python-wheels/
|
|
24
|
+
*.egg-info/
|
|
25
|
+
.installed.cfg
|
|
26
|
+
*.egg
|
|
27
|
+
MANIFEST
|
|
28
|
+
|
|
29
|
+
# PyInstaller
|
|
30
|
+
# Usually these files are written by a python script from a template
|
|
31
|
+
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
|
32
|
+
*.manifest
|
|
33
|
+
*.spec
|
|
34
|
+
|
|
35
|
+
# Installer logs
|
|
36
|
+
pip-log.txt
|
|
37
|
+
pip-delete-this-directory.txt
|
|
38
|
+
|
|
39
|
+
# Unit test / coverage reports
|
|
40
|
+
htmlcov/
|
|
41
|
+
.tox/
|
|
42
|
+
.nox/
|
|
43
|
+
.coverage
|
|
44
|
+
.coverage.*
|
|
45
|
+
.cache
|
|
46
|
+
nosetests.xml
|
|
47
|
+
coverage.xml
|
|
48
|
+
*.cover
|
|
49
|
+
*.py.cover
|
|
50
|
+
.hypothesis/
|
|
51
|
+
.pytest_cache/
|
|
52
|
+
cover/
|
|
53
|
+
|
|
54
|
+
# Translations
|
|
55
|
+
*.mo
|
|
56
|
+
*.pot
|
|
57
|
+
|
|
58
|
+
# Django stuff:
|
|
59
|
+
*.log
|
|
60
|
+
local_settings.py
|
|
61
|
+
db.sqlite3
|
|
62
|
+
db.sqlite3-journal
|
|
63
|
+
|
|
64
|
+
# Flask stuff:
|
|
65
|
+
instance/
|
|
66
|
+
.webassets-cache
|
|
67
|
+
|
|
68
|
+
# Scrapy stuff:
|
|
69
|
+
.scrapy
|
|
70
|
+
|
|
71
|
+
# Sphinx documentation
|
|
72
|
+
docs/_build/
|
|
73
|
+
|
|
74
|
+
# PyBuilder
|
|
75
|
+
.pybuilder/
|
|
76
|
+
target/
|
|
77
|
+
|
|
78
|
+
# Jupyter Notebook
|
|
79
|
+
.ipynb_checkpoints
|
|
80
|
+
|
|
81
|
+
# IPython
|
|
82
|
+
profile_default/
|
|
83
|
+
ipython_config.py
|
|
84
|
+
|
|
85
|
+
# pyenv
|
|
86
|
+
# For a library or package, you might want to ignore these files since the code is
|
|
87
|
+
# intended to run in multiple environments; otherwise, check them in:
|
|
88
|
+
# .python-version
|
|
89
|
+
|
|
90
|
+
# pipenv
|
|
91
|
+
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
|
92
|
+
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
|
93
|
+
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
|
94
|
+
# install all needed dependencies.
|
|
95
|
+
#Pipfile.lock
|
|
96
|
+
|
|
97
|
+
# UV
|
|
98
|
+
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
|
|
99
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
100
|
+
# commonly ignored for libraries.
|
|
101
|
+
#uv.lock
|
|
102
|
+
|
|
103
|
+
# poetry
|
|
104
|
+
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
|
105
|
+
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
|
106
|
+
# commonly ignored for libraries.
|
|
107
|
+
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
|
108
|
+
#poetry.lock
|
|
109
|
+
#poetry.toml
|
|
110
|
+
|
|
111
|
+
# pdm
|
|
112
|
+
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
|
113
|
+
# pdm recommends including project-wide configuration in pdm.toml, but excluding .pdm-python.
|
|
114
|
+
# https://pdm-project.org/en/latest/usage/project/#working-with-version-control
|
|
115
|
+
#pdm.lock
|
|
116
|
+
#pdm.toml
|
|
117
|
+
.pdm-python
|
|
118
|
+
.pdm-build/
|
|
119
|
+
|
|
120
|
+
# pixi
|
|
121
|
+
# Similar to Pipfile.lock, it is generally recommended to include pixi.lock in version control.
|
|
122
|
+
#pixi.lock
|
|
123
|
+
# Pixi creates a virtual environment in the .pixi directory, just like venv module creates one
|
|
124
|
+
# in the .venv directory. It is recommended not to include this directory in version control.
|
|
125
|
+
.pixi
|
|
126
|
+
|
|
127
|
+
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
|
128
|
+
__pypackages__/
|
|
129
|
+
|
|
130
|
+
# Celery stuff
|
|
131
|
+
celerybeat-schedule
|
|
132
|
+
celerybeat.pid
|
|
133
|
+
|
|
134
|
+
# SageMath parsed files
|
|
135
|
+
*.sage.py
|
|
136
|
+
|
|
137
|
+
# Environments
|
|
138
|
+
.env
|
|
139
|
+
.envrc
|
|
140
|
+
.venv
|
|
141
|
+
env/
|
|
142
|
+
venv/
|
|
143
|
+
ENV/
|
|
144
|
+
env.bak/
|
|
145
|
+
venv.bak/
|
|
146
|
+
|
|
147
|
+
# Spyder project settings
|
|
148
|
+
.spyderproject
|
|
149
|
+
.spyproject
|
|
150
|
+
|
|
151
|
+
# Rope project settings
|
|
152
|
+
.ropeproject
|
|
153
|
+
|
|
154
|
+
# mkdocs documentation
|
|
155
|
+
/site
|
|
156
|
+
|
|
157
|
+
# mypy
|
|
158
|
+
.mypy_cache/
|
|
159
|
+
.dmypy.json
|
|
160
|
+
dmypy.json
|
|
161
|
+
|
|
162
|
+
# Pyre type checker
|
|
163
|
+
.pyre/
|
|
164
|
+
|
|
165
|
+
# pytype static type analyzer
|
|
166
|
+
.pytype/
|
|
167
|
+
|
|
168
|
+
# Cython debug symbols
|
|
169
|
+
cython_debug/
|
|
170
|
+
|
|
171
|
+
# PyCharm
|
|
172
|
+
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
|
173
|
+
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
|
174
|
+
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
|
175
|
+
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
|
176
|
+
#.idea/
|
|
177
|
+
|
|
178
|
+
# Abstra
|
|
179
|
+
# Abstra is an AI-powered process automation framework.
|
|
180
|
+
# Ignore directories containing user credentials, local state, and settings.
|
|
181
|
+
# Learn more at https://abstra.io/docs
|
|
182
|
+
.abstra/
|
|
183
|
+
|
|
184
|
+
# Visual Studio Code
|
|
185
|
+
# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore
|
|
186
|
+
# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore
|
|
187
|
+
# and can be added to the global gitignore or merged into this file. However, if you prefer,
|
|
188
|
+
# you could uncomment the following to ignore the entire vscode folder
|
|
189
|
+
# .vscode/
|
|
190
|
+
|
|
191
|
+
# Ruff stuff:
|
|
192
|
+
.ruff_cache/
|
|
193
|
+
|
|
194
|
+
# PyPI configuration file
|
|
195
|
+
.pypirc
|
|
196
|
+
|
|
197
|
+
# Cursor
|
|
198
|
+
# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to
|
|
199
|
+
# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data
|
|
200
|
+
# refer to https://docs.cursor.com/context/ignore-files
|
|
201
|
+
.cursorignore
|
|
202
|
+
.cursorindexingignore
|
|
203
|
+
|
|
204
|
+
# Marimo
|
|
205
|
+
marimo/_static/
|
|
206
|
+
marimo/_lsp/
|
|
207
|
+
__marimo__/
|
|
208
|
+
|
|
209
|
+
# Jetbrains
|
|
210
|
+
.idea/
|
|
211
|
+
/uv.lock
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jonny Bergdahl
|
|
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,148 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: jlt-bus-schedule
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Async client that scrapes bus departures and stops from JLT (Jönköpings Länstrafik)
|
|
5
|
+
Project-URL: Homepage, https://github.com/jonnybergdahl/Python-JLT-BusSchedule-Scraper
|
|
6
|
+
Project-URL: Issues, https://github.com/jonnybergdahl/Python-JLT-BusSchedule-Scraper/issues
|
|
7
|
+
Author: Jonny Bergdahl
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: bus,home assistant,jlt,jönköping,public transport
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: AsyncIO
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: OS Independent
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: aiohttp>=3.9
|
|
24
|
+
Requires-Dist: beautifulsoup4>=4.12.0
|
|
25
|
+
Requires-Dist: tzdata; sys_platform == 'win32'
|
|
26
|
+
Provides-Extra: test
|
|
27
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
|
|
28
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
|
|
31
|
+
# Python-JLT-BusSchedule-Scraper
|
|
32
|
+
Python library that scrapes the JLT site for bus schedules.
|
|
33
|
+
|
|
34
|
+
[JLT (Jönköpings Länstrafik)](https://www.jlt.se) has no public API for departures, so this
|
|
35
|
+
library scrapes the HTML fragment used by the departure board on jlt.se, and the stop search
|
|
36
|
+
of the travel planner that jlt.se embeds from etiscloud.net.
|
|
37
|
+
|
|
38
|
+
It is used by the [JLT Bus Schedule](https://github.com/jonnybergdahl/HomeAssistant_JltBusSchedule_Integration)
|
|
39
|
+
Home Assistant integration.
|
|
40
|
+
|
|
41
|
+
## Installation
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
pip install jlt-bus-schedule
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Requires Python 3.11 or later.
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
```python
|
|
52
|
+
import asyncio
|
|
53
|
+
|
|
54
|
+
from jlt_bus_schedule import JltApiClient
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
async def main() -> None:
|
|
58
|
+
async with JltApiClient() as client:
|
|
59
|
+
stops = await client.async_search_stops("Rådhusparken")
|
|
60
|
+
stop = stops[0]
|
|
61
|
+
print(f"{stop.name} ({stop.id})")
|
|
62
|
+
|
|
63
|
+
for departure in await client.async_get_departures(stop.id, take=10):
|
|
64
|
+
print(
|
|
65
|
+
f"{departure.departure_time} {departure.line:>3} "
|
|
66
|
+
f"{departure.destination} ({departure.time_to_departure})"
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
asyncio.run(main())
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
Jönköping Rådhusparken (6001320)
|
|
75
|
+
13:20 3 Råslätt via Asecs-Ryhov (Avgår nu)
|
|
76
|
+
13:21 141 Sävsjö via Vrigstad (Avgår nu)
|
|
77
|
+
13:22 4 Huskvarna via Solåsen-Ryhov (Avgår om 1 min)
|
|
78
|
+
...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
### Sharing an aiohttp session
|
|
82
|
+
|
|
83
|
+
Pass an existing `aiohttp.ClientSession` to share it with the rest of your application, as
|
|
84
|
+
Home Assistant integrations should. The client never closes a session it did not create.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
88
|
+
|
|
89
|
+
client = JltApiClient(async_get_clientsession(hass))
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Without a session the client creates its own, which is closed by `await client.close()` or when
|
|
93
|
+
leaving an `async with` block.
|
|
94
|
+
|
|
95
|
+
A custom `User-Agent` header can be set with `JltApiClient(session, user_agent="MyApp/1.0")`.
|
|
96
|
+
|
|
97
|
+
## API
|
|
98
|
+
|
|
99
|
+
### `JltApiClient`
|
|
100
|
+
|
|
101
|
+
| Method | Description |
|
|
102
|
+
|--------|-------------|
|
|
103
|
+
| `async_get_departures(stop_id, take=20, timeout=10)` | Upcoming departures for all lines at a stop, as a list of `Departure`. Departures that have already left are skipped. |
|
|
104
|
+
| `async_search_stops(query, timeout=10)` | Stops matching a name, best match first, as a list of `Stop`. |
|
|
105
|
+
| `close()` | Close the session, if it was created by the client. |
|
|
106
|
+
|
|
107
|
+
Both methods raise `JltConnectionError` on HTTP errors, connection errors and timeouts. All
|
|
108
|
+
exceptions raised by the library inherit from `JltError`.
|
|
109
|
+
|
|
110
|
+
### `Departure`
|
|
111
|
+
|
|
112
|
+
| Attribute | Type | Example |
|
|
113
|
+
|-----------|------|---------|
|
|
114
|
+
| `line` | `str` | `"1"` |
|
|
115
|
+
| `destination` | `str` | `"Huskvarna via Centrum-Elmia"` |
|
|
116
|
+
| `direction` | `str` | `"mot Huskvarna via Centrum-Elmia"` |
|
|
117
|
+
| `departure` | `datetime` | Timezone aware, in `JLT_TIMEZONE` (Europe/Stockholm) |
|
|
118
|
+
| `departure_time` | `str` | `"11:41"` |
|
|
119
|
+
| `time_to_departure` | `str` | `"Avgår om 5 min"` |
|
|
120
|
+
|
|
121
|
+
### `Stop`
|
|
122
|
+
|
|
123
|
+
| Attribute | Type | Example |
|
|
124
|
+
|-----------|------|---------|
|
|
125
|
+
| `id` | `int` | `6001320` |
|
|
126
|
+
| `name` | `str` | `"Jönköping Rådhusparken"` |
|
|
127
|
+
|
|
128
|
+
### Parsers
|
|
129
|
+
|
|
130
|
+
`parse_departures(html, now)` and `parse_stops(html)` parse the HTML fragments without doing
|
|
131
|
+
any network requests. `parse_departures` uses `now` to place the clock times on the right date,
|
|
132
|
+
so departures after midnight end up on the next day.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install -e ".[test]"
|
|
138
|
+
pytest
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Tests marked `live` call the real jlt.se site to detect markup changes. Skip them with
|
|
142
|
+
`pytest -m "not live"`.
|
|
143
|
+
|
|
144
|
+
## Releasing
|
|
145
|
+
|
|
146
|
+
Releases are published to PyPI by the `Publish to PyPI` workflow when a GitHub release is
|
|
147
|
+
published, using [trusted publishing](https://docs.pypi.org/trusted-publishers/). Bump
|
|
148
|
+
`__version__` in `src/jlt_bus_schedule/const.py` before creating the release.
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# Python-JLT-BusSchedule-Scraper
|
|
2
|
+
Python library that scrapes the JLT site for bus schedules.
|
|
3
|
+
|
|
4
|
+
[JLT (Jönköpings Länstrafik)](https://www.jlt.se) has no public API for departures, so this
|
|
5
|
+
library scrapes the HTML fragment used by the departure board on jlt.se, and the stop search
|
|
6
|
+
of the travel planner that jlt.se embeds from etiscloud.net.
|
|
7
|
+
|
|
8
|
+
It is used by the [JLT Bus Schedule](https://github.com/jonnybergdahl/HomeAssistant_JltBusSchedule_Integration)
|
|
9
|
+
Home Assistant integration.
|
|
10
|
+
|
|
11
|
+
## Installation
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
pip install jlt-bus-schedule
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Requires Python 3.11 or later.
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```python
|
|
22
|
+
import asyncio
|
|
23
|
+
|
|
24
|
+
from jlt_bus_schedule import JltApiClient
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def main() -> None:
|
|
28
|
+
async with JltApiClient() as client:
|
|
29
|
+
stops = await client.async_search_stops("Rådhusparken")
|
|
30
|
+
stop = stops[0]
|
|
31
|
+
print(f"{stop.name} ({stop.id})")
|
|
32
|
+
|
|
33
|
+
for departure in await client.async_get_departures(stop.id, take=10):
|
|
34
|
+
print(
|
|
35
|
+
f"{departure.departure_time} {departure.line:>3} "
|
|
36
|
+
f"{departure.destination} ({departure.time_to_departure})"
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
asyncio.run(main())
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
Jönköping Rådhusparken (6001320)
|
|
45
|
+
13:20 3 Råslätt via Asecs-Ryhov (Avgår nu)
|
|
46
|
+
13:21 141 Sävsjö via Vrigstad (Avgår nu)
|
|
47
|
+
13:22 4 Huskvarna via Solåsen-Ryhov (Avgår om 1 min)
|
|
48
|
+
...
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Sharing an aiohttp session
|
|
52
|
+
|
|
53
|
+
Pass an existing `aiohttp.ClientSession` to share it with the rest of your application, as
|
|
54
|
+
Home Assistant integrations should. The client never closes a session it did not create.
|
|
55
|
+
|
|
56
|
+
```python
|
|
57
|
+
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
|
58
|
+
|
|
59
|
+
client = JltApiClient(async_get_clientsession(hass))
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Without a session the client creates its own, which is closed by `await client.close()` or when
|
|
63
|
+
leaving an `async with` block.
|
|
64
|
+
|
|
65
|
+
A custom `User-Agent` header can be set with `JltApiClient(session, user_agent="MyApp/1.0")`.
|
|
66
|
+
|
|
67
|
+
## API
|
|
68
|
+
|
|
69
|
+
### `JltApiClient`
|
|
70
|
+
|
|
71
|
+
| Method | Description |
|
|
72
|
+
|--------|-------------|
|
|
73
|
+
| `async_get_departures(stop_id, take=20, timeout=10)` | Upcoming departures for all lines at a stop, as a list of `Departure`. Departures that have already left are skipped. |
|
|
74
|
+
| `async_search_stops(query, timeout=10)` | Stops matching a name, best match first, as a list of `Stop`. |
|
|
75
|
+
| `close()` | Close the session, if it was created by the client. |
|
|
76
|
+
|
|
77
|
+
Both methods raise `JltConnectionError` on HTTP errors, connection errors and timeouts. All
|
|
78
|
+
exceptions raised by the library inherit from `JltError`.
|
|
79
|
+
|
|
80
|
+
### `Departure`
|
|
81
|
+
|
|
82
|
+
| Attribute | Type | Example |
|
|
83
|
+
|-----------|------|---------|
|
|
84
|
+
| `line` | `str` | `"1"` |
|
|
85
|
+
| `destination` | `str` | `"Huskvarna via Centrum-Elmia"` |
|
|
86
|
+
| `direction` | `str` | `"mot Huskvarna via Centrum-Elmia"` |
|
|
87
|
+
| `departure` | `datetime` | Timezone aware, in `JLT_TIMEZONE` (Europe/Stockholm) |
|
|
88
|
+
| `departure_time` | `str` | `"11:41"` |
|
|
89
|
+
| `time_to_departure` | `str` | `"Avgår om 5 min"` |
|
|
90
|
+
|
|
91
|
+
### `Stop`
|
|
92
|
+
|
|
93
|
+
| Attribute | Type | Example |
|
|
94
|
+
|-----------|------|---------|
|
|
95
|
+
| `id` | `int` | `6001320` |
|
|
96
|
+
| `name` | `str` | `"Jönköping Rådhusparken"` |
|
|
97
|
+
|
|
98
|
+
### Parsers
|
|
99
|
+
|
|
100
|
+
`parse_departures(html, now)` and `parse_stops(html)` parse the HTML fragments without doing
|
|
101
|
+
any network requests. `parse_departures` uses `now` to place the clock times on the right date,
|
|
102
|
+
so departures after midnight end up on the next day.
|
|
103
|
+
|
|
104
|
+
## Development
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
pip install -e ".[test]"
|
|
108
|
+
pytest
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Tests marked `live` call the real jlt.se site to detect markup changes. Skip them with
|
|
112
|
+
`pytest -m "not live"`.
|
|
113
|
+
|
|
114
|
+
## Releasing
|
|
115
|
+
|
|
116
|
+
Releases are published to PyPI by the `Publish to PyPI` workflow when a GitHub release is
|
|
117
|
+
published, using [trusted publishing](https://docs.pypi.org/trusted-publishers/). Bump
|
|
118
|
+
`__version__` in `src/jlt_bus_schedule/const.py` before creating the release.
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jlt-bus-schedule"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "Async client that scrapes bus departures and stops from JLT (Jönköpings Länstrafik)"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE"]
|
|
12
|
+
authors = [{ name = "Jonny Bergdahl" }]
|
|
13
|
+
requires-python = ">=3.11"
|
|
14
|
+
dependencies = [
|
|
15
|
+
"aiohttp>=3.9",
|
|
16
|
+
"beautifulsoup4>=4.12.0",
|
|
17
|
+
# Windows has no system timezone database for zoneinfo
|
|
18
|
+
"tzdata; sys_platform == 'win32'",
|
|
19
|
+
]
|
|
20
|
+
keywords = ["jlt", "jönköping", "bus", "public transport", "home assistant"]
|
|
21
|
+
classifiers = [
|
|
22
|
+
"Development Status :: 4 - Beta",
|
|
23
|
+
"Framework :: AsyncIO",
|
|
24
|
+
"Intended Audience :: Developers",
|
|
25
|
+
"Operating System :: OS Independent",
|
|
26
|
+
"Programming Language :: Python :: 3",
|
|
27
|
+
"Programming Language :: Python :: 3.11",
|
|
28
|
+
"Programming Language :: Python :: 3.12",
|
|
29
|
+
"Programming Language :: Python :: 3.13",
|
|
30
|
+
"Programming Language :: Python :: 3.14",
|
|
31
|
+
"Topic :: Software Development :: Libraries",
|
|
32
|
+
"Typing :: Typed",
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
[project.optional-dependencies]
|
|
36
|
+
test = [
|
|
37
|
+
"pytest>=8",
|
|
38
|
+
"pytest-asyncio>=0.24",
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
[project.urls]
|
|
42
|
+
Homepage = "https://github.com/jonnybergdahl/Python-JLT-BusSchedule-Scraper"
|
|
43
|
+
Issues = "https://github.com/jonnybergdahl/Python-JLT-BusSchedule-Scraper/issues"
|
|
44
|
+
|
|
45
|
+
[tool.hatch.version]
|
|
46
|
+
path = "src/jlt_bus_schedule/const.py"
|
|
47
|
+
|
|
48
|
+
[tool.hatch.build.targets.sdist]
|
|
49
|
+
include = ["src", "tests", "README.md", "LICENSE"]
|
|
50
|
+
|
|
51
|
+
[tool.pytest.ini_options]
|
|
52
|
+
testpaths = ["tests"]
|
|
53
|
+
asyncio_mode = "auto"
|
|
54
|
+
asyncio_default_fixture_loop_scope = "function"
|
|
55
|
+
markers = [
|
|
56
|
+
"live: tests that call the live jlt.se site, deselect with '-m \"not live\"'",
|
|
57
|
+
]
|
|
58
|
+
|
|
59
|
+
[tool.ruff.lint]
|
|
60
|
+
select = ["B", "D", "E", "F", "I", "RUF", "SLF", "UP"]
|
|
61
|
+
ignore = ["D203", "D213", "E501"]
|
|
62
|
+
|
|
63
|
+
[tool.ruff.lint.isort]
|
|
64
|
+
force-sort-within-sections = true
|
|
65
|
+
combine-as-imports = true
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Async client for the JLT (Jönköpings Länstrafik) departure board."""
|
|
2
|
+
|
|
3
|
+
from .client import JltApiClient
|
|
4
|
+
from .const import API_URL, JLT_TIMEZONE, STOP_SEARCH_URL, USER_AGENT, __version__
|
|
5
|
+
from .exceptions import JltConnectionError, JltError
|
|
6
|
+
from .models import Departure, Stop
|
|
7
|
+
from .parser import parse_departures, parse_stops
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"API_URL",
|
|
11
|
+
"JLT_TIMEZONE",
|
|
12
|
+
"STOP_SEARCH_URL",
|
|
13
|
+
"USER_AGENT",
|
|
14
|
+
"Departure",
|
|
15
|
+
"JltApiClient",
|
|
16
|
+
"JltConnectionError",
|
|
17
|
+
"JltError",
|
|
18
|
+
"Stop",
|
|
19
|
+
"__version__",
|
|
20
|
+
"parse_departures",
|
|
21
|
+
"parse_stops",
|
|
22
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Client for the JLT (Jönköpings Länstrafik) departure board.
|
|
2
|
+
|
|
3
|
+
JLT has no public JSON API for departures, so this scrapes the HTML fragment
|
|
4
|
+
used by the departure board on jlt.se, and the stop search of the travel
|
|
5
|
+
planner that jlt.se embeds from etiscloud.net.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from types import TracebackType
|
|
13
|
+
from typing import Self
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from .const import API_URL, JLT_TIMEZONE, STOP_SEARCH_URL, USER_AGENT
|
|
18
|
+
from .exceptions import JltConnectionError
|
|
19
|
+
from .models import Departure, Stop
|
|
20
|
+
from .parser import parse_departures, parse_stops
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class JltApiClient:
|
|
24
|
+
"""Fetch departures and stops from JLT.
|
|
25
|
+
|
|
26
|
+
Pass an existing aiohttp session to share it with the rest of the
|
|
27
|
+
application. Without one, the client creates its own session, which is
|
|
28
|
+
closed by close() or when leaving an ``async with`` block.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
session: aiohttp.ClientSession | None = None,
|
|
34
|
+
user_agent: str = USER_AGENT,
|
|
35
|
+
) -> None:
|
|
36
|
+
"""Initialize the client."""
|
|
37
|
+
self._session = session
|
|
38
|
+
self._close_session = False
|
|
39
|
+
self._user_agent = user_agent
|
|
40
|
+
|
|
41
|
+
async def __aenter__(self) -> Self:
|
|
42
|
+
"""Enter the async context manager."""
|
|
43
|
+
return self
|
|
44
|
+
|
|
45
|
+
async def __aexit__(
|
|
46
|
+
self,
|
|
47
|
+
exc_type: type[BaseException] | None,
|
|
48
|
+
exc: BaseException | None,
|
|
49
|
+
traceback: TracebackType | None,
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Close the session if it was created by the client."""
|
|
52
|
+
await self.close()
|
|
53
|
+
|
|
54
|
+
async def close(self) -> None:
|
|
55
|
+
"""Close the session if it was created by the client."""
|
|
56
|
+
if self._session and self._close_session:
|
|
57
|
+
await self._session.close()
|
|
58
|
+
self._session = None
|
|
59
|
+
self._close_session = False
|
|
60
|
+
|
|
61
|
+
async def _async_get(
|
|
62
|
+
self, url: str, params: dict[str, str | int], timeout: float
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Fetch a URL and return the response body."""
|
|
65
|
+
if self._session is None:
|
|
66
|
+
self._session = aiohttp.ClientSession()
|
|
67
|
+
self._close_session = True
|
|
68
|
+
|
|
69
|
+
try:
|
|
70
|
+
async with asyncio.timeout(timeout):
|
|
71
|
+
async with self._session.get(
|
|
72
|
+
url, params=params, headers={"User-Agent": self._user_agent}
|
|
73
|
+
) as response:
|
|
74
|
+
response.raise_for_status()
|
|
75
|
+
return await response.text()
|
|
76
|
+
except (TimeoutError, aiohttp.ClientError) as err:
|
|
77
|
+
raise JltConnectionError(f"Error fetching {url}: {err}") from err
|
|
78
|
+
|
|
79
|
+
async def async_get_departures(
|
|
80
|
+
self, stop_id: int, take: int = 20, timeout: float = 10
|
|
81
|
+
) -> list[Departure]:
|
|
82
|
+
"""Return upcoming departures for all lines at a stop."""
|
|
83
|
+
html = await self._async_get(
|
|
84
|
+
API_URL, {"fromId": stop_id, "take": take}, timeout
|
|
85
|
+
)
|
|
86
|
+
return parse_departures(html, datetime.now(JLT_TIMEZONE))
|
|
87
|
+
|
|
88
|
+
async def async_search_stops(self, query: str, timeout: float = 10) -> list[Stop]:
|
|
89
|
+
"""Return stops matching a name, best match first."""
|
|
90
|
+
html = await self._async_get(
|
|
91
|
+
STOP_SEARCH_URL,
|
|
92
|
+
{"fld": "input-from_ajax", "letters": query, "optType": 0},
|
|
93
|
+
timeout,
|
|
94
|
+
)
|
|
95
|
+
return parse_stops(html)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Constants for the JLT client."""
|
|
2
|
+
|
|
3
|
+
from zoneinfo import ZoneInfo
|
|
4
|
+
|
|
5
|
+
__version__ = "1.0.0"
|
|
6
|
+
|
|
7
|
+
API_URL = "https://www.jlt.se/api/StopAreaApi/GetClosestDepartures"
|
|
8
|
+
STOP_SEARCH_URL = "https://www.etiscloud.net/jonkopingRP/v6/ajaxList.aspx"
|
|
9
|
+
USER_AGENT = (
|
|
10
|
+
f"jlt-bus-schedule/{__version__} "
|
|
11
|
+
"(+https://github.com/jonnybergdahl/Python-JLT-BusSchedule-Scraper)"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
# Departure times on jlt.se are always Swedish local time
|
|
15
|
+
JLT_TIMEZONE = ZoneInfo("Europe/Stockholm")
|