Skip to content

Instantly share code, notes, and snippets.

@ariankordi
Last active February 12, 2024 00:43
Show Gist options
  • Save ariankordi/e3b0a12f7b2e713cd1bc2923ed2c50f8 to your computer and use it in GitHub Desktop.
Save ariankordi/e3b0a12f7b2e713cd1bc2923ed2c50f8 to your computer and use it in GitHub Desktop.
python selenium script (meant to work in conjunction with cursed nso reverse proxy!!!!!) that shows splatoon stages on your kindle
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>arian.splatnet-kindle</string>
<key>Program</key>
<string>/opt/homebrew/bin/python3</string>
<key>ProgramArguments</key>
<array>
<string>/opt/homebrew/bin/python3</string>
<string>/Users/arian/Documents/splatnet-kindle/splatnet-kindle.py</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>SSH_HOST</key>
<string>kindle-hostname-goes-here</string>
<key>SSH_PASS</key>
<string>kindle-password-goes-here</string>
</dict>
<key>StartCalendarInterval</key>
<!--
you will need to switch to this when daylight savings time changes
TODO: either give a more elegant fix for this...
... or instead run every hour,
and let the script decide whether it's the correct second hour
<array>
<dict>
<key>Hour</key>
<integer>0</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>2</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>4</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>6</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>8</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>10</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>12</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>14</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>16</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>18</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>20</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>22</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
-->
<array>
<dict>
<key>Hour</key>
<integer>1</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>3</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>5</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>7</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>9</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>11</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>13</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>15</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>17</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>19</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>21</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
<dict>
<key>Hour</key>
<integer>23</integer>
<key>Minute</key>
<integer>0</integer>
</dict>
</array>
</dict>
</plist>
[Unit]
Description=wip script to update kindle
# no more than 5 restarts within 3.4 mins
StartLimitInterval=300
[Service]
#Type=oneshot
Environment=SSH_HOST=kindle-hostname-goes-here
Environment=SSH_PASS=kindle-password-goes-here
WorkingDirectory=/home/arian/Downloads/splatnet-kindle
ExecStart=/usr/bin/python3 splatnet-kindle.py
Restart=on-failure
RestartSec=30
StartLimitBurst=5
[Unit]
Description=wip script to update kindle
[Timer]
#OnCalendar=*:0/2
OnCalendar=0/2:00:00 UTC
#RandomizedDelaySec=1h
#Persistent=true
[Install]
WantedBy=timers.target
var style = document.createElement('style');
style.innerHTML = `
/* for testing
html {filter: grayscale(100%)}
*/
/* no scrollbars */
*::-webkit-scrollbar {display: none}
/* tighter */
[class^=NavigationBar_container]{height: 0 !important; min-height: 20px !important}
[class^=Schedule_settings]{gap:0 !important; padding-top: 0 !important}
[class^=Schedule_Schedule]{padding-bottom: 5px !important}
[class^=Schedule_scheduleContainer]{padding-top : 15px !important}
/* smaller time */
span[class^=Balloon_Balloon__]{margin-top: 9px !important; padding: 0 0 0 5px !important; font-size: 19px !important; font-family: var(--font-family-s2) !important;}
/* no bottom home bar */
[class^=HomeIndicatorSpacer_HomeIndicatorSpacer]{display : none !important}
/* no side bar */
[class^=NavigationTab_Wrapper__]{display: none !important}
/* black background */
/*:root { --color-background: black !important; }
/* text color - white theme */
:root {--color-background: white !important}
[class^=App_App__]{color: black !important}
[class^=Schedule_RuleName__]{text-shadow: none !important}
[class^=Schedule_bankaraMode__]{background-color: var(--color-yellow) !important}
[class^=DateLabel_time__]{color: black !important; text-shadow: none !important}
[class^=FestInfo_fesDate]{color: black !important}
[class^=FestInfo_fesContent__]{background-color: darkslategray !important}
[class^=FestInfo_fesLinkBtn__]{/*margin-top: 10px*/display: none !important}
[class^=FestInfo_fesStageName__]{background: var(--color-yellow) !important}
[class^=FestInfo_container__]{margin-bottom: /*-25px*/-15px !important;padding-top: 3px !important}
/* not adjusting size of splatfest header for now */
[class^=SwipableView_swipableViewItem__] { background-color: var(--color-background) !important; }
/* big stage names */
[class^=Schedule_StageName__]{line-height: 23px !important; font-size: 18px !important}
/* big icons */
[class^=Schedule_ruleImg__]{height: 33px !important; width: 33px !important}
/* big mode names */
[class^=Schedule_RuleName__]{font-size: 23px !important}
/* big anarchy modes */
[class^=Schedule_bankaraMode__]{font-size: 18px !important}
`
document.head.appendChild(style);
// Replace the following line with your JavaScript code
// persistent data request stubs
window.restorePersistentData = () => {window.console.log('restorepersistentdata called')};
window.storePersistentData = input => {window.console.log('persistentdatastore called', input)};
// game token (x-gamewebtoken) request
window.requestGameWebToken = () => {
window.console.log('requestgamewebtoken called');
fetch('http://localhost:36017/_/request_gamewebtoken')
.then(response => {
return response.text();
})
.then(data => {
// data is now gtoken
window.onGameWebTokenReceive(data);
});
};
/* handle fetches for skipping one rotation ahead, more or less temporary?
* reasons this sucks:
* it's inefficient due to repetitive json en/decoding
* either use complicated string operations or hook JSON.unstringify LMAO (remember, only once!)
* it uses a static query hash for the time being
* i don't trust this method to not fail lol
* that should be all
*/
const realFetch = fetch;
window.fetch = async function(...arg) {
//window.ase = arg;console.log(arg)
// check for a post (graphql) request that includes the hash of the query we want to edit
if(
window.oneAhead &&
arg[0] &&
arg[1] &&
arg[1].method === "POST" &&
arg[1].body &&
arg[1].body.includes("f76dd61e08f4ce1d5d5b17762a243fec")
) {
const response = await realFetch(...arg);
const responseText = await response.text();
const resultJSON = JSON.parse(responseText);
resultJSON.data.regularSchedules.nodes.shift();
resultJSON.data.bankaraSchedules.nodes.shift();
resultJSON.data.xSchedules.nodes.shift();
resultJSON.data.festSchedules.nodes.shift();
//resultJSON.data.bankaraSchedules.nodes.unshift(resultJSON.data.bankaraSchedules.nodes.pop());
// const result = JSON.stringify(resultJSON); // Remove this line
// Create a custom Response object with the modified JSON data
const modifiedResponse = new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
// Override the json() method of the custom Response object
modifiedResponse.json = async () => /*{console.log('hit our json function..!!!!');return */resultJSON//};
// console.log('check ass');window.ass = resultJSON;
return modifiedResponse;
}
return realFetch(arg[0], arg[1]);
};
#!/usr/bin/python3
# inform about missing libraries potentially
try:
from selenium import webdriver
import selenium.common.exceptions
from selenium.webdriver.common.by import By
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
# import the service so a custom executable path can be specified
from selenium.webdriver.chrome.service import Service
from shutil import which
# export image to jpeg and with minimal colors
from PIL import Image
from PIL.ImageOps import posterize
#from tempfile import gettempdir
# ssh client
import paramiko
except ImportError as e:
print('\033[91mthis script uses paramiko, selenium, and PIL ' \
'(pillow/pillow-simd). please make sure you have them installed, ' \
'refer to the script\'s imports for help\033[0m')
raise e
import io
import os
import sys
# our kb estimation
from math import ceil
from time import sleep
# this is where the profile of the chromedriver will be
# feel free to customize it if you don't use linux or don't like it
chromedriver_user_data_dir = f'--user-data-dir={os.environ["HOME"]}/.config/splatnet-kindle-chromedriver'
# check for environment variables involving ssh credentials
#if not all(v in os.environ for v in ['SSH_HOST', 'SSH_PASS']): # 'SSH_USER',]):
if not 'SSH_HOST' in os.environ:
print('make sure to define the following environment ' \
'variables: SSH_HOST, SSH_USER (optional, default root), ' \
'and SSH_PASS (optional, ssh key is used too). ' \
'these represent the ssh credentials of the kindle ' \
'for which this script will attempt to connect to. ' \
'this script assumes your kindle has ssh enabled on wifi via the USBNetwork hack. OK?')
sys.exit(1)
# connect to kindle first things first
# because if this fails then there is no point of continuing
ssh = paramiko.SSHClient()
# you may comment this if you have already ssh'ed into your kindle
# NOTE: this isn't necessarily secure but this laso isn't mission critical so meh
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
# this should throw an exception on timeout, stopping the script from continuing
ssh.connect(os.environ['SSH_HOST'],
# use root as default username so i can be lazy and not define it
username=os.environ.get('SSH_USER', 'root'),
password=os.environ.get('SSH_PASS'), timeout=30)
# hardcoded link to ranked battle stages
url = 'https://api.lp1.av5ja.srv.nintendo.net/schedule/bankara'
options = webdriver.ChromeOptions()
# if you pass in any argv it will enable this debug mode
try:
# if there's any second argument then disable headless
if sys.argv[1]:
print('\033[1mDEBUG MODE ENABLED:\033[0m browser will not run headless. ' \
'it will also stick around. if you get an error next time you run this it\'s ' \
'probably because the chromium didn\'t launch because there is already one open. ok?')
# makes the browser hang around for testing
options.add_experimental_option("detach", True)
except IndexError:
# run webdriver in headless mode ideal for screenshots
# sometimes screenshots look wrong with this off?
options.add_argument("--headless")
pass
# options below are added somewhat dubiously
options.add_argument("--enable-gpu")
options.add_argument("--ozone-platform-hint=auto")
options.add_argument("--ignore-gpu-blocklist")
options.add_argument("--use-vulkan")
options.add_argument("--enable-raw-draw")
options.add_argument("--enable-gpu-rasterization")
options.add_argument("--enable-gpu-compositing")
# force scaling, bc on large systems the window is rendered at a larger scale
if 'SCALE' in os.environ:
options.add_argument("--force-device-scale-factor=" + os.environ['SCALE'])
else:
# hardcoded for chromium, not other browsers...!!!
options.add_argument("--force-device-scale-factor=1")
options.add_argument("--password-store=basic")
# not necessary to use user data dir if seemingly we cannot find a home directory
if 'HOME' in os.environ:
# set profile directory for the chromedriver in order to...
# ... cache and be faster. uses the template for this path
options.add_argument(chromedriver_user_data_dir)
else:
print('$HOME not found in the environment, not specifying a data ' \
'directory for chromedriver (= no caches and this may be slower than it has to be.)')
# the result of which will be None when there is no chromedriver in PATH
which_path = which('chromedriver')
if which_path:
# specify custom service so that the executable path in the PATH can be used
# this bypasses the selenium-manager on purpose as I dislike selenium-manager
# it may be faster and easier to use the native chromedriver and the version does not really matter
service = Service(executable_path=which_path)
else:
# if chromedriver was not found in path then just use default method
if sys.platform == 'linux':
print('chromedriver not found in PATH. this is not a big deal it\'s just ' \
'that I try to avoid using selenium-manager in this script, SO, if nothing ' \
'happens for a while or you see it error out, it may be selenium-manager running, ' \
'lol...\nif this causes problems then make sure `which chromedriver` returns something (i.e, it is in your PATH)')
service = Service()
# initialize webdriver with chrome, other backends are untested
# and I don't think they would due to the DevTools execute_cdp_cmd hook for the JS
driver = webdriver.Chrome(options=options, service=service)
#try:
# hardcoded size of these screens: kindle 4/touch, basic 5,7,8,10
width = 600
height = 800
width = os.environ['WIDTH'] if 'WIDTH' in os.environ else width
height = os.environ['HEIGHT'] if 'HEIGHT' in os.environ else height
driver.set_window_size(width, height)
dirname = os.path.split(os.path.abspath(__file__))[0]
# JavaScript code to inject into the page
js_code = open(os.path.join(dirname, 'splatnet-kindle.js'), 'r').read()
# this is also injected later on
css_code = open(os.path.join(dirname, 'splatnet-kindle.css'), 'r').read()
# inject js on every page load (with driver.execute_script this is intermittent)
driver.execute_cdp_cmd('Page.addScriptToEvaluateOnNewDocument', {'source': js_code})
# Visit the URL
driver.get(url)
# inject css into the page via JS
'''
driver.execute_script("""
var style = document.createElement('style');
style.innerHTML = `"""+ css_code +"""`
document.head.appendChild(style);
""")
'''
# inject css, except that the css file is actually a js file
# in order to avoid that concatenating so keep that in mind...
driver.execute_script(css_code)
# set the js to show one rotation ahead on the page
# ideally you use this by running the script e.g 10, 5 minutes early
# and schedule the kindle to show the image exACTLY when the rotation changes
#driver.execute_script('window.oneAhead = true;')
# wait for page to load by looking for first schedule entry
try:
element = WebDriverWait(driver, 30).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, 'div[aria-hidden=false] div[class^=Schedule_scheduleContainer] > div[class^=Schedule_]:first-of-type'))
# old one
#EC.presence_of_element_located((By.CSS_SELECTOR, '[class^=Schedule_scheduleContainer]'))
# ISSUE?: this does NOT wait for images and fonts to load
# but apparently you would have to set up a timeout script for that so that's a lot more complex
# it might be more responsive since selenium polls for elements (idk if it would for a script)
)
except selenium.common.exceptions.TimeoutException as e:
# timeout = likely chance is that fetch failed
print('\033[1mtimed out - page may not have loaded, showing you the latest console entries:\033[0m')
for log in driver.get_log('browser'):
# highlight "net::" in bold, as to point to network related errors
# like if any of the given servers cannot be contacted
#message_net_highlighted = log['message'].replace('net::', '\033[1mnet::')
# actually highlighting this text represents pretty much all server errors
message_net_highlighted = log['message'].replace('Failed to load resource', '\033[1mFailed to load resource')
# make text red if the log level is severe
print(('\033[91m' if log['level'] == 'SEVERE' else '') + message_net_highlighted + '\033[0m')
raise e
# also, as a hack, select fest if it says "Splatfest Active!"
# that div should have a class in all circumstances, we just searched for it
# but see if the class is actually fest announcement...
if element.get_attribute('class').startswith('Schedule_festAnnouncement'):
# actually try to click on fest button and wait for it
# the button is invisible so just use js to click it (asynchronous!)
# the alternative is to make it visible and then click which would probably be slower tbh because selenium is just making http requests to chromedriver
# this should not cause problems since it is waited for anyway
#driver.find_element(By.XPATH, '//*[contains(@class, "swipeTabIconFest")]/..').click()
driver.execute_script("document.querySelector('[class^=swipeTabIconFest]').parentElement.click()")
# now wait for first tab being visible slash animation finishing (SPLATFEST SHOULD BE FIRST!!!! but don't wait long because now we know the page is loaded and gucci)
# by the way you can avoid the animation, search for `,skipAnimation` in the webpack bundle
# that would be faster but involve way more and this is already a hack as-is
WebDriverWait(driver, 3).until(
EC.visibility_of_element_located((By.CSS_SELECTOR, '#SwipeTab-panel-0[style="display: block; transform: none;"]'))
)
# Take a screenshot
#driver.save_screenshot("screenshot.png")
# TODO: REMOVE THIS 200ms DELAY AND REPLACE WITH SCRIPT THAT WAITS FOR IMAGES&FONTS TO BE READY
sleep(0.2)
# Take a screenshot as a binary stream
screenshot_stream = driver.get_screenshot_as_png()
# Convert the binary stream to a Pillow Image object
image = Image.open(io.BytesIO(screenshot_stream))
# Convert the image to 4 bit grayscale (monochrome)
image = image.convert('L')
image = posterize(image, bits=4)
fname = 'splatnet-kindle-screenshot-mono'
# save monochrome screenshot to a BytesIO stream in memory
image_stream = io.BytesIO()
# save to system specific tmp folder
#local_file = os.path.join(gettempdir(), fname)
# eips supports jpeg and png but png averages 75 KB while jpeg averages 210 w/ artifacting
image.save(image_stream, format='png') # format='jpeg', quality=95)
# show size of image in kb for the sake of logging it
stream_size_kb = ceil(sys.getsizeof(image_stream) / 1024)
print(f'size of {fname}: {stream_size_kb} KB, now transferring to {os.environ["SSH_HOST"]}...')
image.close()
# this may not be necessary, it appears to exit with the script
driver.quit()
# finally, transfer the file to the kindle and run eips -g
with ssh.open_sftp() as sftp:
# kindles have /dev/shm as a tmpfs and it works ok
remote_fname = f'/dev/shm/{fname}'
# the file is never removed by anything (but it is overwritten)
with sftp.file(remote_fname, 'wb') as remote_file:
remote_file.write(image_stream.getvalue())
# -c refreshes the screen so add/remove this if you want that
command = f'/usr/sbin/eips -c -g {remote_fname}' # && rm {remote_fname}')
# omit stdin and stderr, we will combine the latter
_, stdout, _ = ssh.exec_command(command)
stdout.channel.set_combine_stderr(True)
output = stdout.read()
if len(output):
print('output:', output.decode())
image_stream.close()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment