update: pivot to gui, removed wx

This commit is contained in:
2025-08-28 13:13:01 +08:00
parent f04ec0eee7
commit bf54ad9316
11 changed files with 1117 additions and 739 deletions

4
.gitignore vendored
View File

@@ -1,6 +1,2 @@
.vscode/
venv/
resources/
/*.bat
/*.csv
/*.xlsx

566
index.html Normal file
View File

@@ -0,0 +1,566 @@
<link rel="icon" href="https://s3.dualstack.us-east-2.amazonaws.com/pythondotorg-assets/media/files/python-logo-only.svg" type="image/svg+xml">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/pure-min.css" integrity="sha384-X38yfunGUhNzHpBaEBsWLO+A0HDYOQi8ufWDkZ0k9e0eXz/tH3II7uKZ9msv++Ls" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/purecss@3.0.0/build/grids-responsive-min.css">
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/dayjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/utc.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/timezone.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/duration.js"></script>
<script src="https://cdn.jsdelivr.net/npm/dayjs@1/plugin/relativeTime.js"></script>
<title>Python Application</title>
<form class="pure-form pure-form-stacked pure-u-11-12 pure-u-lg-3-4 pure-u-xl-2-3">
<fieldset>
<legend>Basic Information</legend>
<div class="gaps pure-g">
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">File Name</span>
<span class="less ellipsis pure-u-3-4" id="fileLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Subject</span>
<span class="less ellipsis pure-u-3-4" id="subjectLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">From</span>
<span class="less ellipsis pure-u-3-4" id="fromLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Recipient</span>
<span class="less ellipsis pure-u-3-4" id="recipientLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Progress</span>
<span class="less ellipsis pure-u-3-4" id="progressLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Timezone</span>
<span class="less ellipsis pure-u-3-4" id="timezoneLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Uptime</span>
<span class="less ellipsis pure-u-3-4" id="uptimeLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Remaining</span>
<span class="less ellipsis pure-u-3-4" id="remainingLabel"></span>
</div>
<div class="inline-flex pure-u-1 pure-u-sm-1-2">
<span class="ellipsis pure-u-1-4">Status</span>
<span class="less ellipsis pure-u-3-4" id="statusLabel"></span>
</div>
</div>
<br>
<button type="button" class="inline-flex pure-button pure-button-primary" id="send">
<span class="text">Open</span>
<span class="icon spin hidden">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-loader">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M12 6l0 -3" />
<path d="M16.25 7.75l2.15 -2.15" />
<path d="M18 12l3 0" />
<path d="M16.25 16.25l2.15 2.15" />
<path d="M12 18l0 3" />
<path d="M7.75 16.25l-2.15 2.15" />
<path d="M6 12l-3 0" />
<path d="M7.75 7.75l-2.15 -2.15" />
</svg>
</span>
</button>
<button type="button" class="pure-button" id="skip" disabled>
Skip
</button>
<button type="button" class="pure-button" id="cancel" disabled>
Cancel
</button>
</fieldset>
<br>
<fieldset>
<legend>Parameters</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="timeout">Timeout</label>
<input id="timeout" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="interval">Interval</label>
<input id="interval" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="max_occurrence">Max Occurrence</label>
<input id="max_occurrence" class="inactive pure-u-23-24" type="number" min="0" value="5" disabled/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="retry">Retry</label>
<input id="retry" class="pure-u-23-24" type="number" min="0"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="locale">Locale</label>
<select id="locale" class="pure-input-1-2" required>
</select>
</div>
</div>
<br>
<label for="slice" class="pure-checkbox">
<input id="slice" type="checkbox"/> Enable task slicing
</label>
<label for="greet" class="pure-checkbox">
<input id="greet" type="checkbox"/> Greet recipients (by proper names if possible)
</label>
<label for="save" class="pure-checkbox">
<input id="save" type="checkbox" checked/> Save to file upon exit
</label>
<label for="nospam" class="pure-checkbox">
<input id="nospam" type="checkbox"/> Avoid spamming
</label>
</fieldset>
<br>
<fieldset>
<legend>Columns</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_address">Receipient Address</label>
<select id="column_address" class="columns pure-u-23-24" required>
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_name">Receipient Name</label>
<select id="column_name" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_code">Reference Code</label>
<select id="column_code" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_pays">Country</label>
<select id="column_pays" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_sent">Remarks</label>
<select id="column_sent" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
</select>
</div>
</div>
</fieldset>
<br>
<fieldset>
<legend>Slicing</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="group_by">Group by</label>
<select id="group_by" class="slicing localonly pure-u-23-24" disabled>
<option value="none">None</option>
<option value="locale">Locale</option>
<option value="country">Country</option>
</select>
<label for="subcategory">Subcategory</label>
<select id="subcategory" class="slicing inactive localonly pure-u-23-24" multiple disabled>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="chunk_size">Chunk Size</label>
<input id="chunk_size" class="slicing pure-u-23-24" type="number" min="100" value="1000" disabled/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<div class="inline-flex">
<div class="pure-u-1-2">
<label for="offset">Offset</label>
<input id="offset" class="slicing pure-u-23-24" type="number" min="0" disabled/>
</div>
<div class="pure-u-1-2">
<label for="limit">Max. (limit)</label>
<input id="limit" class="slicing localonly pure-u-23-24" type="number" readonly disabled/>
</div>
</div>
</div>
</div>
</fieldset>
</form>
<script type="text/javascript">
const $ = (selectors) => {
return document.querySelector(selectors);
};
const $$ = (selectors) => {
return document.querySelectorAll(selectors);
};
const $$$ = (selectors, event, listener) => {
$(selectors).addEventListener(event, listener);
};
let Limit = null;
let Status = null;
let Columns = null;
let Locales = null;
let Connection = null;
$$$('#send', 'click', async (e) => {
switch (Status) {
case 'STANDBY':
Connection.send('RESUME');
$('#send > span.text').innerHTML = 'Pause';
$('#send > span.icon').classList.remove('hidden');
$('#send').disabled = true;
break;
case 'RUNNING':
Connection.send('ONHOLD');
$('#send > span.text').innerHTML = 'Resume';
$('#send > span.icon').classList.add('hidden');
$('#send').disabled = true;
break;
case 'READY':
let parameters = {};
for (let element of $$("input[type='number']:not([disabled]):not(.localonly)")) {
parameters[element.id] = element.valueAsNumber;
}
for (let element of $$("select:not([disabled]):not(.localonly)")) {
parameters[element.id] = element.value || element.dataset.default;
}
for (let element of $$("input[type='checkbox']:not([disabled])")) {
parameters[element.id] = element.checked;
}
if (parameters.slice && $('#subcategory').dataset.values) {
parameters.subcategory = $('#subcategory').dataset.values.split(',');
}
for (let element of $$("input:not(.inactive), select:not(.inactive)")) {
element.disabled = true;
}
Connection.send(JSON.stringify(parameters));
Status = 'RUNNING';
$('#send > span.text').innerHTML = 'Pause';
$('#send > span.icon').classList.remove('hidden');
$('#skip').disabled = false;
break;
default:
try {
$('#send > span.icon').classList.remove('hidden');
let [handle] = await showOpenFilePicker();
let file = await handle.getFile();
let buffer = await new Promise((resolve, reject) => {
let reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(file);
});
Connection.send(file.name);
Connection.send(buffer);
Status = 'READY';
$('#fileLabel').innerHTML = file.name;
$('#uptimeLabel').dataset.timestamp = dayjs().unix();
$('#send > span.text').innerHTML = 'Send';
$('#cancel').disabled = false;
$('#send').disabled = true;
} finally {
$('#send > span.icon').classList.add('hidden');
}
}
});
$$$('#skip', 'click', (e) => {
Connection.send('BYPASS');
if (Status === 'STANDBY') Connection.send('ONHOLD');
$('#skip').disabled = true;
});
$$$('#cancel', 'click', (e) => {
Connection.send('CANCEL');
$('#cancel').disabled = true;
});
$$$('#locale', 'change', (e) => {
$('#group_by').dispatchEvent(new Event('change'));
$('#timezoneLabel').dataset.timezone = Locales.find((x) => x.locale == e.target.value).timezone;
});
$$$('#slice', 'change', (e) => {
for (let element of $$('.slicing:not(.inactive)')) {
element.disabled = !e.target.checked;
}
$('#subcategory').dispatchEvent(new Event('change'));
});
$$$('#nospam', 'change', (e) => {
$('#max_occurrence').classList.toggle('inactive');
$('#max_occurrence').disabled = !e.target.checked;
$('#column_code').required = e.target.checked;
});
$$$('#column_pays', 'change', (e) => {
$('#group_by').dispatchEvent(new Event('change'));
});
$$$('#group_by', 'change', (e) => {
let columns = Columns ? Columns[$('#column_pays').value] : null;
let countries = Object.assign({}, columns);
let results = {};
switch (e.target.value) {
case 'none':
case 'country':
results = countries;
break;
case 'locale':
let locale = $('#locale').value;
let targets = Locales.toReversed();
for (let target of targets) {
for (let key of Object.keys(countries)) {
if (target.predicate == null || target.predicate.includes(key)) {
if (target.locale === locale) {
results[key] = countries[key];
}
delete countries[key];
}
}
}
break;
}
switch (e.target.value) {
default:
$('#subcategory').options.length = 0;
case 'none':
$('#subcategory').classList.add('inactive');
$('#subcategory').disabled = true;
break;
case 'country':
case 'locale':
$('#subcategory').classList.remove('inactive');
$('#subcategory').disabled = false;
break;
}
for (let [key, value] of Object.entries(results)) {
let option = new Option(`${key} (${value})`, key);
option.dataset.count = value;
$('#subcategory').options.add(option);
}
$('#subcategory').dispatchEvent(new Event('change'));
});
$$$('#subcategory', 'change', (e) => {
let options = Array.from(e.target.options).filter((v) => v.selected);
let values = options.map((o) => o.value).join(',');
let limit = options.reduce((i, v) => i + Number(v.dataset.count), 0);
$('#subcategory').dataset.limit = limit;
$('#subcategory').dataset.values = values;
$('#chunk_size').dispatchEvent(new Event('change'));
});
$$$('#chunk_size', 'change', (e) => {
let limit = $('#subcategory').disabled ? Limit : Number(subcategory.dataset.limit);
let size = Math.floor(limit / e.target.value);
$('#limit').value = size;
$('#offset').max = size;
$('#offset').value = 0;
});
function main(url, parameters, locales) {
Connection = new WebSocket(url);
Locales = JSON.parse(locales);
for (let [key, value] of Object.entries(parameters)) {
$(`input#${key}`)?.setAttribute('value', value);
}
for (let item of Locales) {
$('#locale').add(new Option(item.locale.toUpperCase(), item.locale));
$('#locale').dispatchEvent(new Event('change'))
}
Connection.addEventListener('message', (e) => {
let command = JSON.parse(e.data);
let {name, args} = command;
switch (name) {
case 'setAddress':
let [person, address] = args;
document.title = `${person} (${address})`;
$('#fromLabel').innerText = address;
break;
case 'setSubject':
let [subject] = args;
$('#subjectLabel').innerHTML = subject;
break;
case 'setMetadata':
[Limit, Columns] = args;
for (let element of $$('.columns')) {
let options = element.options;
options.length = 1;
for (let column of Object.keys(Columns)) {
options.add(new Option(column, column, false, column === parameters[element.id]));
}
element.dataset.default = parameters[element.id];
element.dispatchEvent(new Event('change'));
}
$('#send').disabled = false;
$('#cancel').disabled = false;
break;
case 'setProgress':
let [sent, name, recipient] = args;
let label = name ? `${name} <${recipient}>` : recipient;
$('#recipientLabel').innerHTML = label;
$('#progressLabel').dataset.sent = sent;
break;
case 'setStatus':
switch (args[0]) {
case 'RESUME':
case 'BYPASS':
Status = 'RUNNING';
$('#send').disabled = false;
$('#skip').disabled = false;
break;
case 'ONHOLD':
Status = 'STANDBY';
$('#send').disabled = false;
break;
case 'FAILED':
Status = 'STANDBY';
$('#send > span.text').innerHTML = 'Resume';
$('#send > span.icon').classList.add('hidden');
break;
case 'FINISH':
alert('任务结束')
case 'CANCEL':
Status = null;
$('#send > span.text').innerHTML = 'Open';
$('#send > span.icon').classList.add('hidden');
$('#cancel').disabled = true;
$('#skip').disabled = true;
for (let element of $$("input:not(.inactive):not(.slicing)")) {
element.disabled = false;
}
for (let element of $$("select:not(.inactive):not(.slicing)")) {
element.disabled = false;
}
for (let element of $$("input[type='checkbox']")) {
element.dispatchEvent(new Event('change'));
}
break;
}
break;
case 'tell':
let [message, level] = args;
let prefix = level === 0 ? 'ERROR' : level === 1 ? 'WARNING' : 'INFO';
console.log(`[${prefix}] ${message}`);
if (level === 0) alert(message);
break;
}
});
dayjs.extend(dayjs_plugin_utc);
dayjs.extend(dayjs_plugin_timezone);
dayjs.extend(dayjs_plugin_duration);
dayjs.extend(dayjs_plugin_relativeTime);
let clock = setInterval(() => {
let timezone = $('#timezoneLabel').dataset.timezone;
let city = timezone.split('/')[1];
let date = dayjs().tz(timezone).format("YYYY-MM-DD HH:mm:ss");
$('#timezoneLabel').innerHTML = `${date} (${city})`;
$('#statusLabel').innerHTML = Status ? Status.charAt(0).toUpperCase() + Status.slice(1).toLowerCase() : 'Idle';
if (Status === 'RUNNING') {
let sent = Number($('#progressLabel').dataset.sent) || 0;
let limit = Number($('#subcategory').dataset.limit) || Limit;
let percentage = parseFloat((sent / limit * 100).toFixed(2));
$('#progressLabel').innerHTML = `${sent} / ${limit} (${percentage} %)`;
let timestamp = Number($('#uptimeLabel').dataset.timestamp);
let uptime = dayjs.duration(dayjs().diff(dayjs.unix(timestamp)));
let rate = sent / uptime.asSeconds();
let spm = parseFloat(Number(rate*60).toFixed(2));
let speed = spm >= 1 ? `${spm} per minute` : null;
let remaining = rate > 0 ? dayjs.duration((limit - sent) / rate, 'second').humanize() : null;
$('#remainingLabel').innerHTML = [remaining, speed].filter((x) => x).join(' - ');
$('#uptimeLabel').innerHTML = uptime.format("HH:mm:ss");
}
}, 1000);
}
</script>
<style type="text/css">
body {
width: 100%;
margin: 2em 0 2em 0;
display: inline-flex;
justify-content: center;
}
form {
padding: 0 1em 0 1em;
}
label {
user-select: none;
width: fit-content;
}
.inline-flex {
display: inline-flex;
align-items: center;
}
.ellipsis {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hidden {
display: none;
}
.gaps {
row-gap: 0.4em;
}
.icon {
height: 1em;
}
.less {
color: #666;
max-width: 72%;
}
.spin {
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

548
main.py Normal file
View File

@@ -0,0 +1,548 @@
import unicodedata
import traceback
import argparse
import logging
import pandas
import json
import time
import re
import trio
import trio_websocket as ws
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
from selenium.webdriver import Chrome, ChromeOptions
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.remote.webdriver import WebDriver
from io import BytesIO
from enum import Enum
from queue import Queue, Empty
from wakepy import keep
from pathlib import Path
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from threading import Thread
from nameparser import HumanName
parser = argparse.ArgumentParser(description="邮件批量发送脚本")
parser.add_argument('url', nargs='?', default='https://id.ionos.fr/identifier')
parser.add_argument('--column-address', type=str, nargs='?', default='邮箱')
parser.add_argument('--column-name', type=str, nargs='?', default='主要联系人')
parser.add_argument('--column-code', type=str, nargs='?', default='客户编号')
parser.add_argument('--column-pays', type=str, nargs='?', default='国家地区')
parser.add_argument('--column-sent', type=str, nargs='?', default='已发送')
parser.add_argument('-a', '--address', type=str, nargs='?')
parser.add_argument('-p', '--password', type=str, nargs='?')
parser.add_argument('-t', '--timeout', type=int, nargs='?', default=60)
parser.add_argument('-i', '--interval', type=int, nargs='?', default=10)
parser.add_argument('-r', '--retry', type=int, nargs='?', default=3)
args = parser.parse_args()
inbox, outbox = Queue(), Queue()
connection = None
socket = None
server = None
sent = 0
errors = 0
warnings = 0
class Greetings:
def __init__(self, locale: str, timezone: str, default: str, morning=None, afternoon=None, evening=None, predicate=None):
self.locale = locale
self.timezone = timezone
self.default = default
self.morning = morning
self.afternoon = afternoon
self.evening = evening
self.predicate = predicate
@staticmethod
def presets():
return [
Greetings("en", "Europe/London", "Hello" , "Good morning", "Good afternoon" , "Good evening", None),
Greetings("fr", "Europe/Paris" , "Bonjour", None , "Bon après-midi" , "Bonsoir" , ['法国', '比利时']),
Greetings("de", "Europe/Berlin", "Hallo" , "Guten Morgen", "Guten Tag" , "Guten Abend" , ['德国', '奥地利', '瑞士']),
Greetings("it", "Europe/Rome" , "Ciao" , "Buongiorno" , "Buon pomeriggio", "Buonasera" , ['意大利']),
Greetings("es", "Europe/Madrid", "Hola" , "Buenos días" , "Buenas tardes" , None , ['西班牙']),
Greetings("pt", "Europe/Lisbon", "Olá" , "Bom dia" , "Boa tarde" , None , ['葡萄牙']),
]
class Command:
def __init__(self, name: str, *args):
self.name = name
self.args = args
def __str__(self):
pack = vars(self)
return json.dumps(pack)
def tell(message, exception=None, level=2):
message = ': '.join(map(lambda x: str(x), filter(None, [message, exception])))
if isinstance(exception, Exception): message += '\n' + ''.join(traceback.format_exception(exception))
if not outbox.is_shutdown:
outbox.put(Command('tell', message, level))
match level:
case 2: logging.info(message)
case 1: logging.warning(message)
case 0: logging.error(message)
def main(driver: WebDriver):
try:
html = str(Path('index.html').resolve())
driver.get(html)
host, port = socket.getsockname()
parameters = vars(args)
locales = json.dumps(Greetings.presets(), default=lambda o: o.__dict__)
driver.execute_script(f"main(...arguments);", f'ws://{host}:{port}', parameters, locales)
except Exception as e:
tell('载入初始页面时发生错误', e, level=0)
return 1
def locate(selector, condition=EC.presence_of_element_located, parent=driver) -> WebElement:
for attempt in range(parameters.get('retry')):
try:
wait = WebDriverWait(parent, timeout=parameters.get('timeout'))
return wait.until(condition((By.CSS_SELECTOR, selector)))
except StaleElementReferenceException:
# 如果遇到过期元素,重新尝试查找
continue
except TimeoutException:
# 超时错误
raise Exception('操作超时')
raise Exception(f'无法定位元素: {selector}')
def click(selector, condition=EC.element_to_be_clickable, parent=driver):
element = locate(selector, condition, parent) if isinstance(selector, str) else selector
counter = lambda: int(element.get_attribute('taximeter') or 0)
error = False
value = counter()
driver.execute_script("arguments[0].addEventListener('click', () => arguments[0].setAttribute('taximeter', arguments[1] + 1));", element, value)
for attempt in range(parameters.get("retry")):
try:
if not error: element.click()
else: driver.execute_script("arguments[0].click();", element)
except StaleElementReferenceException:
break
except:
error = True
continue
# 检测点击事件
try:
WebDriverWait(driver, parameters.get("interval")).until(lambda _: counter() > value)
break
except TimeoutException: continue
except: break
def ready(driver, predicate):
try:
wait = WebDriverWait(driver, timeout=parameters.get('timeout'))
wait.until(predicate, '操作超时')
except:
return True
wait = WebDriverWait(driver, timeout=parameters.get('timeout'))
wait.until_not(predicate, '操作超时')
return True
def contains_non_latin_alphabet(string: str):
for char in string:
if char.isdigit() or (unicodedata.category(char).startswith('L') and not unicodedata.name(char, '').startswith('LATIN')):
return True
return False
try:
driver.switch_to.new_window('tab')
driver.set_page_load_timeout(parameters.get('timeout'))
driver.get(parameters.get('url'))
except TimeoutException:
# 停止加载
tell('操作超时', level=1)
driver.execute_script("window.stop();")
# 接受 cookie
try: click("#selectAll")
except: pass
if (address := parameters.get('address')) and (pw := parameters.get('password')):
try:
username = locate("#username")
username.send_keys(address)
click("#button--with-loader")
password = locate("#password")
password.send_keys(pw)
click("#button--with-loader")
except Exception as e:
tell('登录时发生了错误', e, level=0)
return 4
while not time.sleep(1):
try:
if not driver.find_element(By.CSS_SELECTOR, "#background-loader").is_displayed():
tell('正在登陆')
click("#io-ox-topbar-account-dropdown-icon button.dropdown-toggle")
address = locate("#topbar-account-dropdown .mail").get_attribute('innerText')
parts = HumanName(address.split('@', 2)[0])
parts.capitalize(force=True)
outbox.put(Command('setAddress', parts.full_name, address))
tell(f'成功登录 {parts.full_name} ({address})')
break
except: continue
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)
# 打开邮件
click("ul[aria-label='List view'] li[data-index='0']", condition=EC.presence_of_element_located)
except Exception as e:
tell('打开草稿邮件时发生错误', e, level=1)
pass
class Faillable(Exception):
def __init__(self, request: str):
self.request = request
super().__init__(self.request)
class Status(Enum):
ACTIVE = 0
INACTIVE = 1
TERMINATED = 3
def isalive(self): return self != Status.TERMINATED
def isactive(self): return self == Status.ACTIVE
def get_subject():
try:
element = driver.find_element(By.CSS_SELECTOR, "h1.subject")
subject = element.text.strip()
return subject
except:
return None
def get_address():
try:
element = driver.find_element(By.CSS_SELECTOR, "header div.from")
address = re.search(r'[^<\s]+@[^>\s]+', element.text[6:])[0]
return address
except:
return None
def get_request():
try: request = inbox.get(block=False)
except Empty: return None
if request == 'ONHOLD':
outbox.put(Command('setStatus', 'ONHOLD'))
request = inbox.get()
if request == 'RESUME':
outbox.put(Command('setStatus', 'RESUME'))
return None
if request in ['BYPASS', 'CANCEL']:
outbox.put(Command('setStatus', request))
raise Faillable(request)
return request
while True:
try:
driver.switch_to.window(driver.window_handles[0])
filename = str(inbox.get())
buffer = BytesIO(inbox.get())
workbook = pandas.read_excel(buffer, sheet_name=0)
frame = workbook.where(pandas.notnull(workbook), None)
limit = int(frame.last_valid_index())
driver.switch_to.window(driver.window_handles[1])
columns = { column: frame[column].unique() for column in frame.columns }
for k, v in columns.items(): columns[k] = { str(unique): len(frame[frame[k] == unique]) for unique in v if unique is not None } if len(v) <= 200 else {}
outbox.put(Command('setMetadata', limit, columns))
while not time.sleep(1):
request = get_request()
subject = get_subject()
outbox.put(Command('setSubject', subject))
if request is not None:
parameters = dict(json.loads(request))
break
except Faillable:
continue
except Exception as e:
tell('读取数据时发生错误', e, level=0)
return 5
if parameters.get('slice'):
if subcategory := parameters.get('subcategory'):
column = parameters.get('column_pays')
frame = frame[frame[column].isin(subcategory)]
offset = parameters.get('offset')
size = parameters.get('chunk_size')
start = size * offset
end = start + size
frame = frame.iloc[start:end]
data = frame.to_dict(orient='list')
recipients = data.get(parameters.get('column_address'), [])
limit = len(recipients)
names = data.get(parameters.get('column_name'), [None] * limit)
codes = data.get(parameters.get('column_code'), [None] * limit)
sents = data.setdefault(parameters.get('column_sent'), [None] * limit)
rate = 60 / (parameters.get('interval') + 3)
length = list.count(sents, None)
tell(f'已读取邮件:{subject}')
tell(f'指定发件人:{address}')
tell(f'已读取联系人信息共 {limit}')
tell(f'预计发送数量 {length}')
tell(f'当前发送速率 {round(rate, 2)} 封/分钟')
if rate > 8.33: tell('当前发送速率已超出限制 8.33 封/分钟', level=1)
tell(f'预计使用时间 {timedelta(minutes=length / rate)}')
tell(f'已设定允许重试次数:{parameters.get('retry')}')
tell(f'已设定最大重复次数:{parameters.get('max_occurrence') or '无限制'}')
locale = parameters.get('locale')
greetings = [item for item in Greetings.presets() if item.locale == locale][0]
timezone = ZoneInfo(greetings.timezone)
tell(f'当前时区:{greetings.timezone}')
tell(f'当前语言:{greetings.locale.upper()}')
index = 0
status = Status.ACTIVE
occurrences = {}
while status.isactive() and index < limit:
global warnings
global errors
global sent
attempt = 0
current = index
index += 1
recipient = str(recipients[current]).strip()
name = names[current]
code = codes[current]
mark = sents[current]
occurrence = occurrences.setdefault(code, [0]) if code else [0]
outbox.put(Command('setProgress', index, name, recipient))
if mark is not None and str(mark).strip():
tell(f'已跳过项目 {recipient}')
occurrence[0] += 1
continue
if (maximum := parameters.get('max_occurrence')) and occurrence[0] >= maximum:
tell(f'收件人 {recipient} 所属组织出现次数已超出限制 {occurrence}', level=1)
warnings += 1
continue
while status.isactive():
try:
clean = True
attempt += 1
request = get_request()
print('[信息] 正在发送:%s (%.2f %%)' % (recipient, current / limit * 100))
if (target := get_address()) != address:
raise Exception(f'邮件发件地址与设定不一致\n>> {address}\n>> {target}')
if (target := get_subject()) != subject:
raise Exception(f'邮件主题与设定不一致\n>> {subject}\n>> {target}')
click("button[aria-label='Edit copy']")
ready(driver, lambda x: x.find_element(By.CSS_SELECTOR, ".io-ox-busy"))
locate("div.io-ox-mail-compose-window iframe", condition=EC.frame_to_be_available_and_switch_to_it)
if parameters.get('greet') and greetings and timezone:
match datetime.now(timezone).hour:
case hour if 6 <= hour < 12: hello = greetings.morning
case hour if 12 <= hour < 18: hello = greetings.afternoon
case hour if 18 <= hour < 21: hello = greetings.evening
case _: hello = None
iframe = driver.switch_to.active_element
action = ActionChains(driver)
clean = False
hello = hello or greetings.default
if name is not None and (name := str(name).strip()) and not contains_non_latin_alphabet(name):
parts = HumanName(name)
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 items := iframe.find_elements(By.XPATH, f'//*[contains(text(), "{hello}")]'):
target = items[0]
clean = target.text == hello
driver.switch_to.default_content()
wrapper = locate("div.io-ox-mail-compose-window div[data-extension-id='to'] > div.mail-input")
to = locate("input.token-input.tt-input[tabindex='0']", parent=wrapper)
# 填入收件人
click(wrapper)
to.send_keys(recipient + Keys.ENTER)
token = locate("div.io-ox-mail-compose-window .mail-input .tokenfield .token")
target = token.get_attribute('innerText').strip()
if target != recipient:
tell(f'收件人地址不一致 ({attempt})', target, level=1)
elif not clean:
tell(f'邮件内容不正确 ({attempt})', level=1)
else:
# 发送邮件
request = get_request()
click("div.io-ox-mail-compose-window button[data-action='send']")
# 检测页面警告
try:
wait = WebDriverWait(driver, timeout=parameters.get('interval'))
alert = wait.until(lambda x: x.find_element(By.CSS_SELECTOR, "div.io-ox-alert.io-ox-alert-error"))
sents[current] = ''
message = alert.text.replace('\n', ' ')
tell(f'邮件系统错误 ({attempt})', message or None, level=1)
# 关闭警告
click("div.io-ox-alert.io-ox-alert-error button[data-action='close']")
except TimeoutException:
sents[current] = '✔️'
occurrence[0] += 1
sent += 1
break
while mails := driver.find_elements(By.CSS_SELECTOR, "div.io-ox-mail-compose-window"):
try:
click("button[data-action='close']", parent=mails[0])
click("div.modal-footer button[data-action='delete']")
except Exception as e:
tell("关闭邮件时发生了错误", e, level=1)
break
if attempt < parameters.get('retry'):
continue
else:
tell('已超出最大重试上限', level=1)
errors += 1
break
except Faillable as o:
request = o.request
except KeyboardInterrupt:
tell('程序中断', level=1)
status = Status.TERMINATED
break
except Exception as e:
tell(f'发生错误 ({attempt})', e, level=0)
outbox.put(Command('setStatus', 'FAILED'))
request = inbox.get()
if request == 'BYPASS':
outbox.put(Command('setStatus', 'BYPASS'))
break
if request == 'RESUME':
outbox.put(Command('setStatus', 'RESUME'))
continue
if request == 'CANCEL':
outbox.put(Command('setStatus', 'CANCEL'))
status = Status.INACTIVE
progress = index / limit * 100
print('[信息] 当前进度:%.2f %%' % progress)
if parameters.get('save'):
try:
tell(f'正在写入文件:{filename}')
pandas.DataFrame.from_dict(data).to_excel(filename, index=False, sheet_name='Sheet1')
except Exception as e:
tell('写入文件时发生了错误', e, level=0)
if status.isalive(): outbox.put(Command('setStatus', 'FINISH'))
else: break
return 0
async def handler(request: ws.WebSocketRequest):
global connection
websocket = await request.accept()
if connection is None:
connection = websocket
else:
await websocket.aclose(code=1000, reason="Server allows only one connection")
return
async def receiver():
while True:
try:
message = await connection.get_message()
inbox.put(message)
except Exception as e:
tell('Receiver', e, level=0)
break
async def sender():
while True:
try:
message = await trio.to_thread.run_sync(outbox.get)
await connection.send_message(str(message))
except Exception as e:
tell('Sender', e, level=0)
break
async with trio.open_nursery() as nursery:
nursery.start_soon(receiver)
nursery.start_soon(sender)
outbox.shutdown(immediate=True)
inbox.shutdown(immediate=True)
async def backend(listen='127.0.0.1', port=0):
global server, socket
listeners = await trio.open_tcp_listeners(port, host=listen)
server = ws.WebSocketServer(handler, listeners, max_message_size=125_000_000)
socket = listeners[0].socket
await server.run()
if __name__ == '__main__':
try:
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] %(message)s", datefmt="%Y-%m-%d %H:%M")
tell('正在初始化')
thread = Thread(target=lambda: trio.run(backend), daemon=True)
thread.start()
tell('正在启动 Chrome 自动化实例')
with keep.presenting():
opts = ChromeOptions()
opts.add_experimental_option("excludeSwitches", ["enable-logging"])
driver = Chrome(opts)
status = main(driver)
except KeyboardInterrupt:
tell('程序中断', level=1)
status = 145
except Exception as e:
tell('致命错误', e, level=0)
status = 1
finally:
driver.quit()
tell(f'已发送 {sent} 封;发送失败 {errors} 封;跳过重复项 {warnings}')
exit(status)

Binary file not shown.

1
scripts/example.bat Normal file
View File

@@ -0,0 +1 @@
.\..\venv\Scripts\pythonw.exe .\..\main.py --address "user@example.com" --password "example" --max-occurrence 5 --interval 10

View File

@@ -1,3 +0,0 @@
chcp 65001
.\..\venv\Scripts\python.exe .\..\邮件批量发送.py --address "user@example.com" --password "example" --max-occurrence 5 --interval 10
@pause

2
scripts/setup.bat Normal file
View File

@@ -0,0 +1,2 @@
python -m venv .\..\venv
.\..\venv\Scripts\python.exe -m pip install -r .\..\requirements.txt

View File

@@ -1,3 +0,0 @@
chcp 65001
.\..\venv\Scripts\python.exe .\..\邮件列表切片.py --size 1000
@pause

View File

@@ -1,143 +0,0 @@
import argparse
import pathlib
import pandas
import wx
parser = argparse.ArgumentParser(description="邮件列表切片脚本")
parser.add_argument('--column-pays', type=str, nargs='?', default='国家地区')
parser.add_argument('--column-code', type=str, nargs='?', default='客户编号')
parser.add_argument('-d', '--outdir', type=str, nargs='?')
parser.add_argument('-s', '--size', type=int, nargs='?', default=1000)
args = parser.parse_args()
mappings = [
{
"locale": "fr",
"predicate": lambda it: it in ['法国', 'FR', '比利时', 'BE']
},
{
"locale": "de",
"predicate": lambda it: it in ['德国', 'DE', '奥地利', 'AT', '瑞士', 'CH']
},
{
"locale": "it",
"predicate": lambda it: it in ['意大利', 'IT']
},
{
"locale": "es",
"predicate": lambda it: it in ['西班牙', 'ES']
},
{
"locale": "pt",
"predicate": lambda it: it in ['葡萄牙', 'PT']
},
{
"locale": "en",
"predicate": lambda it: bool(it)
},
{
"locale": "un",
"predicate": lambda it: True
},
]
def main():
try:
print('[信息] 请选择数据源')
app = wx.App(None)
dialog = wx.FileDialog(None, 'Open', wildcard='*.xlsx', style=wx.FD_OPEN|wx.FD_FILE_MUST_EXIST)
if dialog.ShowModal() == wx.ID_OK:
filepath = dialog.GetPath()
else:
print('[警告] 操作取消')
return 0
dialog.Destroy()
if args.outdir is None:
print('[信息] 请选择保存位置')
dialog = wx.DirDialog(None, 'Save to', style=wx.DD_DIR_MUST_EXIST)
if dialog.ShowModal() == wx.ID_OK:
args.outdir = dialog.GetPath()
else:
print('[警告] 操作取消')
return 0
dialog.Destroy()
print(f'[信息] 正在读取数据:{filepath}')
sheets = pandas.read_excel(filepath, sheet_name=None)
names = sheets.keys()
parts = pathlib.Path(filepath)
prefix = parts.stem
except Exception as e:
print(f'[警告] 读取数据表失败:{e}')
return 1
if (length := len(sheets)) > 1:
print(f'[信息] 已读取以下 ({length}) 工作表:', end='\n\n')
for name in names: print(f'\t{name}')
while True:
if (key := input('\n[????] 请输入工作表名称:')) in names:
sheet = sheets.get(key)
break
else:
print(f'[警告] 名称无效,请重试')
else:
print(f'[信息] 已读取工作表:{names[0]}')
sheet = sheets.get(names[0])
data = sheet.to_dict(orient='records')
primary = {}
secondary = {}
for record in data:
pays = record.get(args.column_pays)
for rule in mappings:
locale = rule.get('locale')
predicate = rule.get('predicate')
if predicate(pays):
group: list = primary.setdefault(locale, [])
group.append(record)
break
for key, group in primary.items():
factor = 1
offset = 0
cursor = 0
while (limit := args.size * factor + offset) and (slice := group[cursor:limit]):
last: dict = slice[-1:][0]
next: dict = group[limit] if len(group) > limit else None
if next is not None and (c1 := last.get(args.column_code)) and (c2 := next.get(args.column_code)) and c1 == c2:
offset += 1
continue
name = '.'.join([key, str(factor)])
secondary.setdefault(name, slice)
factor += 1
cursor = limit
offset = 0
for key, group in secondary.items():
try:
filepath = f'{args.outdir}/{prefix}.{key}.xlsx'
df = pandas.DataFrame.from_records(group)
print(f'[信息] 正在写入文件:{filepath}')
df.to_excel(filepath, index=False, sheet_name='Sheet1')
except Exception as e:
print(f'[警告] 写入文件时发生了错误:{e}')
continue
return 0
try: status = main()
except KeyboardInterrupt: status = 144
exit(status)

View File

@@ -1,425 +0,0 @@
import unicodedata
import argparse
import pandas
import time
import wx
from selenium.common.exceptions import StaleElementReferenceException, TimeoutException
from selenium.webdriver import Chrome, ChromeOptions
from selenium.webdriver.common.action_chains import ActionChains
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.support import expected_conditions as EC
from selenium.webdriver.support.wait import WebDriverWait
from selenium.webdriver.remote.webelement import WebElement
from selenium.webdriver.remote.webdriver import WebDriver
from enum import Enum
from pathlib import Path
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from nameparser import HumanName
parser = argparse.ArgumentParser(description="邮件批量发送脚本")
parser.add_argument('url', nargs='?', default='https://id.ionos.fr/identifier')
parser.add_argument('--column-address', type=str, nargs='?', default='邮箱')
parser.add_argument('--column-name', type=str, nargs='?', default='主要联系人')
parser.add_argument('--column-code', type=str, nargs='?', default='客户编号')
parser.add_argument('--column-sent', type=str, nargs='?', default='已发送')
parser.add_argument('-a', '--address', type=str, nargs='?', default='')
parser.add_argument('-p', '--password', type=str, nargs='?', default='')
parser.add_argument('-t', '--timeout', type=int, nargs='?', default=60)
parser.add_argument('-i', '--interval', type=int, nargs='?', default=10)
parser.add_argument('-m', '--max-occurrence', type=int, nargs='?', default=0)
parser.add_argument('-T', '--timezone', type=str, nargs='?', default='')
parser.add_argument('-r', '--retry', type=int, nargs='?', default=3)
args = parser.parse_args()
date = datetime.now()
sent = 0
errors = 0
warnings = 0
greetings = [
{
"locale": "en",
"default": "Hello",
"registry": ["Good morning", "Good afternoon", "Good evening"]
},
{
"locale": "fr",
"default": "Bonjour",
"registry": [None, "Bon après-midi", "Bonsoir"]
},
{
"locale": "de",
"default": "Hallo",
"registry": ["Guten Morgen", "Guten Tag", "Guten Abend"]
},
{
"locale": "it",
"default": "Ciao",
"registry": ["Buongiorno", "Buon pomeriggio", "Buonasera"]
},
{
"locale": "es",
"default": "Hola",
"registry": ["Buenos días", "Buenas tardes", None]
},
{
"locale": "pt",
"default": "Olá",
"registry": ["Bom dia", "Boa tarde", None]
}
]
def main(driver: WebDriver):
def locate(selector, condition=EC.presence_of_element_located, parent=driver) -> WebElement:
for attempt in range(args.retry):
try:
wait = WebDriverWait(parent, timeout=args.timeout)
return wait.until(condition((By.CSS_SELECTOR, selector)))
except StaleElementReferenceException:
# 如果遇到过期元素,重新尝试查找
continue
except TimeoutException:
# 超时错误
raise Exception('操作超时')
raise Exception(f'无法定位元素: {selector}')
def click(selector, condition=EC.element_to_be_clickable, parent=driver):
element = locate(selector, condition, parent) if isinstance(selector, str) else selector
counter = lambda: int(element.get_attribute('taximeter') or 0)
error = False
value = counter()
driver.execute_script("arguments[0].addEventListener('click', () => arguments[0].setAttribute('taximeter', arguments[1] + 1));", element, value)
for attempt in range(args.retry):
try:
if not error: element.click()
else: driver.execute_script("arguments[0].click();", element)
except StaleElementReferenceException:
break
except:
error = True
continue
# 检测点击事件
try:
WebDriverWait(driver, args.interval).until(lambda _: counter() > value)
break
except TimeoutException: continue
except: break
def ready(driver, predicate):
try:
wait = WebDriverWait(driver, timeout=args.timeout)
wait.until(predicate, '操作超时')
except:
return True
wait = WebDriverWait(driver, timeout=args.timeout)
wait.until_not(predicate, '操作超时')
return True
def contains_non_latin_alphabet(string: str):
for char in string:
if char.isdigit() or (unicodedata.category(char).startswith('L') and not unicodedata.name(char, '').startswith('LATIN')):
return True
return False
try:
driver.get(args.url)
except TimeoutException:
# 停止加载
print(f'[警告] 操作超时')
driver.execute_script("window.stop();")
# 接受 cookie
try: click("#selectAll")
except: pass
if args.address and args.password:
try:
print(f'[信息] 正在登陆 {args.address}')
username = locate("#username")
username.send_keys(args.address)
click("#button--with-loader")
password = locate("#password")
password.send_keys(args.password)
click("#button--with-loader")
except Exception as e:
print(f'[!!!!] 登录时发生了错误:{e}')
return 4
else:
print('[信息] 请在页面上输入账户凭据')
while True:
try:
driver.find_element(By.CSS_SELECTOR, "#io-ox-core")
loader = driver.find_element(By.CSS_SELECTOR, "#background-loader")
if not loader.is_displayed():
print(f'[信息] 已登录')
break
else:
raise Exception()
except:
time.sleep(args.interval)
while True:
try:
print(f'[信息] 正在打开草稿邮件')
# 打开草稿箱
click("li[data-id='default0/Brouillons']", condition=EC.presence_of_element_located)
click("button[data-id='default0/Brouillons']", condition=EC.presence_of_element_located)
# 打开邮件
click("ul[aria-label='List view'] li[data-index='0']", condition=EC.presence_of_element_located)
except Exception as e:
print(f'[警告] 打开草稿邮件时发生了错误:{e}')
pass
try:
print('[信息] 请选择数据源')
title = 'Open (%s)' % args.address
dialog = wx.FileDialog(None, title, wildcard='*.xlsx', style=wx.FD_OPEN | wx.FD_FILE_MUST_EXIST)
if dialog.ShowModal() == wx.ID_OK:
filepath = dialog.GetPath()
print(f'[信息] 正在读取数据:{filepath}')
else:
print('[警告] 操作取消')
return 0
workbook = pandas.read_excel(filepath)
data = workbook.where(pandas.notnull(workbook), None).to_dict(orient='list')
recipients = data.get(args.column_address, [])
parts = Path(filepath).stem.split('.', 2)
suffix = parts[1].lower() if len(parts) > 1 else None
except Exception as e:
print(f'[警告] 读取数据表失败:{e}')
continue
finally:
dialog.Destroy()
limit = len(recipients)
names = data.get(args.column_name, [None] * limit)
codes = data.get(args.column_code, [None] * limit)
sents = data.setdefault(args.column_sent, [None] * limit)
rate = 60 / (args.interval + 3)
length = list.count(sents, None)
timezone = ZoneInfo(args.timezone) if args.timezone else None
print(f'[信息] 已读取联系人信息共 {limit}')
print(f'[信息] 预计发送数量 {length}')
print(f'[信息] 当前发送速率 {round(rate, 2)} 封/分钟')
if rate > 8.33: print('[警告] 当前发送速率已超出限制 8.33 封/分钟')
print(f'[信息] 预计使用时间 {timedelta(minutes=length / rate)}')
print(f'[信息] 已设定允许重试次数:{args.retry}')
print(f'[信息] 已设定最大重复次数:{args.max_occurrence or '无限制'}')
print(f'[信息] 当前时区:{timezone or ''}')
entries = list(filter(lambda it: it.get('locale') == suffix, greetings))
locale = dict(entries[0]) if len(entries) > 0 else None
print(f'[信息] 当前语言:{str(suffix).upper() if locale else ''}')
if input('[????] 开始发送?确定 (y) / 取消 (N): ') not in ['Y', 'y']:
print('[信息] 操作取消')
continue
subject = locate("h1.subject").text.strip()
sender = locate("header div.from").text[6:].replace('\n', ' ').strip().lstrip('<').rstrip('>')
print(f'[信息] 已读取邮件:{subject}')
print(f'[信息] 指定发件人:{sender}')
if sender.lower() != str(args.address).lower():
print(f'[警告] 检测到发件人与设定不一致')
print(f'[信息] 提示:请检查邮件是否正确')
class Status(Enum):
ACTIVE = 0
INACTIVE = 1
TERMINATED = 3
def isalive(self): return self != Status.TERMINATED
def isactive(self): return self == Status.ACTIVE
global date
date = datetime.now()
index = 0
status = Status.ACTIVE
occurrences = {}
while status.isactive() and index < limit:
global warnings
global errors
global sent
attempt = 0
current = index
index += 1
recipient = str(recipients[current]).strip()
name = names[current]
code = codes[current]
mark = sents[current]
occurrence = occurrences.setdefault(code, [0]) if code else [0]
if mark is not None and str(mark).strip():
print(f'[信息] 已跳过项目 {recipient}')
occurrence[0] += 1
continue
if args.max_occurrence > 0 and occurrence[0] >= args.max_occurrence:
print(f'[警告] 收件人 {recipient} 所属组织出现次数已超出限制 {occurrence}')
warnings += 1
continue
while status.isactive():
try:
clean = True
attempt += 1
print('[信息] 正在发送:%s (%.2f %%)' % (recipient, current / limit * 100))
click("button[aria-label='Edit copy']")
if (target := locate("h1.subject").text.strip()) != subject:
print(f'[警告] 邮件主题与设定不一致:{target}')
if input('[????] 是否继续?确定 (y) / 取消 (N): ') in ['Y', 'y']:
subject = target
print(f'[信息] 已更新邮件主题设定')
else:
raise Exception('邮件主题意外变更')
ready(driver, lambda x: x.find_element(By.CSS_SELECTOR, ".io-ox-busy"))
locate("div.io-ox-mail-compose-window iframe", condition=EC.frame_to_be_available_and_switch_to_it)
if locale and (registry := locale.get('registry')):
match datetime.now(timezone).hour if timezone else -1:
case hour if 6 <= hour < 12: hello = registry[0]
case hour if 12 <= hour < 18: hello = registry[1]
case hour if 18 <= hour < 21: hello = registry[2]
case _: hello = None
iframe = driver.switch_to.active_element
action = ActionChains(driver)
clean = False
hello = hello or locale.get('default')
if name is not None and (name := str(name).strip()) and not contains_non_latin_alphabet(name):
parts = HumanName(name)
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 items := iframe.find_elements(By.XPATH, f'//*[contains(text(), "{hello}")]'):
target = items[0]
clean = target.text == hello
driver.switch_to.default_content()
wrapper = locate("div.io-ox-mail-compose-window div[data-extension-id='to'] > div.mail-input")
to = locate("input.token-input.tt-input[tabindex='0']", parent=wrapper)
# 填入收件人
click(wrapper)
to.send_keys(recipient + Keys.ENTER)
token = locate("div.io-ox-mail-compose-window .mail-input .tokenfield .token")
target = token.get_attribute('innerText').strip()
if target != recipient:
print(f'[警告] ({attempt}): 收件人地址不一致:{target}')
elif not clean:
print(f'[警告] ({attempt}): 邮件内容不正确')
else:
# 发送邮件
click("div.io-ox-mail-compose-window button[data-action='send']")
# 检测页面警告
try:
wait = WebDriverWait(driver, timeout=args.interval)
alert = wait.until(lambda x: x.find_element(By.CSS_SELECTOR, "div.io-ox-alert.io-ox-alert-error"))
sents[current] = ''
message = alert.text.replace('\n', ' ')
print(f'[警告] ({attempt}): {message or '未知错误'}')
# 关闭警告
click("div.io-ox-alert.io-ox-alert-error button[data-action='close']")
except TimeoutException:
sents[current] = '✔️'
occurrence[0] += 1
sent += 1
break
while mails := driver.find_elements(By.CSS_SELECTOR, "div.io-ox-mail-compose-window"):
try:
click("button[data-action='close']", parent=mails[0])
click("div.modal-footer button[data-action='delete']")
except Exception as e:
print(f"[警告] 关闭邮件时发生了错误:{e}")
break
if attempt < args.retry:
continue
else:
print('[警告] 已超出最大重试上限')
errors += 1
break
except KeyboardInterrupt:
print('[信息] 程序中断')
status = Status.TERMINATED
break
except Exception as e:
print(f'[警告] ({attempt}): 发生错误:{e}')
key = input('[????] 重试 (r) / 跳过 (s) / 取消 (C): ')
if key in ['R', 'r']: continue
elif key in ['S', 's']: break
else: status = Status.INACTIVE
progress = index / limit * 100
print('[信息] 当前进度:%.2f %%' % progress)
if input('[????] 是否保存到文件?确定 (y) / 取消 (N): ') in ['Y', 'y']:
try:
print(f'[信息] 正在写入文件:{filepath}')
pandas.DataFrame.from_dict(data).to_excel(filepath, index=False, sheet_name='Sheet1')
except Exception as e:
print(f'[警告] 写入文件时发生了错误:{e}')
if status.isalive() and input('[????] 继续运行 (c) / 退出程序 (W): ') in ['C', 'c']: continue
else: break
return 0
try:
print('[信息] 程序初始化中...')
app = wx.App(None)
print('[信息] 正在启动 Chrome 自动化实例')
opts = ChromeOptions()
opts.add_experimental_option("excludeSwitches", ["enable-logging"])
driver = Chrome(opts)
driver.set_page_load_timeout(args.timeout)
status = main(driver)
except KeyboardInterrupt:
print('[信息] 程序中断')
status = 145
except Exception as e:
print(f'[!!!!] 致命错误:{e}')
status = 1
finally:
driver.quit()
print(f'[信息] 已发送 {sent} 封;发送失败 {errors} 封;跳过重复项 {warnings}')
print(f'[信息] 总耗时 {str(datetime.now() - date)}')
exit(status)

View File

@@ -1,161 +0,0 @@
<html>
<head>
<script src="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.js"></script>
<link href="https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css" rel="stylesheet">
<link href="http://antifavicon.com/ex8.png" rel="icon">
<title>编辑邮件</title>
<style>
body {
height: 85vh;
}
.container {
display: flex;
flex-direction: column;
padding: 8px;
max-height: fit-content;
}
#subject {
border: 0px;
border-left: 1px solid #CCC;
border-right: 1px solid #CCC;
padding: 10.5px 16px 10.5px 16px;
outline: none;
width: 100%;
font-size: 14px;
line-height: calc(1.5 / 1);
}
input:-webkit-autofill {
-webkit-box-shadow: 0 0 0px 1000px white inset;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="ql-formats">
<!-- Add font size dropdown -->
<select class="ql-font">
</select>
<select class="ql-header">
<option value="1"></option>
<option value="2"></option>
<option value="3"></option>
<option value="4"></option>
<!-- Note a missing, thus falsy value, is used to reset to default -->
<option selected></option>
</select>
</span>
<span class="ql-formats">
<button class="ql-bold"></button>
<button class="ql-italic"></button>
<button class="ql-underline"></button>
<button class="ql-strike"></button>
</span>
<span class="ql-formats">
<button class="ql-script" value="sub"></button>
<button class="ql-script" value="super"></button>
<button class="ql-list" value="ordered"></button>
<button class="ql-list" value="bullet"></button>
</span>
<span class="ql-formats">
<button id="load">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="oklch(0.269 0 0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M17 21h-10a2 2 0 0 1 -2 -2v-14a2 2 0 0 1 2 -2h7l5 5v11a2 2 0 0 1 -2 2z" />
<path d="M12 11l0 6" />
<path d="M9 14l6 0" />
</svg>
</button>
<button id="save">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="oklch(0.269 0 0)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"/>
<path d="M6 4h10l4 4v10a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2v-12a2 2 0 0 1 2 -2" />
<path d="M12 14m-2 0a2 2 0 1 0 4 0a2 2 0 1 0 -4 0" />
<path d="M14 4l0 4l-6 0l0 -4" />
</svg>
</button>
</span>
</div>
<input id="subject" name="subject" type="text" />
<div id="editor"></div>
</body>
<script>
const quill = new Quill('#editor', {
theme: 'snow',
modules: {
toolbar: '#toolbar'
}
});
const loadButton = document.querySelector("#load");
const saveButton = document.querySelector('#save');
const editor = document.querySelector('#editor');
const input = document.querySelector('#subject');
let reader = new FileReader();
let parser = new DOMParser();
let fileHandle = 'desktop';
reader.addEventListener('load', () => {
const contents = reader.result;
const virtual = parser.parseFromString(contents, 'text/html');
const body = virtual.querySelector('body');
const html = body ? body.innerHTML : contents;
const delta = quill.clipboard.convert({ html });
quill.setContents(delta, 'silent');
});
loadButton.addEventListener('click', async () => {
[fileHandle] = await window.showOpenFilePicker({
startIn: fileHandle,
types: [
{ accept: { "text/html": [".html"] }},
],
});
const file = await fileHandle.getFile();
const name = fileHandle.name;
const subject = name.substring(0, name.lastIndexOf('.'));
input.value = subject;
reader.readAsText(file);
});
saveButton.addEventListener('click', async () => {
fileHandle = await window.showSaveFilePicker({
startIn: fileHandle,
suggestedName: input.value,
types: [
{ accept: { "text/html": [".html"] }},
],
});
let writable = await fileHandle.createWritable();
let contents = editor.firstChild.innerHTML;
const name = fileHandle.name;
const subject = name.substring(0, name.lastIndexOf('.'));
input.value = subject;
let html = document.createElement('html');
let body = document.createElement('body');
let meta = document.createElement('meta');
let link = document.createElement('link');
if (!(/<html>[\s\S]*<\/html>/i).test(contents)) {
meta.setAttribute('charset', "UTF-8");
link.setAttribute('href', "https://cdn.jsdelivr.net/npm/quill@2.0.3/dist/quill.snow.css");
link.setAttribute('rel', "stylesheet");
body.setAttribute('class', "ql-container ql-editor ql-snow");
body.innerHTML = contents;
html.appendChild(meta);
html.appendChild(link);
html.appendChild(body);
contents = html.outerHTML;
}
await writable.write(contents);
await writable.close();
});
</script>
</html>