fc-clubs-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.
- fc_clubs_api-0.1.0/LICENSE.md +21 -0
- fc_clubs_api-0.1.0/PKG-INFO +183 -0
- fc_clubs_api-0.1.0/README.md +144 -0
- fc_clubs_api-0.1.0/fc_clubs_api.egg-info/PKG-INFO +183 -0
- fc_clubs_api-0.1.0/fc_clubs_api.egg-info/SOURCES.txt +10 -0
- fc_clubs_api-0.1.0/fc_clubs_api.egg-info/dependency_links.txt +1 -0
- fc_clubs_api-0.1.0/fc_clubs_api.egg-info/requires.txt +1 -0
- fc_clubs_api-0.1.0/fc_clubs_api.egg-info/top_level.txt +1 -0
- fc_clubs_api-0.1.0/fc_clubs_api.py +370 -0
- fc_clubs_api-0.1.0/pyproject.toml +26 -0
- fc_clubs_api-0.1.0/setup.cfg +4 -0
- fc_clubs_api-0.1.0/tests/test_fc_clubs_api.py +168 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Erkan Dogan
|
|
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,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fc-clubs-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python client for the EA Sports FC 27 Pro Clubs API, returning pandas DataFrames.
|
|
5
|
+
Author: Erkan Dogan
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Erkan Dogan
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/1erkandogan/fc27-clubs-api
|
|
29
|
+
Project-URL: Issues, https://github.com/1erkandogan/fc27-clubs-api/issues
|
|
30
|
+
Keywords: ea,fc27,pro clubs,api,pandas
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Requires-Python: >=3.9
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE.md
|
|
37
|
+
Requires-Dist: pandas>=1.5
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# FC 27 Clubs API
|
|
41
|
+
|
|
42
|
+
An unofficial Python client for the **EA Sports FC 27 Pro Clubs API**, the same
|
|
43
|
+
endpoints `proclubs.ea.com` calls from the browser. You can find clubs and get
|
|
44
|
+
their stats, members, matches and per-player match ratings as tables (pandas
|
|
45
|
+
DataFrames).
|
|
46
|
+
|
|
47
|
+
Not affiliated with or endorsed by EA. The endpoints are undocumented and can
|
|
48
|
+
change without notice.
|
|
49
|
+
|
|
50
|
+
- **New to Python, or want to know how it works?** Read [docs/how-it-works.md](docs/how-it-works.md).
|
|
51
|
+
It walks through the code and explains every import and decision.
|
|
52
|
+
- **Want EA's raw responses?** See [docs/endpoints.md](docs/endpoints.md).
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
You need Python 3.9 or newer.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install fc-clubs-api # also installs pandas, the only dependency
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
No API key needed. The whole client is one file, [`fc_clubs_api.py`](fc_clubs_api.py),
|
|
63
|
+
and you import it as `fc_clubs_api`.
|
|
64
|
+
|
|
65
|
+
To work on the code itself, install from a clone instead:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/1erkandogan/fc27-clubs-api.git
|
|
69
|
+
cd fc27-clubs-api
|
|
70
|
+
pip install -e . # your edits to fc_clubs_api.py take effect without reinstalling
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from fc_clubs_api import FC27API
|
|
77
|
+
|
|
78
|
+
api = FC27API(timezone="Europe/Istanbul") # match times in your timezone
|
|
79
|
+
|
|
80
|
+
club_id = api.find_club_id("Your Club Name") # -> the id, e.g. 1001
|
|
81
|
+
|
|
82
|
+
print(api.get_club_matches(club_id)) # last 10 league matches
|
|
83
|
+
print(api.get_member_stats(club_id)) # squad stats this season
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or run the example: `python examples/quickstart.py "Your Club Name"`
|
|
87
|
+
|
|
88
|
+
## Methods
|
|
89
|
+
|
|
90
|
+
| Method | Gives you (one row per ...) |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `find_club_id(name)` | the club's id as a number. If several clubs match, the error lists them. |
|
|
93
|
+
| `search_club_by_name(name)` | club matching the name: division, wins, goals, points |
|
|
94
|
+
| `get_club_details(club_id)` | club (1 row): name, ids, stadium |
|
|
95
|
+
| `get_club_overall_stats(club_id)` | club (1 row): record, goals, streaks, skill rating |
|
|
96
|
+
| `get_member_stats(club_id)` | member: games, goals, assists, average rating, pass/tackle rates |
|
|
97
|
+
| `get_member_career_stats(club_id)` | member: career totals at the club |
|
|
98
|
+
| `get_club_matches(club_id, match_type="leagueMatch", count=10)` | match, from your side: opponent, score, `result` (win/draw/loss), `dnf` |
|
|
99
|
+
| `get_match_players(club_id, match_type="leagueMatch", count=10, both_teams=False)` | player per match: rating, goals, assists, shots, passes, tackles |
|
|
100
|
+
| `get_playoff_achievements(club_id)` | achievement (EA has only returned an empty list so far) |
|
|
101
|
+
| `get_json(endpoint, params)` | EA's raw response as dicts/lists, e.g. `api.get_json("clubs/info", {"clubIds": 1001})` |
|
|
102
|
+
|
|
103
|
+
- `match_type` is `"leagueMatch"`, `"friendlyMatch"` or `"playoffMatch"`.
|
|
104
|
+
- By default, tables have a short set of readable columns. Add `all_columns=True` to
|
|
105
|
+
`search_club_by_name`, `get_club_details`, `get_club_overall_stats`,
|
|
106
|
+
`get_member_stats` or `get_match_players` to get every field EA sends, with EA's names.
|
|
107
|
+
- `FC27API(platform="common-gen5", timeout=10, timezone="UTC")`: all settings are optional.
|
|
108
|
+
- If there is no data, you get an empty table (`df.empty` is `True`).
|
|
109
|
+
|
|
110
|
+
## Recipes
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from fc_clubs_api import FC27API
|
|
114
|
+
|
|
115
|
+
api = FC27API(timezone="Europe/Istanbul")
|
|
116
|
+
club_id = api.find_club_id("Your Club Name")
|
|
117
|
+
|
|
118
|
+
# Top 5 scorers this season
|
|
119
|
+
members = api.get_member_stats(club_id)
|
|
120
|
+
print(members.sort_values("goals", ascending=False).head(5)[["name", "goals", "assists"]])
|
|
121
|
+
|
|
122
|
+
# Last 5 league results
|
|
123
|
+
matches = api.get_club_matches(club_id)
|
|
124
|
+
print(matches.head(5)[["timestamp", "opponentName", "goals", "goalsAgainst", "result"]])
|
|
125
|
+
|
|
126
|
+
# Win rate over the last 10 league matches
|
|
127
|
+
wins = (matches["result"] == "win").sum()
|
|
128
|
+
print(f"Won {wins} of {len(matches)}")
|
|
129
|
+
|
|
130
|
+
# One player's recent matches
|
|
131
|
+
players = api.get_match_players(club_id)
|
|
132
|
+
print(players[players["name"] == "YourGamertag"][["timestamp", "rating", "goals", "assists"]])
|
|
133
|
+
|
|
134
|
+
# Average match rating per player, best first
|
|
135
|
+
print(players.groupby("name")["rating"].mean().sort_values(ascending=False))
|
|
136
|
+
|
|
137
|
+
# Save any table to open in Excel
|
|
138
|
+
members.to_csv("members.csv", index=False)
|
|
139
|
+
# members.to_excel("members.xlsx", index=False) # needs: pip install openpyxl
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Errors
|
|
143
|
+
|
|
144
|
+
- **`FC27APIError`**: EA couldn't be reached, refused the request, or didn't send JSON.
|
|
145
|
+
- **`ValueError`**: you passed something invalid, such as an unknown `match_type`, or a
|
|
146
|
+
club name that `find_club_id` couldn't match to one club.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from fc_clubs_api import FC27API, FC27APIError
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
df = FC27API().get_club_details(1001)
|
|
153
|
+
except FC27APIError as error:
|
|
154
|
+
print("EA problem:", error)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Good to know
|
|
158
|
+
|
|
159
|
+
- **EA blocks non-browser requests.** Every request sends browser-like headers
|
|
160
|
+
(`HEADERS` in `fc_clubs_api.py`). Without them EA answers 403 or doesn't answer at all.
|
|
161
|
+
Plain `curl` is blocked even with the headers, while Python works.
|
|
162
|
+
- **No caching or rate limiting.** Every call goes straight to EA. If you loop over
|
|
163
|
+
many clubs, add a pause between requests (`time.sleep(1)`) so you don't get blocked.
|
|
164
|
+
|
|
165
|
+
## Tests
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
python -m unittest discover -s tests -v
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The tests run offline, using EA responses saved in `tests/fixtures/`. They keep the real structure,
|
|
172
|
+
but every club and player name, id and date is a placeholder.
|
|
173
|
+
|
|
174
|
+
## Contributing
|
|
175
|
+
|
|
176
|
+
Bug reports are welcome, especially when EA changes something and the client
|
|
177
|
+
breaks. Please open an issue before writing code: the project is deliberately
|
|
178
|
+
small and some things that look like oversights are decisions. See
|
|
179
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT, see [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# FC 27 Clubs API
|
|
2
|
+
|
|
3
|
+
An unofficial Python client for the **EA Sports FC 27 Pro Clubs API**, the same
|
|
4
|
+
endpoints `proclubs.ea.com` calls from the browser. You can find clubs and get
|
|
5
|
+
their stats, members, matches and per-player match ratings as tables (pandas
|
|
6
|
+
DataFrames).
|
|
7
|
+
|
|
8
|
+
Not affiliated with or endorsed by EA. The endpoints are undocumented and can
|
|
9
|
+
change without notice.
|
|
10
|
+
|
|
11
|
+
- **New to Python, or want to know how it works?** Read [docs/how-it-works.md](docs/how-it-works.md).
|
|
12
|
+
It walks through the code and explains every import and decision.
|
|
13
|
+
- **Want EA's raw responses?** See [docs/endpoints.md](docs/endpoints.md).
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
You need Python 3.9 or newer.
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
pip install fc-clubs-api # also installs pandas, the only dependency
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
No API key needed. The whole client is one file, [`fc_clubs_api.py`](fc_clubs_api.py),
|
|
24
|
+
and you import it as `fc_clubs_api`.
|
|
25
|
+
|
|
26
|
+
To work on the code itself, install from a clone instead:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
git clone https://github.com/1erkandogan/fc27-clubs-api.git
|
|
30
|
+
cd fc27-clubs-api
|
|
31
|
+
pip install -e . # your edits to fc_clubs_api.py take effect without reinstalling
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quick start
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from fc_clubs_api import FC27API
|
|
38
|
+
|
|
39
|
+
api = FC27API(timezone="Europe/Istanbul") # match times in your timezone
|
|
40
|
+
|
|
41
|
+
club_id = api.find_club_id("Your Club Name") # -> the id, e.g. 1001
|
|
42
|
+
|
|
43
|
+
print(api.get_club_matches(club_id)) # last 10 league matches
|
|
44
|
+
print(api.get_member_stats(club_id)) # squad stats this season
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Or run the example: `python examples/quickstart.py "Your Club Name"`
|
|
48
|
+
|
|
49
|
+
## Methods
|
|
50
|
+
|
|
51
|
+
| Method | Gives you (one row per ...) |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `find_club_id(name)` | the club's id as a number. If several clubs match, the error lists them. |
|
|
54
|
+
| `search_club_by_name(name)` | club matching the name: division, wins, goals, points |
|
|
55
|
+
| `get_club_details(club_id)` | club (1 row): name, ids, stadium |
|
|
56
|
+
| `get_club_overall_stats(club_id)` | club (1 row): record, goals, streaks, skill rating |
|
|
57
|
+
| `get_member_stats(club_id)` | member: games, goals, assists, average rating, pass/tackle rates |
|
|
58
|
+
| `get_member_career_stats(club_id)` | member: career totals at the club |
|
|
59
|
+
| `get_club_matches(club_id, match_type="leagueMatch", count=10)` | match, from your side: opponent, score, `result` (win/draw/loss), `dnf` |
|
|
60
|
+
| `get_match_players(club_id, match_type="leagueMatch", count=10, both_teams=False)` | player per match: rating, goals, assists, shots, passes, tackles |
|
|
61
|
+
| `get_playoff_achievements(club_id)` | achievement (EA has only returned an empty list so far) |
|
|
62
|
+
| `get_json(endpoint, params)` | EA's raw response as dicts/lists, e.g. `api.get_json("clubs/info", {"clubIds": 1001})` |
|
|
63
|
+
|
|
64
|
+
- `match_type` is `"leagueMatch"`, `"friendlyMatch"` or `"playoffMatch"`.
|
|
65
|
+
- By default, tables have a short set of readable columns. Add `all_columns=True` to
|
|
66
|
+
`search_club_by_name`, `get_club_details`, `get_club_overall_stats`,
|
|
67
|
+
`get_member_stats` or `get_match_players` to get every field EA sends, with EA's names.
|
|
68
|
+
- `FC27API(platform="common-gen5", timeout=10, timezone="UTC")`: all settings are optional.
|
|
69
|
+
- If there is no data, you get an empty table (`df.empty` is `True`).
|
|
70
|
+
|
|
71
|
+
## Recipes
|
|
72
|
+
|
|
73
|
+
```python
|
|
74
|
+
from fc_clubs_api import FC27API
|
|
75
|
+
|
|
76
|
+
api = FC27API(timezone="Europe/Istanbul")
|
|
77
|
+
club_id = api.find_club_id("Your Club Name")
|
|
78
|
+
|
|
79
|
+
# Top 5 scorers this season
|
|
80
|
+
members = api.get_member_stats(club_id)
|
|
81
|
+
print(members.sort_values("goals", ascending=False).head(5)[["name", "goals", "assists"]])
|
|
82
|
+
|
|
83
|
+
# Last 5 league results
|
|
84
|
+
matches = api.get_club_matches(club_id)
|
|
85
|
+
print(matches.head(5)[["timestamp", "opponentName", "goals", "goalsAgainst", "result"]])
|
|
86
|
+
|
|
87
|
+
# Win rate over the last 10 league matches
|
|
88
|
+
wins = (matches["result"] == "win").sum()
|
|
89
|
+
print(f"Won {wins} of {len(matches)}")
|
|
90
|
+
|
|
91
|
+
# One player's recent matches
|
|
92
|
+
players = api.get_match_players(club_id)
|
|
93
|
+
print(players[players["name"] == "YourGamertag"][["timestamp", "rating", "goals", "assists"]])
|
|
94
|
+
|
|
95
|
+
# Average match rating per player, best first
|
|
96
|
+
print(players.groupby("name")["rating"].mean().sort_values(ascending=False))
|
|
97
|
+
|
|
98
|
+
# Save any table to open in Excel
|
|
99
|
+
members.to_csv("members.csv", index=False)
|
|
100
|
+
# members.to_excel("members.xlsx", index=False) # needs: pip install openpyxl
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Errors
|
|
104
|
+
|
|
105
|
+
- **`FC27APIError`**: EA couldn't be reached, refused the request, or didn't send JSON.
|
|
106
|
+
- **`ValueError`**: you passed something invalid, such as an unknown `match_type`, or a
|
|
107
|
+
club name that `find_club_id` couldn't match to one club.
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
from fc_clubs_api import FC27API, FC27APIError
|
|
111
|
+
|
|
112
|
+
try:
|
|
113
|
+
df = FC27API().get_club_details(1001)
|
|
114
|
+
except FC27APIError as error:
|
|
115
|
+
print("EA problem:", error)
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
## Good to know
|
|
119
|
+
|
|
120
|
+
- **EA blocks non-browser requests.** Every request sends browser-like headers
|
|
121
|
+
(`HEADERS` in `fc_clubs_api.py`). Without them EA answers 403 or doesn't answer at all.
|
|
122
|
+
Plain `curl` is blocked even with the headers, while Python works.
|
|
123
|
+
- **No caching or rate limiting.** Every call goes straight to EA. If you loop over
|
|
124
|
+
many clubs, add a pause between requests (`time.sleep(1)`) so you don't get blocked.
|
|
125
|
+
|
|
126
|
+
## Tests
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
python -m unittest discover -s tests -v
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The tests run offline, using EA responses saved in `tests/fixtures/`. They keep the real structure,
|
|
133
|
+
but every club and player name, id and date is a placeholder.
|
|
134
|
+
|
|
135
|
+
## Contributing
|
|
136
|
+
|
|
137
|
+
Bug reports are welcome, especially when EA changes something and the client
|
|
138
|
+
breaks. Please open an issue before writing code: the project is deliberately
|
|
139
|
+
small and some things that look like oversights are decisions. See
|
|
140
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
MIT, see [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fc-clubs-api
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Unofficial Python client for the EA Sports FC 27 Pro Clubs API, returning pandas DataFrames.
|
|
5
|
+
Author: Erkan Dogan
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2025 Erkan Dogan
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Project-URL: Homepage, https://github.com/1erkandogan/fc27-clubs-api
|
|
29
|
+
Project-URL: Issues, https://github.com/1erkandogan/fc27-clubs-api/issues
|
|
30
|
+
Keywords: ea,fc27,pro clubs,api,pandas
|
|
31
|
+
Classifier: Programming Language :: Python :: 3
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Requires-Python: >=3.9
|
|
35
|
+
Description-Content-Type: text/markdown
|
|
36
|
+
License-File: LICENSE.md
|
|
37
|
+
Requires-Dist: pandas>=1.5
|
|
38
|
+
Dynamic: license-file
|
|
39
|
+
|
|
40
|
+
# FC 27 Clubs API
|
|
41
|
+
|
|
42
|
+
An unofficial Python client for the **EA Sports FC 27 Pro Clubs API**, the same
|
|
43
|
+
endpoints `proclubs.ea.com` calls from the browser. You can find clubs and get
|
|
44
|
+
their stats, members, matches and per-player match ratings as tables (pandas
|
|
45
|
+
DataFrames).
|
|
46
|
+
|
|
47
|
+
Not affiliated with or endorsed by EA. The endpoints are undocumented and can
|
|
48
|
+
change without notice.
|
|
49
|
+
|
|
50
|
+
- **New to Python, or want to know how it works?** Read [docs/how-it-works.md](docs/how-it-works.md).
|
|
51
|
+
It walks through the code and explains every import and decision.
|
|
52
|
+
- **Want EA's raw responses?** See [docs/endpoints.md](docs/endpoints.md).
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
You need Python 3.9 or newer.
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install fc-clubs-api # also installs pandas, the only dependency
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
No API key needed. The whole client is one file, [`fc_clubs_api.py`](fc_clubs_api.py),
|
|
63
|
+
and you import it as `fc_clubs_api`.
|
|
64
|
+
|
|
65
|
+
To work on the code itself, install from a clone instead:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/1erkandogan/fc27-clubs-api.git
|
|
69
|
+
cd fc27-clubs-api
|
|
70
|
+
pip install -e . # your edits to fc_clubs_api.py take effect without reinstalling
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from fc_clubs_api import FC27API
|
|
77
|
+
|
|
78
|
+
api = FC27API(timezone="Europe/Istanbul") # match times in your timezone
|
|
79
|
+
|
|
80
|
+
club_id = api.find_club_id("Your Club Name") # -> the id, e.g. 1001
|
|
81
|
+
|
|
82
|
+
print(api.get_club_matches(club_id)) # last 10 league matches
|
|
83
|
+
print(api.get_member_stats(club_id)) # squad stats this season
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Or run the example: `python examples/quickstart.py "Your Club Name"`
|
|
87
|
+
|
|
88
|
+
## Methods
|
|
89
|
+
|
|
90
|
+
| Method | Gives you (one row per ...) |
|
|
91
|
+
|---|---|
|
|
92
|
+
| `find_club_id(name)` | the club's id as a number. If several clubs match, the error lists them. |
|
|
93
|
+
| `search_club_by_name(name)` | club matching the name: division, wins, goals, points |
|
|
94
|
+
| `get_club_details(club_id)` | club (1 row): name, ids, stadium |
|
|
95
|
+
| `get_club_overall_stats(club_id)` | club (1 row): record, goals, streaks, skill rating |
|
|
96
|
+
| `get_member_stats(club_id)` | member: games, goals, assists, average rating, pass/tackle rates |
|
|
97
|
+
| `get_member_career_stats(club_id)` | member: career totals at the club |
|
|
98
|
+
| `get_club_matches(club_id, match_type="leagueMatch", count=10)` | match, from your side: opponent, score, `result` (win/draw/loss), `dnf` |
|
|
99
|
+
| `get_match_players(club_id, match_type="leagueMatch", count=10, both_teams=False)` | player per match: rating, goals, assists, shots, passes, tackles |
|
|
100
|
+
| `get_playoff_achievements(club_id)` | achievement (EA has only returned an empty list so far) |
|
|
101
|
+
| `get_json(endpoint, params)` | EA's raw response as dicts/lists, e.g. `api.get_json("clubs/info", {"clubIds": 1001})` |
|
|
102
|
+
|
|
103
|
+
- `match_type` is `"leagueMatch"`, `"friendlyMatch"` or `"playoffMatch"`.
|
|
104
|
+
- By default, tables have a short set of readable columns. Add `all_columns=True` to
|
|
105
|
+
`search_club_by_name`, `get_club_details`, `get_club_overall_stats`,
|
|
106
|
+
`get_member_stats` or `get_match_players` to get every field EA sends, with EA's names.
|
|
107
|
+
- `FC27API(platform="common-gen5", timeout=10, timezone="UTC")`: all settings are optional.
|
|
108
|
+
- If there is no data, you get an empty table (`df.empty` is `True`).
|
|
109
|
+
|
|
110
|
+
## Recipes
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from fc_clubs_api import FC27API
|
|
114
|
+
|
|
115
|
+
api = FC27API(timezone="Europe/Istanbul")
|
|
116
|
+
club_id = api.find_club_id("Your Club Name")
|
|
117
|
+
|
|
118
|
+
# Top 5 scorers this season
|
|
119
|
+
members = api.get_member_stats(club_id)
|
|
120
|
+
print(members.sort_values("goals", ascending=False).head(5)[["name", "goals", "assists"]])
|
|
121
|
+
|
|
122
|
+
# Last 5 league results
|
|
123
|
+
matches = api.get_club_matches(club_id)
|
|
124
|
+
print(matches.head(5)[["timestamp", "opponentName", "goals", "goalsAgainst", "result"]])
|
|
125
|
+
|
|
126
|
+
# Win rate over the last 10 league matches
|
|
127
|
+
wins = (matches["result"] == "win").sum()
|
|
128
|
+
print(f"Won {wins} of {len(matches)}")
|
|
129
|
+
|
|
130
|
+
# One player's recent matches
|
|
131
|
+
players = api.get_match_players(club_id)
|
|
132
|
+
print(players[players["name"] == "YourGamertag"][["timestamp", "rating", "goals", "assists"]])
|
|
133
|
+
|
|
134
|
+
# Average match rating per player, best first
|
|
135
|
+
print(players.groupby("name")["rating"].mean().sort_values(ascending=False))
|
|
136
|
+
|
|
137
|
+
# Save any table to open in Excel
|
|
138
|
+
members.to_csv("members.csv", index=False)
|
|
139
|
+
# members.to_excel("members.xlsx", index=False) # needs: pip install openpyxl
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
## Errors
|
|
143
|
+
|
|
144
|
+
- **`FC27APIError`**: EA couldn't be reached, refused the request, or didn't send JSON.
|
|
145
|
+
- **`ValueError`**: you passed something invalid, such as an unknown `match_type`, or a
|
|
146
|
+
club name that `find_club_id` couldn't match to one club.
|
|
147
|
+
|
|
148
|
+
```python
|
|
149
|
+
from fc_clubs_api import FC27API, FC27APIError
|
|
150
|
+
|
|
151
|
+
try:
|
|
152
|
+
df = FC27API().get_club_details(1001)
|
|
153
|
+
except FC27APIError as error:
|
|
154
|
+
print("EA problem:", error)
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
## Good to know
|
|
158
|
+
|
|
159
|
+
- **EA blocks non-browser requests.** Every request sends browser-like headers
|
|
160
|
+
(`HEADERS` in `fc_clubs_api.py`). Without them EA answers 403 or doesn't answer at all.
|
|
161
|
+
Plain `curl` is blocked even with the headers, while Python works.
|
|
162
|
+
- **No caching or rate limiting.** Every call goes straight to EA. If you loop over
|
|
163
|
+
many clubs, add a pause between requests (`time.sleep(1)`) so you don't get blocked.
|
|
164
|
+
|
|
165
|
+
## Tests
|
|
166
|
+
|
|
167
|
+
```bash
|
|
168
|
+
python -m unittest discover -s tests -v
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
The tests run offline, using EA responses saved in `tests/fixtures/`. They keep the real structure,
|
|
172
|
+
but every club and player name, id and date is a placeholder.
|
|
173
|
+
|
|
174
|
+
## Contributing
|
|
175
|
+
|
|
176
|
+
Bug reports are welcome, especially when EA changes something and the client
|
|
177
|
+
breaks. Please open an issue before writing code: the project is deliberately
|
|
178
|
+
small and some things that look like oversights are decisions. See
|
|
179
|
+
[CONTRIBUTING.md](CONTRIBUTING.md).
|
|
180
|
+
|
|
181
|
+
## License
|
|
182
|
+
|
|
183
|
+
MIT, see [LICENSE.md](LICENSE.md).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
fc_clubs_api.py
|
|
4
|
+
pyproject.toml
|
|
5
|
+
fc_clubs_api.egg-info/PKG-INFO
|
|
6
|
+
fc_clubs_api.egg-info/SOURCES.txt
|
|
7
|
+
fc_clubs_api.egg-info/dependency_links.txt
|
|
8
|
+
fc_clubs_api.egg-info/requires.txt
|
|
9
|
+
fc_clubs_api.egg-info/top_level.txt
|
|
10
|
+
tests/test_fc_clubs_api.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pandas>=1.5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fc_clubs_api
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Unofficial client for the EA Sports FC 27 Pro Clubs API.
|
|
2
|
+
|
|
3
|
+
Every method returns a pandas DataFrame (a table). New to this code? Read
|
|
4
|
+
docs/how-it-works.md, which walks through this file step by step.
|
|
5
|
+
Raw response formats are in docs/endpoints.md.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
# Built into Python, no install needed:
|
|
9
|
+
import json # turns the text EA sends back into Python dicts and lists
|
|
10
|
+
import urllib.error # the errors urllib raises (e.g. EA answering 403)
|
|
11
|
+
import urllib.parse # builds the "?platform=...&clubIds=..." part of a URL
|
|
12
|
+
import urllib.request # sends the request to EA
|
|
13
|
+
|
|
14
|
+
# The only thing you need to install (pip install pandas). It gives us tables
|
|
15
|
+
# (DataFrames) that are easy to sort, filter, average and save to Excel/CSV.
|
|
16
|
+
import pandas as pd
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__version__ = "0.1.0" # keep in sync with "version" in pyproject.toml
|
|
20
|
+
|
|
21
|
+
BASE_URL = "https://proclubs.ea.com/api/fc"
|
|
22
|
+
|
|
23
|
+
MATCH_TYPES = ("leagueMatch", "friendlyMatch", "playoffMatch")
|
|
24
|
+
|
|
25
|
+
# EA's servers sit behind Akamai, which blocks requests that don't look like
|
|
26
|
+
# they come from the proclubs.ea.com website itself: without these headers you
|
|
27
|
+
# get a 403 error or no answer at all. "sec-fetch-site: same-origin" is the key one.
|
|
28
|
+
HEADERS = {
|
|
29
|
+
"accept": "application/json",
|
|
30
|
+
"accept-language": "en-US,en;q=0.9",
|
|
31
|
+
"sec-ch-ua": '"Google Chrome";v="141", "Not?A_Brand";v="8", "Chromium";v="141"',
|
|
32
|
+
"sec-fetch-site": "same-origin",
|
|
33
|
+
"user-agent": (
|
|
34
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
35
|
+
"(KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
|
|
36
|
+
),
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# EA returns a lot of columns with inconsistent names (passesmade, ratingAve,
|
|
40
|
+
# mom, ...). By default each method keeps only the useful ones and renames them.
|
|
41
|
+
# Format: "EA's name": "our name". Pass all_columns=True to get everything as-is.
|
|
42
|
+
COLUMNS = {
|
|
43
|
+
"search": {
|
|
44
|
+
"clubId": "clubId",
|
|
45
|
+
"clubName": "clubName",
|
|
46
|
+
"currentDivision": "currentDivision",
|
|
47
|
+
"bestDivision": "bestDivision",
|
|
48
|
+
"gamesPlayed": "gamesPlayed",
|
|
49
|
+
"wins": "wins",
|
|
50
|
+
"ties": "ties",
|
|
51
|
+
"losses": "losses",
|
|
52
|
+
"goals": "goals",
|
|
53
|
+
"goalsAgainst": "goalsAgainst",
|
|
54
|
+
"cleanSheets": "cleanSheets",
|
|
55
|
+
"points": "points",
|
|
56
|
+
},
|
|
57
|
+
"details": {
|
|
58
|
+
"clubId": "clubId",
|
|
59
|
+
"name": "clubName",
|
|
60
|
+
"regionId": "regionId",
|
|
61
|
+
"teamId": "teamId",
|
|
62
|
+
"customKit.stadName": "stadium",
|
|
63
|
+
},
|
|
64
|
+
"overall": {
|
|
65
|
+
"clubId": "clubId",
|
|
66
|
+
"gamesPlayed": "gamesPlayed",
|
|
67
|
+
"wins": "wins",
|
|
68
|
+
"ties": "ties",
|
|
69
|
+
"losses": "losses",
|
|
70
|
+
"goals": "goals",
|
|
71
|
+
"goalsAgainst": "goalsAgainst",
|
|
72
|
+
"skillRating": "skillRating",
|
|
73
|
+
"wstreak": "winStreak",
|
|
74
|
+
"unbeatenstreak": "unbeatenStreak",
|
|
75
|
+
"promotions": "promotions",
|
|
76
|
+
"relegations": "relegations",
|
|
77
|
+
"bestDivision": "bestDivision",
|
|
78
|
+
},
|
|
79
|
+
"members": {
|
|
80
|
+
"name": "name",
|
|
81
|
+
"proName": "playerName",
|
|
82
|
+
"favoritePosition": "position",
|
|
83
|
+
"proOverall": "overall",
|
|
84
|
+
"gamesPlayed": "gamesPlayed",
|
|
85
|
+
"winRate": "winRate",
|
|
86
|
+
"goals": "goals",
|
|
87
|
+
"assists": "assists",
|
|
88
|
+
"ratingAve": "averageRating",
|
|
89
|
+
"manOfTheMatch": "manOfTheMatch",
|
|
90
|
+
"shotSuccessRate": "shotSuccessRate",
|
|
91
|
+
"passesMade": "passesMade",
|
|
92
|
+
"passSuccessRate": "passSuccessRate",
|
|
93
|
+
"tacklesMade": "tacklesMade",
|
|
94
|
+
"tackleSuccessRate": "tackleSuccessRate",
|
|
95
|
+
"cleanSheetsDef": "cleanSheetsDef",
|
|
96
|
+
"cleanSheetsGK": "cleanSheetsGK",
|
|
97
|
+
"redCards": "redCards",
|
|
98
|
+
},
|
|
99
|
+
"players": {
|
|
100
|
+
"matchId": "matchId",
|
|
101
|
+
"timestamp": "timestamp",
|
|
102
|
+
"clubId": "clubId",
|
|
103
|
+
"playername": "name",
|
|
104
|
+
"pos": "position",
|
|
105
|
+
"rating": "rating",
|
|
106
|
+
"goals": "goals",
|
|
107
|
+
"assists": "assists",
|
|
108
|
+
"shots": "shots",
|
|
109
|
+
"passesmade": "passesMade",
|
|
110
|
+
"passattempts": "passAttempts",
|
|
111
|
+
"tacklesmade": "tacklesMade",
|
|
112
|
+
"tackleattempts": "tackleAttempts",
|
|
113
|
+
"saves": "saves",
|
|
114
|
+
"cleansheetsany": "cleanSheet",
|
|
115
|
+
"mom": "manOfTheMatch",
|
|
116
|
+
"redcards": "redCards",
|
|
117
|
+
"secondsPlayed": "secondsPlayed",
|
|
118
|
+
},
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class FC27APIError(Exception):
|
|
123
|
+
"""Raised when EA can't be reached, refuses the request, or doesn't send JSON."""
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def to_dataframe(records):
|
|
127
|
+
"""Turn a list of dicts from EA into a DataFrame.
|
|
128
|
+
|
|
129
|
+
Two fixes happen here:
|
|
130
|
+
1. Nested dicts become their own columns (json_normalize), e.g.
|
|
131
|
+
{"clubInfo": {"name": "X"}} becomes a column called "clubInfo.name".
|
|
132
|
+
2. EA sends numbers as text ("25", "7.4"). Text can't be summed or sorted
|
|
133
|
+
numerically, so every column that is fully numeric is converted.
|
|
134
|
+
"""
|
|
135
|
+
if not records:
|
|
136
|
+
return pd.DataFrame()
|
|
137
|
+
|
|
138
|
+
df = pd.json_normalize(records)
|
|
139
|
+
for column in df.columns:
|
|
140
|
+
try:
|
|
141
|
+
df[column] = pd.to_numeric(df[column])
|
|
142
|
+
except (ValueError, TypeError):
|
|
143
|
+
pass # a text column such as a name: leave it as text
|
|
144
|
+
return df
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def pick_columns(df, columns, all_columns):
|
|
148
|
+
"""Keep only the columns listed in `columns` and give them friendly names."""
|
|
149
|
+
if all_columns:
|
|
150
|
+
return df
|
|
151
|
+
|
|
152
|
+
keep = []
|
|
153
|
+
for ea_name in columns:
|
|
154
|
+
if ea_name in df.columns:
|
|
155
|
+
keep.append(ea_name)
|
|
156
|
+
return df[keep].rename(columns=columns)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class FC27API:
|
|
160
|
+
"""Talks to EA's Pro Clubs API.
|
|
161
|
+
|
|
162
|
+
platform: "common-gen5" (PS5 / Xbox Series / PC). The only one tested.
|
|
163
|
+
timeout: seconds to wait for EA before giving up.
|
|
164
|
+
timezone: timezone for match times, e.g. "Europe/Istanbul". Default "UTC".
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, platform="common-gen5", timeout=10, timezone="UTC"):
|
|
168
|
+
self.platform = platform
|
|
169
|
+
self.timeout = timeout
|
|
170
|
+
self.timezone = timezone
|
|
171
|
+
|
|
172
|
+
# ------------------------------------------------------------------ basics
|
|
173
|
+
|
|
174
|
+
def get_json(self, endpoint, params):
|
|
175
|
+
"""Ask EA for `endpoint` and return the answer as Python dicts/lists.
|
|
176
|
+
|
|
177
|
+
Use this if you want EA's raw, untouched response, for example
|
|
178
|
+
api.get_json("clubs/info", {"clubIds": 1001})
|
|
179
|
+
"""
|
|
180
|
+
query = {"platform": self.platform}
|
|
181
|
+
query.update(params)
|
|
182
|
+
url = BASE_URL + "/" + endpoint + "?" + urllib.parse.urlencode(query)
|
|
183
|
+
request = urllib.request.Request(url, headers=HEADERS)
|
|
184
|
+
|
|
185
|
+
# `from None` keeps the error message short: our message already says
|
|
186
|
+
# what went wrong, so Python's internal error chain isn't shown.
|
|
187
|
+
try:
|
|
188
|
+
with urllib.request.urlopen(request, timeout=self.timeout) as response:
|
|
189
|
+
data = json.load(response)
|
|
190
|
+
except urllib.error.HTTPError as error:
|
|
191
|
+
raise FC27APIError(f"EA answered with error {error.code} for {url}") from None
|
|
192
|
+
except OSError as error: # no internet, timeout, DNS problem, ...
|
|
193
|
+
raise FC27APIError(f"Could not reach EA ({error}) for {url}") from None
|
|
194
|
+
except json.JSONDecodeError:
|
|
195
|
+
raise FC27APIError(f"EA did not send JSON for {url}") from None
|
|
196
|
+
|
|
197
|
+
# EA sometimes answers "null" instead of an empty list.
|
|
198
|
+
if data is None:
|
|
199
|
+
return []
|
|
200
|
+
return data
|
|
201
|
+
|
|
202
|
+
def _convert_times(self, df):
|
|
203
|
+
"""EA gives times as Unix seconds (1767297600); make them readable dates."""
|
|
204
|
+
if not df.empty:
|
|
205
|
+
utc_times = pd.to_datetime(df["timestamp"], unit="s", utc=True)
|
|
206
|
+
df["timestamp"] = utc_times.dt.tz_convert(self.timezone)
|
|
207
|
+
return df
|
|
208
|
+
|
|
209
|
+
# ------------------------------------------------------------------- clubs
|
|
210
|
+
|
|
211
|
+
def search_club_by_name(self, club_name, all_columns=False):
|
|
212
|
+
"""All clubs whose name contains `club_name`, one row per club."""
|
|
213
|
+
results = self.get_json("allTimeLeaderboard/search", {"clubName": club_name})
|
|
214
|
+
return pick_columns(to_dataframe(results), COLUMNS["search"], all_columns)
|
|
215
|
+
|
|
216
|
+
def find_club_id(self, club_name):
|
|
217
|
+
"""Return the id (a number) of the club called `club_name`.
|
|
218
|
+
|
|
219
|
+
Uses the club with exactly that name (ignoring upper/lower case), or the
|
|
220
|
+
only search result. If it can't decide, the error lists the options.
|
|
221
|
+
"""
|
|
222
|
+
results = self.get_json("allTimeLeaderboard/search", {"clubName": club_name})
|
|
223
|
+
if len(results) == 0:
|
|
224
|
+
raise ValueError(f"No club found for {club_name!r}")
|
|
225
|
+
|
|
226
|
+
for club in results:
|
|
227
|
+
if club["clubName"].lower() == club_name.lower():
|
|
228
|
+
return int(club["clubId"])
|
|
229
|
+
|
|
230
|
+
if len(results) == 1:
|
|
231
|
+
return int(results[0]["clubId"])
|
|
232
|
+
|
|
233
|
+
options = []
|
|
234
|
+
for club in results:
|
|
235
|
+
options.append(f"{club['clubName']} (id {club['clubId']})")
|
|
236
|
+
raise ValueError(
|
|
237
|
+
f"{len(results)} clubs match {club_name!r}: {', '.join(options)}. "
|
|
238
|
+
"Use the exact club name, or use the id directly."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
def get_club_details(self, club_id, all_columns=False):
|
|
242
|
+
"""One row: club name, ids and stadium (all_columns adds kit details)."""
|
|
243
|
+
data = self.get_json("clubs/info", {"clubIds": club_id})
|
|
244
|
+
# EA answers {"1001": {...club...}}; we only need the {...club...} part.
|
|
245
|
+
clubs = list(data.values())
|
|
246
|
+
return pick_columns(to_dataframe(clubs), COLUMNS["details"], all_columns)
|
|
247
|
+
|
|
248
|
+
def get_club_overall_stats(self, club_id, all_columns=False):
|
|
249
|
+
"""One row: wins/draws/losses, goals, streaks, skill rating."""
|
|
250
|
+
data = self.get_json("clubs/overallStats", {"clubIds": club_id})
|
|
251
|
+
return pick_columns(to_dataframe(data), COLUMNS["overall"], all_columns)
|
|
252
|
+
|
|
253
|
+
def get_playoff_achievements(self, club_id):
|
|
254
|
+
"""Playoff achievements (EA has only returned an empty list so far)."""
|
|
255
|
+
return to_dataframe(self.get_json("club/playoffAchievements", {"clubId": club_id}))
|
|
256
|
+
|
|
257
|
+
# ----------------------------------------------------------------- members
|
|
258
|
+
|
|
259
|
+
def get_member_stats(self, club_id, all_columns=False):
|
|
260
|
+
"""One row per club member with their stats for the current season."""
|
|
261
|
+
data = self.get_json("members/stats", {"clubId": club_id})
|
|
262
|
+
members = data.get("members", [])
|
|
263
|
+
return pick_columns(to_dataframe(members), COLUMNS["members"], all_columns)
|
|
264
|
+
|
|
265
|
+
def get_member_career_stats(self, club_id):
|
|
266
|
+
"""One row per club member with their career totals at this club."""
|
|
267
|
+
data = self.get_json("members/career/stats", {"clubId": club_id})
|
|
268
|
+
return to_dataframe(data.get("members", []))
|
|
269
|
+
|
|
270
|
+
# ----------------------------------------------------------------- matches
|
|
271
|
+
|
|
272
|
+
def _get_matches(self, club_id, match_type, count):
|
|
273
|
+
if match_type not in MATCH_TYPES:
|
|
274
|
+
raise ValueError(f"match_type must be one of {MATCH_TYPES}, not {match_type!r}")
|
|
275
|
+
params = {"clubIds": club_id, "matchType": match_type, "maxResultCount": count}
|
|
276
|
+
return self.get_json("clubs/matches", params)
|
|
277
|
+
|
|
278
|
+
def get_club_matches(self, club_id, match_type="leagueMatch", count=10):
|
|
279
|
+
"""One row per match, seen from `club_id`'s side, newest first.
|
|
280
|
+
|
|
281
|
+
match_type: "leagueMatch", "friendlyMatch" or "playoffMatch".
|
|
282
|
+
count: how many recent matches to ask EA for.
|
|
283
|
+
"""
|
|
284
|
+
# EA uses the club id as a text key ("1001"), so compare as text.
|
|
285
|
+
club_id = str(club_id)
|
|
286
|
+
rows = []
|
|
287
|
+
|
|
288
|
+
for match in self._get_matches(club_id, match_type, count):
|
|
289
|
+
clubs = match.get("clubs", {})
|
|
290
|
+
us = clubs.get(club_id, {})
|
|
291
|
+
|
|
292
|
+
# The other key in `clubs` is the opponent.
|
|
293
|
+
opponent_id = None
|
|
294
|
+
them = {}
|
|
295
|
+
for other_id in clubs:
|
|
296
|
+
if other_id != club_id:
|
|
297
|
+
opponent_id = other_id
|
|
298
|
+
them = clubs[other_id]
|
|
299
|
+
|
|
300
|
+
goals = int(us.get("goals", 0))
|
|
301
|
+
goals_against = int(us.get("goalsAgainst", 0))
|
|
302
|
+
|
|
303
|
+
# League matches say who won in wins/ties/losses. Friendlies leave
|
|
304
|
+
# those at "0", so there we compare the goals instead.
|
|
305
|
+
if us.get("wins") == "1":
|
|
306
|
+
result = "win"
|
|
307
|
+
elif us.get("losses") == "1":
|
|
308
|
+
result = "loss"
|
|
309
|
+
elif us.get("ties") == "1":
|
|
310
|
+
result = "draw"
|
|
311
|
+
elif goals > goals_against:
|
|
312
|
+
result = "win"
|
|
313
|
+
elif goals < goals_against:
|
|
314
|
+
result = "loss"
|
|
315
|
+
else:
|
|
316
|
+
result = "draw"
|
|
317
|
+
|
|
318
|
+
rows.append({
|
|
319
|
+
"matchId": match.get("matchId"),
|
|
320
|
+
"timestamp": match.get("timestamp"),
|
|
321
|
+
"matchType": match_type,
|
|
322
|
+
"clubId": club_id,
|
|
323
|
+
"clubName": us.get("details", {}).get("name"),
|
|
324
|
+
"opponentId": opponent_id,
|
|
325
|
+
"opponentName": them.get("details", {}).get("name"),
|
|
326
|
+
"goals": goals,
|
|
327
|
+
"goalsAgainst": goals_against,
|
|
328
|
+
"result": result,
|
|
329
|
+
# DNF = "did not finish": someone quit and the other team got the win.
|
|
330
|
+
"dnf": us.get("winnerByDnf") == "1" or them.get("winnerByDnf") == "1",
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
return self._convert_times(to_dataframe(rows))
|
|
334
|
+
|
|
335
|
+
def get_match_players(self, club_id, match_type="leagueMatch", count=10,
|
|
336
|
+
both_teams=False, all_columns=False):
|
|
337
|
+
"""One row per player per match: rating, goals, assists, passes, ...
|
|
338
|
+
|
|
339
|
+
By default only `club_id`'s players are included; both_teams=True adds
|
|
340
|
+
the opponents' players too (tell them apart with the clubId column).
|
|
341
|
+
"""
|
|
342
|
+
club_id = str(club_id)
|
|
343
|
+
rows = []
|
|
344
|
+
|
|
345
|
+
for match in self._get_matches(club_id, match_type, count):
|
|
346
|
+
# match["players"] looks like {"1001": {"<playerId>": {...stats...}}}
|
|
347
|
+
for player_club, players in match.get("players", {}).items():
|
|
348
|
+
if player_club != club_id and not both_teams:
|
|
349
|
+
continue # skip the opponent's players
|
|
350
|
+
for player_id, stats in players.items():
|
|
351
|
+
row = {
|
|
352
|
+
"matchId": match.get("matchId"),
|
|
353
|
+
"timestamp": match.get("timestamp"),
|
|
354
|
+
"clubId": player_club,
|
|
355
|
+
"playerId": player_id,
|
|
356
|
+
}
|
|
357
|
+
row.update(stats) # add all of EA's stat fields to the row
|
|
358
|
+
rows.append(row)
|
|
359
|
+
|
|
360
|
+
df = self._convert_times(to_dataframe(rows))
|
|
361
|
+
return pick_columns(df, COLUMNS["players"], all_columns)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# This only runs when you start this file directly (python fc_clubs_api.py),
|
|
365
|
+
# not when another script does `from fc_clubs_api import FC27API`.
|
|
366
|
+
if __name__ == "__main__":
|
|
367
|
+
api = FC27API()
|
|
368
|
+
club_id = api.find_club_id(input("Club name: "))
|
|
369
|
+
print(api.get_club_matches(club_id))
|
|
370
|
+
print(api.get_member_stats(club_id))
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fc-clubs-api"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Unofficial Python client for the EA Sports FC 27 Pro Clubs API, returning pandas DataFrames."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { file = "LICENSE.md" }
|
|
12
|
+
authors = [{ name = "Erkan Dogan" }]
|
|
13
|
+
dependencies = ["pandas>=1.5"]
|
|
14
|
+
keywords = ["ea", "fc27", "pro clubs", "api", "pandas"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Homepage = "https://github.com/1erkandogan/fc27-clubs-api"
|
|
23
|
+
Issues = "https://github.com/1erkandogan/fc27-clubs-api/issues"
|
|
24
|
+
|
|
25
|
+
[tool.setuptools]
|
|
26
|
+
py-modules = ["fc_clubs_api"]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Offline tests: run with python -m unittest discover -s tests -v
|
|
2
|
+
|
|
3
|
+
These tests never contact EA. `unittest.mock.patch` temporarily replaces
|
|
4
|
+
urllib.request.urlopen (the function that sends the request) with a fake that
|
|
5
|
+
returns a saved response from tests/fixtures/. This keeps the tests fast and
|
|
6
|
+
repeatable, and they don't break when EA's live data changes.
|
|
7
|
+
|
|
8
|
+
The fixtures have the exact shape of real EA responses, but every club, player,
|
|
9
|
+
id, date and kit value is a placeholder: Example FC = 1001, Opponent A = 2001,
|
|
10
|
+
Player1, Player2, ...
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import unittest
|
|
16
|
+
import urllib.error
|
|
17
|
+
import urllib.parse
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from unittest.mock import patch
|
|
20
|
+
|
|
21
|
+
from fc_clubs_api import FC27API, FC27APIError
|
|
22
|
+
|
|
23
|
+
FIXTURES = Path(__file__).parent / "fixtures"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def fixture(name):
|
|
27
|
+
return json.loads((FIXTURES / f"{name}.json").read_text(encoding="utf-8"))
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def fake_ea(payload):
|
|
31
|
+
"""Pretend EA answers every request with `payload` (as JSON text)."""
|
|
32
|
+
body = json.dumps(payload).encode("utf-8")
|
|
33
|
+
# side_effect builds a fresh response for every call, like a real server.
|
|
34
|
+
return patch("urllib.request.urlopen", side_effect=lambda *args, **kwargs: io.BytesIO(body))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def sent_params(fake):
|
|
38
|
+
"""The query parameters of the last request our code sent."""
|
|
39
|
+
request = fake.call_args[0][0]
|
|
40
|
+
query = urllib.parse.urlparse(request.full_url).query
|
|
41
|
+
return dict(urllib.parse.parse_qsl(query))
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ClubTests(unittest.TestCase):
|
|
45
|
+
def test_search_returns_all_results_with_short_columns(self):
|
|
46
|
+
with fake_ea(fixture("search")) as fake:
|
|
47
|
+
df = FC27API().search_club_by_name("example")
|
|
48
|
+
|
|
49
|
+
self.assertEqual(len(df), 2)
|
|
50
|
+
self.assertEqual(df["clubName"].iat[0], "Example FC")
|
|
51
|
+
self.assertEqual(df["wins"].iat[0], 20) # "20" (text) became 20 (number)
|
|
52
|
+
self.assertNotIn("clubInfo.customKit.kitId", df.columns)
|
|
53
|
+
self.assertEqual(sent_params(fake), {"platform": "common-gen5", "clubName": "example"})
|
|
54
|
+
|
|
55
|
+
def test_all_columns_keeps_ea_names(self):
|
|
56
|
+
with fake_ea(fixture("search")):
|
|
57
|
+
df = FC27API().search_club_by_name("example", all_columns=True)
|
|
58
|
+
self.assertIn("clubInfo.customKit.kitId", df.columns)
|
|
59
|
+
|
|
60
|
+
def test_club_details_is_one_row(self):
|
|
61
|
+
with fake_ea(fixture("info")):
|
|
62
|
+
df = FC27API().get_club_details(1001)
|
|
63
|
+
|
|
64
|
+
self.assertEqual(len(df), 1)
|
|
65
|
+
self.assertEqual(df["clubName"].iat[0], "Example FC")
|
|
66
|
+
self.assertEqual(df["stadium"].iat[0], "Example Stadium")
|
|
67
|
+
|
|
68
|
+
def test_overall_stats_keeps_nulls(self):
|
|
69
|
+
with fake_ea(fixture("overall")):
|
|
70
|
+
df = FC27API().get_club_overall_stats(1001)
|
|
71
|
+
|
|
72
|
+
self.assertEqual(df["skillRating"].iat[0], 1500)
|
|
73
|
+
self.assertEqual(df["winStreak"].iat[0], 3)
|
|
74
|
+
self.assertTrue(df["bestDivision"].isna().iat[0])
|
|
75
|
+
|
|
76
|
+
def test_members(self):
|
|
77
|
+
with fake_ea(fixture("members")):
|
|
78
|
+
df = FC27API().get_member_stats(1001)
|
|
79
|
+
self.assertEqual(len(df), 2)
|
|
80
|
+
self.assertEqual(df["averageRating"].iat[0], 7.4)
|
|
81
|
+
|
|
82
|
+
with fake_ea(fixture("career")):
|
|
83
|
+
self.assertEqual(len(FC27API().get_member_career_stats(1001)), 2)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
class FindClubIdTests(unittest.TestCase):
|
|
87
|
+
def test_exact_name_ignoring_case(self):
|
|
88
|
+
with fake_ea(fixture("search")):
|
|
89
|
+
self.assertEqual(FC27API().find_club_id("example fc"), 1001)
|
|
90
|
+
|
|
91
|
+
def test_single_result(self):
|
|
92
|
+
with fake_ea(fixture("search")[1:]):
|
|
93
|
+
self.assertEqual(FC27API().find_club_id("example"), 1002)
|
|
94
|
+
|
|
95
|
+
def test_ambiguous_lists_options(self):
|
|
96
|
+
with fake_ea(fixture("search")):
|
|
97
|
+
with self.assertRaises(ValueError) as caught:
|
|
98
|
+
FC27API().find_club_id("example")
|
|
99
|
+
self.assertIn("Example FC (id 1001)", str(caught.exception))
|
|
100
|
+
|
|
101
|
+
def test_no_result(self):
|
|
102
|
+
with fake_ea([]):
|
|
103
|
+
with self.assertRaises(ValueError):
|
|
104
|
+
FC27API().find_club_id("nothing")
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class MatchTests(unittest.TestCase):
|
|
108
|
+
def test_matches_from_club_side(self):
|
|
109
|
+
with fake_ea(fixture("matches")) as fake:
|
|
110
|
+
df = FC27API().get_club_matches(1001)
|
|
111
|
+
|
|
112
|
+
# 1: league win (opponent quit), 2: league draw, 3: friendly lost 0-3
|
|
113
|
+
self.assertEqual(list(df["result"]), ["win", "draw", "loss"])
|
|
114
|
+
self.assertEqual(list(df["dnf"]), [True, False, False])
|
|
115
|
+
self.assertEqual(df["opponentId"].iat[0], 2001)
|
|
116
|
+
self.assertEqual(df["goalsAgainst"].iat[2], 3)
|
|
117
|
+
self.assertEqual(str(df["timestamp"].dt.tz), "UTC")
|
|
118
|
+
self.assertEqual(sent_params(fake)["maxResultCount"], "10")
|
|
119
|
+
|
|
120
|
+
def test_timezone(self):
|
|
121
|
+
with fake_ea(fixture("matches")):
|
|
122
|
+
df = FC27API(timezone="Europe/Istanbul").get_club_matches(1001)
|
|
123
|
+
# 20:00 UTC is 23:00 in Istanbul (UTC+3)
|
|
124
|
+
self.assertEqual(df["timestamp"].iat[0].hour, 23)
|
|
125
|
+
|
|
126
|
+
def test_match_players_own_team_by_default(self):
|
|
127
|
+
with fake_ea(fixture("matches")):
|
|
128
|
+
df = FC27API().get_match_players(1001)
|
|
129
|
+
self.assertEqual(len(df), 3 * 2) # 3 matches x 2 players (fixtures are trimmed)
|
|
130
|
+
self.assertEqual(set(df["clubId"]), {1001})
|
|
131
|
+
self.assertIn("passesMade", df.columns)
|
|
132
|
+
|
|
133
|
+
def test_match_players_both_teams(self):
|
|
134
|
+
with fake_ea(fixture("matches")):
|
|
135
|
+
df = FC27API().get_match_players(1001, both_teams=True)
|
|
136
|
+
self.assertEqual(len(df), 3 * 2 * 2)
|
|
137
|
+
|
|
138
|
+
def test_empty_and_null_matches(self):
|
|
139
|
+
for payload in ([], None):
|
|
140
|
+
with fake_ea(payload):
|
|
141
|
+
self.assertTrue(FC27API().get_club_matches(1001, "playoffMatch").empty)
|
|
142
|
+
self.assertTrue(FC27API().get_match_players(1001, "playoffMatch").empty)
|
|
143
|
+
|
|
144
|
+
def test_bad_match_type(self):
|
|
145
|
+
with self.assertRaises(ValueError):
|
|
146
|
+
FC27API().get_club_matches(1001, "cupMatch")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
class ErrorTests(unittest.TestCase):
|
|
150
|
+
def test_http_error(self):
|
|
151
|
+
error = urllib.error.HTTPError("url", 403, "Forbidden", {}, None)
|
|
152
|
+
with patch("urllib.request.urlopen", side_effect=error):
|
|
153
|
+
with self.assertRaises(FC27APIError):
|
|
154
|
+
FC27API().get_club_details(1001)
|
|
155
|
+
|
|
156
|
+
def test_timeout(self):
|
|
157
|
+
with patch("urllib.request.urlopen", side_effect=TimeoutError("timed out")):
|
|
158
|
+
with self.assertRaises(FC27APIError):
|
|
159
|
+
FC27API().get_club_details(1001)
|
|
160
|
+
|
|
161
|
+
def test_not_json(self):
|
|
162
|
+
with patch("urllib.request.urlopen", return_value=io.BytesIO(b"<html>blocked</html>")):
|
|
163
|
+
with self.assertRaises(FC27APIError):
|
|
164
|
+
FC27API().get_club_details(1001)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
unittest.main()
|