geev-unofficial-api 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,407 @@
1
+ Metadata-Version: 2.4
2
+ Name: geev-unofficial-api
3
+ Version: 0.1.0
4
+ Summary: Synchronous client library for the Geev peer-to-peer donation/sales app (https://www.geev.fr).
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: requests>=2.28
9
+ Provides-Extra: tests
10
+ Requires-Dist: pytest>=7; extra == "tests"
11
+
12
+ # geev_unofficial_api
13
+
14
+ A **synchronous** Python client library for the Geev API (`https://prod.geev.fr`).
15
+ It reproduces exactly what the Geev Android app (v8.6.2) sends on the wire -
16
+ headers, HMAC request signing and multipart bodies - so it works against the
17
+ live service without scraping the website.
18
+
19
+ The library is object-oriented: `GeevClient` is the entry point, and the
20
+ network-accessing entities are `User` and `Article`. **Nothing is fetched at
21
+ object construction** - every method performs its own HTTP request only when
22
+ you call it.
23
+
24
+ ```python
25
+ from geev import GeevClient
26
+
27
+ geev = GeevClient()
28
+ geev.login("you@example.com", "s3cret") # returns Session, stored on client
29
+
30
+ # A User handle (no network call yet) ...
31
+ user = geev.get_user("618287b1fafd81627a9ad69d")
32
+ profile = user.profile() # GET /v3/users/{id} (lazy)
33
+ page = user.articles(operation="donations") # GET /v3/users/{id}/items
34
+
35
+ # ... and Articles
36
+ article = geev.get_article(page.items[0]["id"])
37
+ print(article.title, article.is_reservable)
38
+ article.details() # GET /v3/items/{id} (lazy)
39
+ ```
40
+
41
+ ## Table of contents
42
+
43
+ 1. [Install](#1-install)
44
+ 2. [Quick examples](#2-quick-examples)
45
+ 3. [API reference](#3-api-reference)
46
+ - [GeevClient](#31-geevclient)
47
+ - [User](#32-user)
48
+ - [Article](#33-article)
49
+ - [Auth flow](#34-auth-flow--signup-signin-logout)
50
+ 4. [Models](#4-models)
51
+ 5. [Errors](#5-errors)
52
+ 6. [How it matches the app](#6-how-it-matches-the-app)
53
+ 7. [Testing](#7-testing)
54
+ 8. [Project layout](#8-project-layout)
55
+
56
+ ---
57
+
58
+ ## 1. Install
59
+
60
+ ```bash
61
+ uv sync
62
+ pip install -e .
63
+ # optional, for tests
64
+ pip install -e ".[tests]"
65
+ ```
66
+
67
+ Requires Python ≥ 3.11 and `requests`.
68
+
69
+ ---
70
+
71
+ ## 2. Quick examples
72
+
73
+ ### Sign in and explore a user's donations
74
+
75
+ ```python
76
+ from geev import GeevClient
77
+
78
+ geev = GeevClient() # prod API by default
79
+ session = geev.login("jane@example.com", "s3cret")
80
+
81
+ user = geev.get_user("618287b1fafd81627a9ad69d")
82
+ page = user.articles(operation="donations", limit=10)
83
+ print(len(page.items), "articles; next cursor:", page.next_after)
84
+
85
+ for raw in page.items:
86
+ print(raw["id"], raw["title"], raw.get("status"))
87
+ ```
88
+
89
+ ### Search offers
90
+
91
+ ```python
92
+ geev = GeevClient()
93
+ geev.login(email, password)
94
+
95
+ results = geev.search_articles(text="chaise", limit=20) # placement defaults to top_categories
96
+ for article in results:
97
+ print(article.id, article.title, article.city, article.is_reservable)
98
+ ```
99
+
100
+ ### Reserve / order an article
101
+
102
+ While the API has a reservation endpoint, ordering another user's item is a
103
+ **destructive side effect** on the platform - use with care and only with
104
+ accounts you control:
105
+
106
+ ```python
107
+ session = geev.login(email, password)
108
+ article = geev.get_article(ARTICLE_ID)
109
+ reservation = article.reserve() # recipient = logged-in user
110
+ print(reservation.reservationId)
111
+ ```
112
+
113
+ ### Lazy user methods
114
+
115
+ ```python
116
+ user = geev.get_user("618287b1fafd81627a9ad69d")
117
+ user.profile() # first call does the network round-trip
118
+ user.articles() # ...
119
+ user.carbon_summary() # ... on demand, not at construction
120
+ ```
121
+
122
+ ---
123
+
124
+ ## 3. API reference
125
+
126
+ ### 3.1 `GeevClient`
127
+
128
+ `geev.GeevClient(base_url=None, language="fr", token=None, session=None)`
129
+
130
+ | Arg | Default | Meaning |
131
+ |-----|---------|---------|
132
+ | `base_url` | `https://prod.geev.fr/v3` | also `https://dev.geev.fr/v3`, `https://stage.geev.fr/v3` |
133
+ | `language` | `"fr"` | value of the `language` header on every call |
134
+ | `token` | `None` | skip login if you already have an `appToken` |
135
+ | `session` | `None` | a pre-built `Session` (userId + token) |
136
+
137
+ The client stores the current token on `.token` and the full session on
138
+ `.session`, and passes the token to every authenticated request.
139
+
140
+ **Auth**
141
+
142
+ | Method | Endpoint | Notes |
143
+ |--------|----------|-------|
144
+ | `check_email(email) -> bool` | `POST /auth/email/check` | True if available |
145
+ | `signup(first_name, last_name, email, password, marketing_consent=False, opted_out=False, pixel_consent=False, picture_path=None) -> Registration` | `POST /accounts/local` (multipart) | returns `accountId`/`userId`; account not yet active |
146
+ | `resend_validation(account_id)` | `POST /accounts/{accountId}/resend-validation` | - |
147
+ | `validate_account(account_id, code) -> Session` | `POST /accounts/{accountId}/validate` | activates account, stores token |
148
+ | `login(email, password) -> Session` | `POST /auth/local/login` | stores token |
149
+ | `logout()` | `POST /auth/logout` | **destructive**: invalidates the token |
150
+
151
+ `signup` returns a `Registration`; you then validate with the 6-digit code
152
+ emailed by Geev:
153
+
154
+ ```python
155
+ reg = geev.signup(first_name="Jane", last_name="Doe",
156
+ email="jane@example.com", password="S3cret!")
157
+ geev.validate_account(reg.accountId, "123456") # code from the email
158
+ ```
159
+
160
+ **Articles**
161
+
162
+ | Method | Endpoint | Notes |
163
+ |--------|----------|-------|
164
+ | `search_articles(text=None, article_type=None, states=None, categories=None, distance=None, latitude=None, longitude=None, placement="top_categories", mode="standard", limit=20, skip=1) -> List[Article]` | `POST /search/items` | `skip` is 1-based (0 is rejected); `placement` is one of the server's accepted values, see below |
165
+ | `get_article(article_id) -> Article` | `GET /items/{articleId}` | wraps the payload |
166
+ | `reserve_article(article_id, recipient_user_id=None) -> Reservation` | `POST /reservations` | defaults the recipient to the logged-in user |
167
+
168
+ `placement` values accepted by the server: `home_listing`, `top_categories`,
169
+ `home_exclusivities`, `home_near_you`, `home_sales`,
170
+ `my_formula_contact_advantages`, `not_found`, `explorer`,
171
+ `favorites_carousel`. `top_categories` supports keyword `text` filters.
172
+
173
+ **Messaging / contact the vendor**
174
+
175
+ | Method | Endpoint | Notes |
176
+ |--------|----------|-------|
177
+ | `get_conversation(conversation_id) -> Conversation` | `GET /conversations/{conversationId}` | fetch thread + history |
178
+ | `contact_article(article_id, message, dry_run=False, confirm=False) -> Conversation` | `POST /items/{articleId}/contact` | starts/reuses the chat with the author |
179
+ | `request_adoption(article_id, message, dry_run=False) -> dict` | `POST /adoptions` | `{itemIds, message}` - expresses intent, does **not** reserve |
180
+ | `list_conversations(item_id=None, with_archived=False) -> list` | `GET /self/conversations` | one article summary per thread |
181
+
182
+ **Inbox, reserved deals, delivery**
183
+
184
+ | Method | Endpoint | Notes |
185
+ |--------|----------|-------|
186
+ | `get_inbox(with_archived=False) -> List[ConversationSummary]` | `GET /self/conversations` | inbox: one summary per thread, with latest message + unread count |
187
+ | `get_reserved_collections() -> List[ConversationSummary]` | ... | inbox entries where a deal is `reserved` (vendor accepted) |
188
+ | `confirm_adoption(reservation_id, *, communication_grade, punctuality_grade, feedback=None) -> AdoptionConfirmed` | `PATCH /reservations/{id}/confirm-adoption` | adopter confirms the donation was delivered; closes the deal |
189
+ | `confirm_order(article_id, *, recipient_user_id=None, firstname=None, lastname=None) -> OrderConfirmed` | `POST /reservations` | buyer confirms a sale order |
190
+
191
+ **Users**
192
+
193
+ | Method | Endpoint | Notes |
194
+ |--------|----------|-------|
195
+ | `get_user(user_id) -> User` | – | no network call |
196
+ | `get_me() -> User` | – | a `User` handle for the logged-in session (`session.userId`); no network call |
197
+
198
+ ### 3.2 `User`
199
+
200
+ `geev.users.User` is created via `client.get_user(user_id)` and fetches on
201
+ demand. All listing/profile calls require the client to be logged in.
202
+
203
+ | Method | Endpoint | Return |
204
+ |--------|----------|--------|
205
+ | `profile()` | `GET /v3/users/{userId}` | raw dict (`firstName`, `lastName`, `firstIntention`, `_links`, ...) |
206
+ | `first_name`, `last_name` (properties) | – | called `profile()` lazily |
207
+ | `articles(operation="donations", status=None, after=None, limit=50) -> Page` | `GET /v3/users/{userId}/items` | `Page{items, next_after, raw}` |
208
+ | `iter_articles(operation="donations", status=None, page_size=50) -> Iterator[dict]` | same, cursor-following | yields every item across pages |
209
+ | `reviews(type=None, after=None, limit=20) -> List[Review]` | `GET /v3/users/{userId}/reviews` | - |
210
+ | `carbon_summary(temporality=None, light=False) -> CarbonSummary` | `GET /v3/users/{id}/carbonSummary` | `temporality` ∈ `ever`, `thisYear`, `thisMonth` |
211
+
212
+ `operation` is **required** by the server: `donations` or `requests`. For
213
+ `donations`, pass `status=["AVAILABLE"]` to see only what can be ordered
214
+ today; the app's default is `["AVAILABLE","RESERVED","GIVEN","ACQUIRED"]`.
215
+ The response exposes a cursor in `Page.next_after` (an article id) for the
216
+ next page.
217
+
218
+ ### 3.3 `Article`
219
+
220
+ `geev.articles.Article` wraps a listing/search payload. Convenience read-only
221
+ properties (`id`, `title`, `description`, `type`, `state`, `status`,
222
+ `category`, `universe`, `picture`, `pictures`, `city`, `author_id`,
223
+ `author_name`, `carbon_value`, `savings`, `price`, `stock`, `validated`,
224
+ `is_reservable`) never hit the network - they read the payload that created
225
+ the object.
226
+
227
+ | Method | Endpoint | Return |
228
+ |--------|----------|--------|
229
+ | `details()` | `GET /v3/items/{articleId}` | raw dict with `description`, `status`, `creditCost`, `donator`, `pictures` |
230
+ | `reserve(recipient_user_id=None) -> Reservation` | `POST /v3/reservations` | **destructive**; defaults to logged-in user |
231
+ | `related() -> List[Article]` | `GET /v3/items/{id}/related` | similar articles |
232
+ | `contact(message, dry_run=False, confirm=False) -> Conversation` | `POST /v3/items/{id}/contact` | message the vendor; thread is fetched |
233
+ | `request_adoption(message, dry_run=False) -> dict` | `POST /v3/adoptions` | `{itemIds, message}`; intent, no reserve |
234
+
235
+ Example - start a conversation with the vendor of an article:
236
+
237
+ ```python
238
+ article = geev.get_article(ARTICLE_ID)
239
+ conversation = article.contact("Bonjour, c'est encore disponible ?")
240
+ print(conversation.status) # e.g. CONTACTED
241
+ send = conversation.send_message("Parfait, merci !")
242
+ ```
243
+
244
+ If the account has several conversations without a verified phone number, the
245
+ server answers `428` and the payload advertises a `confirmContact` link -
246
+ retry with `**contact(..., confirm=True)**`.
247
+
248
+ ### 3.4 `Conversation`
249
+
250
+ `geev.conversations.Conversation` wraps a messaging thread. Created via
251
+ `client.get_conversation(id)`, `article.contact(...)`, or implicitly by
252
+ `client.contact_article(...)`; details are fetched once (populating `.raw`,
253
+ `.item_id`, `.status` and `.messages`).
254
+
255
+ | Field / method | Meaning |
256
+ |----------------|---------|
257
+ | `conversation_id` | thread id |
258
+ | `item_id`, `status`, `messages` | fetched fields (after `fetch()`) |
259
+ | `reservation_id`, `reservation` | deal attached to the thread (after `fetch()`) |
260
+ | `fetch() -> Conversation` | `GET /v3/conversations/{id}` |
261
+ | `send_message(text) -> Message` | `POST /v3/conversations/{id}/message` |
262
+ | `list_open(client, item_id=None, with_archived=False) -> list` | `GET /v3/self/conversations` |
263
+
264
+ Example - complete a donation deal:
265
+
266
+ ```python
267
+ reserved = geev.get_reserved_collections()
268
+ deal = next(s for s in reserved if s.given and not s.acquired)
269
+ conversation = geev.get_conversation(deal.conversation_id)
270
+ geev.confirm_adoption(conversation.reservation_id,
271
+ communication_grade=5.0, punctuality_grade=5.0)
272
+ ```
273
+
274
+ ### 3.5 Auth flow - signup, signin, logout
275
+
276
+ The sign-up flow mirrors the app:
277
+
278
+ 1. `check_email(email)` - optional pre-check.
279
+ 2. `signup(...)` - multipart `POST /accounts/local`, returns `Registration`.
280
+ 3. `validate_account(account_id, code)` - `POST /accounts/{accountId}/validate`;
281
+ the response carries the `appToken` = `X-Geev-Token` used afterwards.
282
+ 4. `login(email, password)` - `POST /auth/local/login`, same token mechanism.
283
+ 5. `logout()` - `POST /auth/logout`; invalidates the current token
284
+ (subsequent requests will 401).
285
+
286
+ There is **no persistence** in the library: tokens live only in memory on the
287
+ client object. To reuse a session across runs, capture `session.appToken` and
288
+ `session.userId` yourself and build a new client with
289
+ `GeevClient(token=..., session=...)`.
290
+
291
+ > There is **no user-lookup-by-name** endpoint in the Geev API. Users are
292
+ > identified solely by their `userId` (the last path segment of a profile URL
293
+ > like `https://www.geev.fr/profile/<id>`).
294
+
295
+ ---
296
+
297
+ ## 4. Models
298
+
299
+ | Class | Fields |
300
+ |-------|--------|
301
+ | `Session` | `appToken`, `userId`, `sso`, `userType` |
302
+ | `Registration` | `accountId`, `userId` |
303
+ | `Reservation` | `reservationId`, `itemId`, `raw` |
304
+ | `Page` | `items`, `next_after`, `raw` |
305
+ | `Review` | `id`, `grade`, `message`, `raw` |
306
+ | `CarbonSummary` | `year`, `month`, `carbonValue`, `donations`, `adoptions`, `equivalences`, `raw` |
307
+ | `Location` | `label`, `city`, `postalCode`, `latitude`, `longitude`, `radius`, `obfuscated` |
308
+ | `Message` | `id`, `author_id`, `timestamp`, `text`, `read_by_receiver`, `raw` |
309
+ | `Conversation` | handle class; see §3.4 |
310
+ | `ConversationSummary` | inbox entry: `id`, `title`, `status`, `reserved`/`given`/`acquired`/`closed`, `conversation_id`, `latest_message`, `unseen_count`, `raw` |
311
+ | `OrderConfirmed` | `reservation_id`, `conversation_id`, `raw` |
312
+ | `AdoptionConfirmed` | `big_savings`, `carbon_value`, `savings`, `raw` |
313
+
314
+ Every model also carries the raw server payload in `.raw` so you can access
315
+ fields the library does not wrap yet.
316
+
317
+ ---
318
+
319
+ ## 5. Errors
320
+
321
+ All exceptions derive from `geev.exceptions.GeevError`.
322
+
323
+ | Exception | Raised when |
324
+ |-----------|-------------|
325
+ | `BadRequest` | HTTP 4xx, or a malformed/unexpected body |
326
+ | `ServerError` | HTTP 5xx |
327
+ | `AuthenticationError` | HTTP 401/403, incl. wrong validation code |
328
+ | `ValidationError` | client-side argument validation |
329
+
330
+ `BadRequest` and its subclasses expose `.status_code`, `.payload`, `.method`
331
+ and `.url`.
332
+
333
+ ```python
334
+ from geev import GeevClient, AuthenticationError
335
+
336
+ try:
337
+ geev.login("jane@example.com", "wrong-password")
338
+ except AuthenticationError as e:
339
+ print(e) # includes HTTP status and payload
340
+ ```
341
+
342
+ ---
343
+
344
+ ## 6. How it matches the app
345
+
346
+ The library reproduces the exact wire behaviour of Geev 8.6.2:
347
+
348
+ - **Global headers** on every request: `User-Agent`,
349
+ `x-geev-device-model`, `geev-app-version`, `geev-device`, `timezone`,
350
+ plus per-call `language`, `X-Geev-Token` (when logged in), `Content-type`
351
+ and `Accept`.
352
+ - **Request signing** (`x-geev-timestamp` + `x-geev-request-signature`):
353
+ HMAC-SHA256 over `body_bytes || timestamp_ms` with the key extracted from
354
+ the app's `SignatureInterceptor`. Only present when the request has a body.
355
+ In this library the body is serialized *before* signing, so the signed
356
+ bytes are exactly the bytes on the wire.
357
+ - **Multipart** sign-up body is built manually (OkHttp byte-for-byte
358
+ compatible) so signing stays exact.
359
+
360
+ Reverse-engineered from the decompiled APK; the endpoint reference doc is
361
+ `[RAW_API_DOC.md](docs/RAW_API_DOC.md)`.
362
+
363
+ ---
364
+
365
+ ## 7. Testing
366
+
367
+ The test suite runs against the **live** production API (`prod.geev.fr`). It
368
+ is marked `live`; destructive operations (reserve, logout) are **not**
369
+ executed automatically.
370
+
371
+ ```bash
372
+ pytest tests/test_live.py -m live -v
373
+ ```
374
+
375
+ Defaults for the provided test account are embedded in `tests/conftest.py`;
376
+ override with environment variables:
377
+
378
+ | Variable | Default |
379
+ |----------|---------|
380
+ | `GEEV_TEST_TOKEN` | the provided `appToken` |
381
+ | `GEEV_TEST_USER` | `6a11e587ef4a89cd2c8ad9ac` |
382
+ | `GEEV_TARGET_USER` | `618287b1fafd81627a9ad69d` |
383
+
384
+ ---
385
+
386
+ ## 8. Project layout
387
+
388
+ ```
389
+ ./
390
+ ├── docs
391
+ │   ├── API.md # doc for the endpoints used in this project
392
+ │   └── RAW_API_DOC.md # doc produced by a LLM while reversing the app
393
+ ├── pyproject.toml
394
+ ├── README.md # this document
395
+ ├── geev/
396
+ │ ├── __init__.py # public exports
397
+ │ ├── _http.py # headers, signing, multipart, transport
398
+ │ ├── exceptions.py # error types
399
+ │ ├── models.py # value objects (Session, Page, ...)
400
+ │ ├── auth.py # signup / signin / logout / validate
401
+ │ ├── users.py # User class + user operations
402
+ │ ├── articles.py # Article class + search / reserve
403
+ │ └── client.py # GeevClient facade
404
+ └── tests/
405
+ ├── conftest.py # fixtures (live API credentials)
406
+ └── test_live.py # live API tests
407
+ ```