Skip to content

Instantly share code, notes, and snippets.

@mbarnes
Last active June 15, 2024 15:35
Show Gist options
  • Save mbarnes/a9559ae33f6d1f073b4ec16b45ed3789 to your computer and use it in GitHub Desktop.
Save mbarnes/a9559ae33f6d1f073b4ec16b45ed3789 to your computer and use it in GitHub Desktop.
BJs Wholesale Club Digital Coupon Clipper
#!/usr/bin/python3
#
# Clip all available digital coupons for a BJ's membership.
#
# To avoid interactive prompts, either set environment variables
# BJS_MEMBER_EMAIL and BJS_MEMBER_PASSWORD or add credentials to
# to your ~/.netrc file:
#
# machine bjs.com
# login <BJS_MEMBER_EMAIL>
# password <BJS_MEMBER_PASSWORD>
#
import base64
import getpass
import http
import netrc
import os
import urllib
# 3rd-party modules
import requests
# Show HTTP requests and responses
http.client.HTTPConnection.debuglevel = 0
# Minimum WebGL fingerprint
DEVICE_PROFILE=b'''
{
"components": [
{
"key": "webgl",
"value": [
"data:image/png;base64,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",
"webgl aliased line width range:[1, 7.375]",
"webgl aliased point size range:[1, 255]",
"webgl alpha bits:8",
"webgl antialiasing:yes",
"webgl blue bits:8",
"webgl depth bits:24",
"webgl green bits:8",
"webgl max anisotropy:16",
"webgl max combined texture image units:64",
"webgl max cube map texture size:16384",
"webgl max fragment uniform vectors:1024",
"webgl max render buffer size:16384",
"webgl max texture image units:32",
"webgl max texture size:16384",
"webgl max varying vectors:32",
"webgl max vertex attribs:16",
"webgl max vertex texture image units:32",
"webgl max vertex uniform vectors:1024",
"webgl max viewport dims:[16384, 16384]",
"webgl red bits:8",
"webgl shading language version:WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)",
"webgl vertex shader high float precision:23",
"webgl vertex shader high float precision rangeMin:127",
"webgl vertex shader high float precision rangeMax:127",
"webgl vertex shader medium float precision:23",
"webgl vertex shader medium float precision rangeMin:127",
"webgl vertex shader medium float precision rangeMax:127",
"webgl vertex shader low float precision:23",
"webgl vertex shader low float precision rangeMin:127",
"webgl vertex shader low float precision rangeMax:127",
]
},
]
}
'''
USER_AGENT = 'Mozilla/5.0 (X11; Linux x86_64)'
def bjs_session_login(method):
def inner(session, *args, **kwargs):
"""Log in to BJs.com on first call"""
if not session.account:
pattern = "/digital/live/api/v1.4/storeId/{}/login"
url = pattern.format(session.store_id)
body = {
'deviceProfile': base64.b64encode(DEVICE_PROFILE),
'logonId': session.email,
'logonPassword': session.password
}
response = session.post(url, json=body)
response.raise_for_status()
pattern = "/digital/live/api/v1.0/storeId/{}/accountdetails"
url = pattern.format(session.store_id)
response = session.get(url)
response.raise_for_status()
session.account = response.json()
return method(session, *args, **kwargs)
return inner
class BJsSession(requests.Session):
"""BJs.com REST API session"""
base_url = "https://api.bjs.com"
# XXX This code appears hardcoded in the site's JavaScript,
# defined in the file "main-es2015.js". Unclear if the
# code is the same for everyone. If the site uses some
# 3rd party e-commerce software then the code may be an
# identifier for BJ's Wholesale Club, Inc.
store_id = 10201
def __init__(self, base_url=None):
if base_url:
self.base_url = base_url
super().__init__()
self.headers['Referer'] = 'https://www.bjs.com/'
self.headers['User-Agent'] = USER_AGENT
self.__get_credentials()
self.account = None
def __get_credentials(self):
self.email = os.environ.get('BJS_MEMBER_EMAIL')
self.password = os.environ.get('BJS_MEMBER_PASSWORD')
if not (self.email and self.password):
try:
if auth := netrc.netrc().authenticators('bjs.com'):
self.email, _, self.password = auth
except FileNotFoundError:
pass
if not (self.email and self.password):
print('BJs.com Member Sign In')
self.email = input('Email Address: ').strip()
self.password = getpass.getpass('Password: ').strip()
def request(self, method, url, *args, **kwargs):
"""Send the request after generating the complete URL"""
url = self.create_url(url)
return super().request(method, url, *args, **kwargs)
def create_url(self, url):
"""Create the URL based off this partial path"""
return urllib.parse.urljoin(self.base_url, url)
@bjs_session_login
def activate_offer(self, offer):
"""Activate a discount offer"""
url = "/digital/live/api/v1.0/store/{}/coupons/activate"
params = {
'offerId': offer['offerId'],
'zip': self.account['zipCode'][:5]
}
response = self.get(url.format(self.store_id), params=params)
response.raise_for_status()
data = response.json()
try:
return data['success']
except KeyError:
print('JSON:', data)
# If JSON response is empty,
# assume success and proceed.
if not data:
return True
raise
@bjs_session_login
def available_offers(self):
"""List available discount offers"""
url = "/digital/live/api/v1.0/member/available/offers"
offer_count = 0
total_available = 1
while offer_count < total_available:
body = {
'brand': '',
'category': '',
'indexForPagination': max(0, offer_count - 1),
'isNext': True,
'isPrev': False,
'membershipNumber': self.account['MembershipNumber'],
'pagesize': 100,
'searchString': '',
'zipcode': self.account['zipCode'][:5]
}
response = self.post(url, json=body)
response.raise_for_status()
data = response.json().pop()
offer_count += len(data['availableOffers'])
total_available = data['totalAvailable']
yield from data['availableOffers']
def main():
with BJsSession() as session:
clipped = []
for offer in session.available_offers():
if session.activate_offer(offer):
print('CLIPPED:', offer['offerSummary'], offer['offerDescription'])
clipped.append(offer)
print(len(clipped), "coupons clipped")
if __name__ == '__main__':
main()
@mbarnes
Copy link
Author

mbarnes commented Sep 27, 2023

No, they've started using bot detection.
Will probably require a full rewrite using a web driver framework like Selenium to execute JavaScript.

@mbarnes
Copy link
Author

mbarnes commented Feb 22, 2024

This started working again for me with no changes.

@LightningATCHub
Copy link

Don't think it's working anymore.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment