update: bump 'common' version to 0.1.20

This commit is contained in:
2026-08-11 21:18:44 +08:00
parent ddaca17173
commit a9c8644f03
3 changed files with 109 additions and 118 deletions
+13 -20
View File
@@ -191,15 +191,14 @@ import { default as $, LogRecord, Rpc2 } from '/';
let major = 0;
let minor = 0;
let busy = false;
let save = false;
let status = null;
let locale = null;
let unique = null;
let writable = null;
let filereader = null;
let filehandle = null;
let subcategories = [];
window.addEventListener('beforeunload', (e) => {
writable?.abort();
Rpc2.notify('exit');
e.preventDefault();
e.returnValue = '';
@@ -214,7 +213,7 @@ document.title += ` (${name})`;
async function handlePrimaryButtonClick() {
switch (status) {
case 'IDLE':
let PickerOptions = {
[filehandle] = await showOpenFilePicker({
types: [
{
description: "Excel Spreadsheet",
@@ -223,17 +222,11 @@ async function handlePrimaryButtonClick() {
}
}
]
};
let [handle] = await showOpenFilePicker(PickerOptions);
filereader = () => handle.getFile().then(file => new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => resolve([file, reader.result]);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
}));
});
let chain = filehandle.getFile().then(f => Promise.all([f, f.arrayBuffer()]));
let [file, buffer] = await chain.catch(e => alert('(ERROR) ' + new String(e ?? 'Failed to load file')));
let [file, buffer] = await filereader().catch(e => alert('(ERROR) ' + new String(e ?? 'Unable to read data')));
writable = await handle.createWritable().catch(() => null);
save = await handle.requestPermission({ mode: 'readwrite' }) === 'granted';
major = await Rpc2.invoke('loads', { params: [file.name, new Uint8Array(buffer).toBase64()] });
$.get('#fileLabel').innerText = file.name;
$.get('#fromLabel').innerText = parameters.account;
@@ -241,7 +234,7 @@ async function handlePrimaryButtonClick() {
break;
case 'READY':
let locale = $('#locale').value;
let options = { locale, subcategories, mapping: new Object(), writable: new Boolean(writable), start: 2, limit: null };
let options = { locale, subcategories, save, mapping: new Object(), start: 2, limit: null };
for (let element of $.all("input[type='number'].params")) {
parameters[element.id] = element.disabled ? null : element.valueAsNumber;
@@ -422,7 +415,6 @@ while (await new Promise(o => setTimeout(o, 1000, true))) {
case 'IDLE':
$('#send > span.text').innerText = 'Open';
$('#send').classList.remove('pulse');
await writable?.abort().catch(() => void 0).finally(() => writable = null);
break;
case 'READY':
$('#send > span.text').innerText = 'Send';
@@ -431,17 +423,18 @@ while (await new Promise(o => setTimeout(o, 1000, true))) {
$('#remainingLabel').innerText = '';
break;
case 'CLOSING':
if (writable) try {
if (save) try {
$.all('#actions > button').forEach(e => e.disabled = true);
let data = await filereader().then(([_, b]) => Rpc2.invoke('merge', { params: [new Uint8Array(b).toBase64()] }));
let writable = await filehandle.createWritable({ mode: 'exclusive' });
let data = await filehandle.getFile().arrayBuffer().then(b => Rpc2.invoke('merge', { params: [new Uint8Array(b).toBase64()] }));
await writable.write(Uint8Array.fromBase64(data));
await writable.close();
await Rpc2.notify('close');
} catch (e) {
if (Error.isError(e) && e.name === 'NoModificationAllowedError') continue;
alert('(ERROR) ' + new String(e ?? 'File operation failed'));
await writable.abort().catch(() => void 0);
}
writable = null;
save = false;
continue;
case 'RUNNING':
$('#send > span.text').innerText = 'Pause';
+96 -98
View File
@@ -43,15 +43,15 @@ WEBMAIL = "https://email.ionos.fr/appsuite/#app=io.ox/mail&mailto=%s"
def main(driver: Chrome, logger = logging.getLogger('main')):
timer = Timer()
parameters = vars(args)
sp = ServiceProvider.default()
sp = ServiceProvider(ServiceProvider.Options(application='Mailer'))
class Status(Enum):
IDLE = 0
READY = 1
RUNNING = 2
STANDBY = 3
CLOSING = 4
class ColumnMapping:
def __init__(self, email=None, recipient=None, code=None, region=None, sent=None, variables=None):
self.email = email or '邮箱'
@@ -60,7 +60,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
self.region = region or '国家地区'
self.sent = sent or '已发送'
self.variables = variables or '变量值'
status = Status.IDLE
options = dict()
headers = list()
@@ -69,7 +69,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
filename: str = None
column: ColumnMapping = None
limit = 0
def begin(opts: dict, args: dict):
nonlocal status
if status != Status.READY: raise ValueError(status)
@@ -78,32 +78,32 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
parameters.update(args)
timer.clear()
timer.start()
def pause():
nonlocal status
status = Status.STANDBY
timer.pause()
def resume():
nonlocal status
status = Status.RUNNING
driver.switch_to.window(driver.current_window_handle)
timer.start()
def unique(header: str):
result = dict()
col = headers.index(header) + 1
for row in range(options.get('start'), limit + 1):
key = str(wb.active.cell(row, col).value)
result[key] = result.get(key, 0) + 1
return result
sp.add(begin, pause, resume, unique)
sp.set('status', lambda: status.name)
sp.set('uptime', lambda: timer.delta())
try:
mapping = ColumnMapping(**{
k.lower().strip(): v.strip() for k, v in map(lambda o: str.split(o, '=', 2), parameters['column'] or list())
@@ -111,7 +111,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
except Exception as e:
logger.critical('Unable to load column mappings', exc_info=e)
return 1
class Locale:
def __init__(self, name, timezone, default, morning=None, afternoon=None, evening=None, keywords=None):
self.name: str = name
@@ -121,7 +121,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
self.afternoon: str = afternoon
self.evening: str = evening
self.keywords: list[str] = keywords
locales = [
Locale("en", "Europe/London", "Hello" , "Good morning", "Good afternoon" , "Good evening", []),
Locale("fr", "Europe/Paris" , "Bonjour", None , None , "Bonsoir" , ['法国', '比利时', '留尼汪']),
@@ -130,29 +130,29 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
Locale("es", "Europe/Madrid", "Hola" , "Buenos días" , "Buenas tardes" , None , ['西班牙']),
Locale("pt", "Europe/Lisbon", "Olá" , "Bom dia" , "Boa tarde" , None , ['葡萄牙']),
]
try:
sp.set('context', lambda: { 'locales': list(map(vars, locales)), 'mapping': vars(mapping), 'parameters': parameters })
driver.get(sp.run())
except Exception as e:
logger.critical('Unable to load starup page', exc_info=e)
return 2
def contains_non_latin_alphabet(string: str):
from unicodedata import category, name
for char in string:
if char.isdigit() or (category(char).startswith('L') and not name(char, '').startswith('LATIN')):
return True
return False
def ready(driver: Chrome):
busy = driver.find_element(By.CSS_SELECTOR, ".io-ox-busy")
return busy
def catch(predicate: Callable[[Chrome], WebElement]):
wait = WebDriverWait(driver, timeout=parameters['interval'])
return wait.until(predicate, 'Timeout')
try:
driver.switch_to.new_window('tab')
driver.set_page_load_timeout(parameters['timeout'])
@@ -160,33 +160,33 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
except TimeoutException:
logger.warning('Timeout')
driver.execute_script("window.stop();")
try:
setup(driver, parameters)
click("#selectAll")
except Exception as e:
logger.debug('Can I haz cheez burger? :3', exc_info=e)
try:
logger.info('Logging in as %s', parameters['account'])
username = locate("#username")
username.send_keys(parameters['account'])
click("#button--with-loader")
password = locate("#password")
password.send_keys(parameters['password'])
click("#button--with-loader")
except Exception as e:
logger.critical('Error while logging in to %s', LOGIN, exc_info=e)
return 3
while True:
try:
loader = locate("#background-loader", wait=False)
if not loader.is_displayed(): break
except:
sleep(1)
def get_subject():
try:
element = locate("h1.subject", wait=False)
@@ -194,7 +194,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
return subject
except:
return None
def get_address():
try:
element = locate("header div.from", wait=False)
@@ -202,11 +202,11 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
return address
except:
return None
def cell(row: int, header: str):
result = wb.active.cell(row, headers.index(header) + 1)
return result
try:
click("li[data-id='default0/Brouillons']", condition=EC.presence_of_element_located)
click("button[data-id='default0/Brouillons']", condition=EC.presence_of_element_located)
@@ -216,74 +216,74 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
logger.warning("Could not open drafts; this may cause issues", exc_info=e)
finally:
driver.switch_to.window(driver.window_handles[0])
class Wait(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
if status == Status.RUNNING: return False
sleep(0.2); return True
class Timeout(Action):
timer = Timer()
clock = Timer()
@classmethod
def prepare(cls):
cls.timer.clear()
cls.timer.start()
cls.clock.clear()
cls.clock.start()
return True
@classmethod
def perform(cls):
if cls.timer.delta() > parameters['timeout']: raise cls
if cls.clock.delta() > parameters['timeout']: raise cls
sleep(0.2); return True
class Acknowledge(Action):
@classmethod
def prepare(cls):
return status == Status.RUNNING
class Cancel(Action):
@classmethod
def prepare(cls):
nonlocal status
status = Status.RUNNING
return True
@classmethod
def perform(cls):
driver.switch_to.window(driver.window_handles[0])
raise cls
class Close(Action):
@classmethod
def prepare(cls):
return status == Status.CLOSING
class Skip(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
driver.switch_to.window(driver.current_window_handle)
raise cls
flow = ActionFlow()
flow.stage(Wait, Acknowledge, Timeout, Cancel, Close, Skip)
sp.add(pairs=[ (k.lower(), v) for k, v in flow ])
sp.set('actions', lambda: flow.capabilities())
def loads(name: str, b64: str):
from base64 import b64decode
from openpyxl import load_workbook
nonlocal status, filename, wb, limit
if status != Status.IDLE: raise ValueError(status)
data = b64decode(b64)
logger.info('Received %s byte(s) total', len(data))
logger.info('Loading...')
@@ -292,56 +292,56 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
wb = load_workbook(buffer)
limit = wb.active.max_row
headers.clear()
for col in range(1, wb.active.max_column + 1):
value = str(wb.active.cell(1, col).value)
headers.append(value)
logger.info('Done')
flow.allow(Cancel)
status = Status.READY
return limit
def merge(b64: str):
from base64 import b64decode, b64encode
from openpyxl import load_workbook
if status != Status.CLOSING: raise ValueError(status)
data = b64decode(b64)
index = headers.index(column.sent)
buffer = BytesIO(data)
target = load_workbook(buffer)
for row in range(options.get('start'), limit + 1):
c1 = wb.active.cell(row, index + 1)
c2 = target.active.cell(row, index + 1)
if not (c1.value and str(c1.value).strip()):
if (c2.value and str(c2.value).strip()):
c1.value = c2.value
io = BytesIO()
wb.save(io)
result = b64encode(io.getbuffer()).decode('ascii')
return result
sp.add(loads, merge)
sp.set('progress', lambda: progress)
def save():
try:
file = Path(parameters['directory']).joinpath(filename or f'Mailer-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.xlsx').resolve()
file = Path(parameters['directory']).joinpath(datetime.now().strftime('Mailer-%Y%m%d-%H%M%S-%f.xlsx')).resolve()
logger.info('Saving document at %s', str(file))
wb.save(file)
except Exception as e:
logger.error('Error while writing data', exc_info=e)
exit = sp.pop('exit')
sp.set('exit', lambda: (save() if options.get('writable') and status.value >= 2 else None, exit()))
sp.set('exit', lambda: (save() if options.get('save') and status.value >= 2 else None, exit()))
class MismatchedEmailSubject(Exception): pass
class MismatchedEmailAddress(Exception): pass
class Next(Exception): pass
while True:
try:
flow.react(Wait)
@@ -349,22 +349,22 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
driver.switch_to.window(driver.window_handles[1])
subject = get_subject()
column = ColumnMapping(**options.get('mapping'))
if column.email not in headers or limit < 2:
logger.error("Column '%s' is not found or does not contain valid data", column.email)
raise Cancel()
if column.sent not in headers:
wb.active.cell(1, wb.active.max_column + 1).value = column.sent
headers.append(column.sent)
logger.info('Read %s line(s) total', limit)
logger.info('Subject: %s', subject)
logger.info('From: %s', parameters['account'])
locale: Locale = next(filter(lambda o: o.name == options.get('locale'), locales))
logger.info('Locale: %s', locale.name.upper())
logger.info('Timezone: %s', locale.timezone)
progress['done'] = 0
progress['skip'] = 0
progress['next'] = 0
@@ -378,12 +378,12 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
status = Status.IDLE
logger.error('Error while loading data', exc_info=e)
continue
index = 2
attempts = 0
tz = ZoneInfo(locale.timezone)
occurrence = dict()
while index <= limit:
try:
flow.react(Wait)
@@ -391,16 +391,16 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
try: code = str(cell(index, column.code).value)
except ValueError: code = None
occurrence.setdefault(code, 0)
if (target := cell(index, column.email).value) is None or not (email := str(target).strip()):
logger.warning("[%d/%d] Not an email address; skipping", index-1, limit-1)
raise Skip()
if attempts > parameters['attempts']:
cell(index, column.sent).value = ''
logger.warning("[%d/%d] Exhausted all allowed attempts; skipping", index-1, limit-1)
raise Skip()
if options.get('slice') and (start := options.get('start')) and (end := options.get('limit')):
if index < start or index >= start + end:
logger.info("[%d/%d] Not planned; skipping '%s'", index-1, limit-1, email)
@@ -408,22 +408,20 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
if (items := options.get('subcategories')) and (item := str(cell(index, column.region).value)) not in items:
logger.info("[%d/%d] Value '%s' not enlisted; skipping '%s'", index-1, limit-1, item, email)
raise Next()
if (sent := cell(index, column.sent).value) is not None and str(sent).strip():
logger.info("[%d/%d] Already visited; skipping '%s'", index-1, limit-1, email)
occurrence[code] += 1
raise Skip()
match options.get('occurrence'):
case o if o is None:
pass
case o if o > 0 and code is None:
if (caps := options.get('occurrence')) is not None and caps > 0:
if code is None:
logger.error("Column '%s' cannot be used for spam control", column.code)
raise Cancel()
case o if o > 0 and occurrence[code] >= o:
if occurrence[code] >= caps:
logger.info("[%d/%d] No Spam! Skipping '%s'", index-1, limit-1, email)
raise Skip()
while mails := driver.find_elements(By.CSS_SELECTOR, "div.io-ox-mail-compose-window"):
try:
click(mails[0].find_element(By.CSS_SELECTOR, "button[data-action='close']"), wait=False)
@@ -431,39 +429,39 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
except Exception as e:
logger.debug('Unable to close off email compose windows', exc_info=e)
break
flow.allow(Skip, Cancel)
flow.react(Wait)
progress['email'] = str(email)
logger.info('[%d/%d] Sending to %s', index-1, limit-1, email)
clean = True
error = None
if (target := get_address()) != parameters['account']:
error = MismatchedEmailAddress(target)
if (target := get_subject()) != subject:
error = MismatchedEmailSubject(target)
if error is not None and not options.get('force'):
raise error
flow.react(Wait)
click("ul.classic-toolbar button[aria-label='Edit copy']")
until(ready)
locate("div.io-ox-mail-compose-window iframe", condition=EC.frame_to_be_available_and_switch_to_it)
if options.get('greet') and not (clean := False):
match datetime.now(tz).hour:
case h if 6 <= h < 12: hello = locale.morning
case h if 12 <= h < 18: hello = locale.afternoon
case h if 18 <= h < 21: hello = locale.evening
case _: hello = None
iframe = driver.switch_to.active_element
action = ActionChains(driver)
hello = hello or locale.default
if column.recipient in headers:
if name := str(cell(index, column.recipient).value).strip():
if not contains_non_latin_alphabet(name):
@@ -472,29 +470,29 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
parts.capitalize(force=True)
short = len(parts.first) < 3 or (len(parts.first) < 5 and parts.first.endswith('.'))
hello = ' '.join(filter(None, [hello, parts.title, parts.first, (parts.middle or parts.last) if short else None]))
hello += ','
action.send_keys(hello).perform()
if elements := iframe.find_elements(By.XPATH, f'//*[contains(text(), "{hello}")]'):
target = elements[0]
clean = target.text == hello
driver.switch_to.default_content()
flow.react(Wait)
click("div.io-ox-mail-compose-window div[data-extension-id='to'] > div.mail-input")
box = locate("div.io-ox-mail-compose-window div[data-extension-id='to'] > div.mail-input input.token-input.tt-input[tabindex='0']")
box.send_keys(str(email) + Keys.ENTER)
if column.variables in headers and (v := cell(index, column.variables).value) is not None:
clean = False
target = locate("div.io-ox-mail-compose-window div[data-extension-id='subject'] input")
template = target.get_attribute('value')
length = len(template)
for i, value in enumerate(str(v).split(',')):
template = template.replace('$$%s' % i, value.strip())
target.send_keys(Keys.BACKSPACE * length)
target.send_keys(template)
clean = target.get_attribute('value') == template
@@ -502,15 +500,15 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
flow.react(Wait)
box = locate("div.io-ox-mail-compose-window .mail-input .tokenfield .token")
recipient = box.get_attribute('innerText').strip()
if recipient != str(email):
logger.warning('Malformed email address detected; retrying (%d)...', attempts)
continue
if not clean:
logger.warning('Malformed email content detected; retrying (%d)...', attempts)
continue
flow.react(Wait)
click("div.io-ox-mail-compose-window button[data-action='send']")
except Cancel:
@@ -528,7 +526,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
logger.error('Unexptected error', exc_info=e)
status = Status.STANDBY
continue
try:
alert = catch(lambda x: x.find_element(By.CSS_SELECTOR, "div.io-ox-alert.io-ox-alert-error"))
message = alert.text.replace('\n', ' ')
@@ -544,7 +542,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
logger.error('Unexptected error', exc_info=e)
status = Status.STANDBY
continue
try:
logger.info('Waiting for client to acknowledge...')
flow.deter(Cancel, Skip, Wait)
@@ -555,8 +553,8 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
except Exception as e:
logger.error('RPC communication failed', exc_info=e)
sp.pop('exit')()
if options.get('writable'):
if options.get('save'):
try:
logger.info('Saving document...')
status = Status.CLOSING
@@ -567,7 +565,7 @@ def main(driver: Chrome, logger = logging.getLogger('main')):
except Exception as e:
logger.warning('RPC communication failed', exc_info=e)
save()
flow.deter(Acknowledge, Timeout, Close)
driver.switch_to.window(driver.window_handles[0])
status = Status.IDLE
@@ -592,7 +590,7 @@ if __name__ == '__main__':
"profile.default_content_setting_values.notifications": 2,
"download.default_directory": args.directory,
})
with keep.presenting():
driver = Chrome(options=opts)
status = main(driver)
BIN
View File
Binary file not shown.