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.
@@ -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
+ 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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()