Skip to content

Instantly share code, notes, and snippets.

@mbarnes
Last active February 22, 2024 13:22
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • 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()
@snakeyes1000
Copy link

Did you fix this yet?

@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.

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