Skip to content

Commit 6659252

Browse files
fix: switch epic api from graphql to rest endpoint
Epic's GraphQL endpoint at store.epicgames.com/graphql started returning 403 errors due to Cloudflare bot protection. Switched to the simpler REST endpoint at store-site-backend-static.ak.epicgames.com/freeGamesPromotions which has less strict protection and works reliably with plain fetch(). Changes: - Use REST endpoint instead of GraphQL with Apollo Client - Add User-Agent header to match browser requests - Improve variable naming (apiResponse, typedResponse vs data.data) - Preserve old GraphQL implementation in epicApiGraphql.ts for reference 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 3391b78 commit 6659252

2 files changed

Lines changed: 393 additions & 125 deletions

File tree

src/services/scraper/implementations/epicApi.ts

Lines changed: 54 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
1-
import { ApolloClient, gql, InMemoryCache } from "@apollo/client/core";
2-
import { HttpLink } from "@apollo/client/link/http";
1+
/**
2+
* Epic Games API Scraper (REST endpoint)
3+
*
4+
* This scraper uses the simpler REST endpoint at store-site-backend-static.ak.epicgames.com
5+
* which has less strict Cloudflare protection and works more reliably with plain fetch().
6+
*
7+
* Previous implementation used the GraphQL endpoint at store.epicgames.com/graphql with
8+
* Apollo Client, but that endpoint started returning 403 errors due to Cloudflare's
9+
* bot protection. The old GraphQL-based implementation is preserved in epicApiGraphql.ts
10+
* for reference.
11+
*/
312
import { DateTime } from "luxon";
413
import { BaseScraper, type CronConfig } from "@/services/scraper/base/scraper";
514
import {
@@ -10,15 +19,14 @@ import {
1019
} from "@/types/basic";
1120
import type { NewOffer } from "@/types/database";
1221
import { cleanGameTitle } from "@/utils";
22+
import { logger } from "@/utils/logger";
1323

14-
const BASE_URL = "https://store.epicgames.com/graphql";
15-
16-
interface CatalogData {
17-
Catalog: {
18-
__typename: string;
19-
searchStore: {
20-
__typename: string;
21-
elements: RawOffer[];
24+
interface FreeGamesResponse {
25+
data: {
26+
Catalog: {
27+
searchStore: {
28+
elements: RawOffer[];
29+
};
2230
};
2331
};
2432
}
@@ -86,84 +94,6 @@ interface RawOffer {
8694
} | null;
8795
}
8896

89-
// This seems to be indirectly queried by
90-
// https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US&country=US&allowCountries=US
91-
// More queries can be found on https://github.com/SD4RK/epicstore_api/blob/master/epicstore_api/queries.py
92-
const FREEGAMES_QUERY = gql`
93-
query freeGamesQuery(
94-
$count: Int
95-
$country: String!
96-
$locale: String
97-
$itemNs: String
98-
$start: Int
99-
$tag: String
100-
) {
101-
Catalog {
102-
searchStore(
103-
category: "freegames"
104-
count: $count
105-
country: $country
106-
locale: $locale
107-
itemNs: $itemNs
108-
sortBy: "title"
109-
sortDir: "asc"
110-
start: $start
111-
tag: $tag
112-
) {
113-
elements {
114-
title
115-
effectiveDate
116-
expiryDate
117-
viewableDate
118-
productSlug
119-
keyImages {
120-
type
121-
url
122-
}
123-
customAttributes {
124-
key
125-
value
126-
}
127-
price(country: $country) @include(if: true) {
128-
totalPrice {
129-
discountPrice
130-
originalPrice
131-
currencyCode
132-
}
133-
}
134-
catalogNs {
135-
mappings(pageType: "productHome") {
136-
pageSlug
137-
pageType
138-
}
139-
}
140-
offerMappings {
141-
pageSlug
142-
pageType
143-
}
144-
promotions @include(if: true) {
145-
promotionalOffers {
146-
promotionalOffers {
147-
startDate
148-
endDate
149-
discountSetting {
150-
discountType
151-
discountPercentage
152-
}
153-
}
154-
}
155-
}
156-
}
157-
}
158-
}
159-
}
160-
`;
161-
162-
const languageDefaults = {
163-
locale: "en-US",
164-
country: "US",
165-
};
166-
16797
export class EpicGamesApiScraper extends BaseScraper {
16898
override getSchedule(): CronConfig[] {
16999
// Epic Games updates their free games every Thursday at 11:00 US/Eastern
@@ -196,53 +126,52 @@ export class EpicGamesApiScraper extends BaseScraper {
196126
}
197127

198128
override async readOffers(): Promise<Omit<NewOffer, "category">[]> {
199-
const client = this.createClient();
200-
const response = await client.query<CatalogData, { count: number }>({
201-
query: FREEGAMES_QUERY,
202-
variables: { count: 1000 },
203-
errorPolicy: "all",
204-
});
129+
// Use the simpler REST endpoint which has less Cloudflare protection
130+
const response = await fetch(
131+
"https://store-site-backend-static.ak.epicgames.com/freeGamesPromotions?locale=en-US&country=US&allowCountries=US",
132+
{
133+
headers: {
134+
Accept: "application/json",
135+
"Accept-Language": "en-US,en;q=0.9",
136+
"User-Agent":
137+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
138+
},
139+
},
140+
);
205141

206-
if (!response.data) {
142+
if (!response.ok) {
143+
logger.error(
144+
`Epic API returned ${response.status.toString()}: ${response.statusText}`,
145+
);
146+
throw new Error(`Epic API returned ${response.status.toString()}`);
147+
}
148+
149+
const apiResponse = (await response.json()) as unknown;
150+
151+
if (
152+
!apiResponse ||
153+
typeof apiResponse !== "object" ||
154+
!("data" in apiResponse) ||
155+
!apiResponse.data
156+
) {
157+
logger.error(
158+
`No data returned from Epic Games API. Response: ${JSON.stringify(apiResponse)}`,
159+
);
207160
throw new Error("No data returned from Epic Games API");
208161
}
209162

210-
return this.parseOffers(response.data);
163+
const typedResponse = apiResponse as FreeGamesResponse;
164+
165+
return this.parseOffers(typedResponse.data);
211166
}
212167

213168
protected override shouldAlwaysHaveOffers(): boolean {
214169
return true;
215170
}
216171

217-
private createClient() {
218-
const defaultClientOptions: ApolloClient.DefaultOptions = {
219-
query: {
220-
variables: languageDefaults,
221-
},
222-
};
223-
224-
const httpLink = new HttpLink({
225-
uri: BASE_URL,
226-
fetch: async (uri, options) => {
227-
return fetch(uri, {
228-
...options,
229-
headers: {
230-
...(options?.headers as Record<string, string>),
231-
Accept: "application/json",
232-
},
233-
});
234-
},
235-
});
236-
237-
const client = new ApolloClient({
238-
link: httpLink,
239-
cache: new InMemoryCache(),
240-
defaultOptions: defaultClientOptions,
241-
});
242-
return client;
243-
}
244-
245-
private parseOffers(data: CatalogData): Omit<NewOffer, "category">[] {
172+
private parseOffers(
173+
data: FreeGamesResponse["data"],
174+
): Omit<NewOffer, "category">[] {
246175
const rawOffers: RawOffer[] = data.Catalog.searchStore.elements;
247176

248177
return rawOffers

0 commit comments

Comments
 (0)