Last active
June 15, 2024 15:35
-
-
Save mbarnes/a9559ae33f6d1f073b4ec16b45ed3789 to your computer and use it in GitHub Desktop.
BJs Wholesale Club Digital Coupon Clipper
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/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() |
This started working again for me with no changes.
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
No, they've started using bot detection.
Will probably require a full rewrite using a web driver framework like Selenium to execute JavaScript.