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+ */
312import { DateTime } from "luxon" ;
413import { BaseScraper , type CronConfig } from "@/services/scraper/base/scraper" ;
514import {
@@ -10,15 +19,14 @@ import {
1019} from "@/types/basic" ;
1120import type { NewOffer } from "@/types/database" ;
1221import { 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-
16797export 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