update: added 'common' dependency

This commit is contained in:
2026-05-21 18:03:26 +08:00
parent 61fc828d6b
commit 4943faa84e
4 changed files with 803 additions and 1011 deletions

174
README.md
View File

@@ -1,174 +0,0 @@
# 邮件批量发送脚本
- [参数配置](#参数配置)
- [安装流程](#安装流程)
- [使用说明](#使用说明)
- [可选功能](#可选功能)
## 参数配置
配置批处理文件 (.bat) 时可以选择传入的参数。
留空使用默认设定值。
```
usage: main.py [-h] [--column-address COLUMN_ADDRESS] [--column-name COLUMN_NAME] [--column-code COLUMN_CODE] [--column-pays COLUMN_PAYS]
[--column-sent COLUMN_SENT] [-a ADDRESS] [-p PASSWORD] [-t TIMEOUT] [-i INTERVAL] [-r ATTEMPTS]
[url]
Bulk Email Sending邮件批量发送脚本
positional arguments:
url 邮箱网页端链接
options:
-h, --help 打印帮助信息并退出
--column-address COLUMN_ADDRESS 字段“邮箱”列名称
--column-name COLUMN_NAME 字段“主要联系人”列名称
--column-code COLUMN_CODE 字段“客户编号”列名称
--column-pays COLUMN_PAYS 字段“国家地区”列名称
--column-sent COLUMN_SENT 字段“已发送”列名称
-a, --address ADDRESS 邮箱地址
-p, --password PASSWORD 邮箱密码
-t, --timeout TIMEOUT 超时时长默认值60
-i, --interval INTERVAL 间隔时长默认值10
-r, --attempts ATTEMPTS 允许尝试次数默认值3
```
## 安装流程
> [!NOTE]
> 在执行初始化脚本之前请确保 Python 已经正确安装并添加到 PATH 系统变量中。\
> (在安装向导中勾选 `Add Python to PATH` 选项)
### 系统要求
- Windows 10+
- Python 3.10+
- Git for windows
- Windows Terminal可选
### 安装步骤
> [!TIP]
> 在已经安装了 Windows Terminal 的 Windows 11 环境下,右键桌面(或文件资源管理器窗口),在弹出的菜单中可以找到 `在终端中打开` 的选项,点击可以打开终端。
1. 打开终端,克隆仓库到本地:
```console
git clone https://git.autistic.men/Ultimatron-France/bulk-email-sending-script.git 邮件批量发送
```
2. 进入 `邮件批量发送` 文件夹下的 `profiles` 文件夹,双击运行 `setup-virtualenv` 批处理文件进行环境初始化。
3. 等待初始化完成,终端显示 `Press any key to continue...` 时关闭终端。此时可以删除 `setup-virtualenv` 文件(可选)。
4. 编辑 `profiles` 文件夹下的 `profile-example` 批处理文件,分别将 `--address``--password` 参数对应的值更改为实际使用的邮箱账号密码,保存并退出文本编辑器。这个文件将作为脚本的**启动配置文件**。
5. (可选)将 `profile-example` 重命名为实际使用的邮箱账号名称,方便记忆。
## 使用说明
> [!NOTE]
> 由于脚本在初始化阶段需要连接到 Google 服务,在运行前请确保 VPN 已打开。
### 基本操作步骤
1. 准备需要发送的**邮件**和**客户列表**。客户列表必须包含 `邮箱` 字段(表头名称可自定义),并且以 XLSX 格式保存。
2. 双击运行 `profiles` 文件夹下的[启动配置文件](#操作步骤),等待脚本初始化(初次运行会自动下载必要文件,需要一段时间)。
3. 脚本初始化完成,此时会打开带有两个标签页(第一个是配置页面,第二个是 IONOS 邮箱页面)的新浏览器实例,自动化程序尝试登录到 IONOS 邮件系统。
4. 登录完成,程序自动跳转到配置页面。在配置页面上单击 `Open` 按钮,在弹出的对话框中选择需要发送的客户列表,单击 `打开` 按钮。
5. 客户列表读取完成,程序自动跳转到 IONOS 邮箱页面。单击**草稿箱**按钮,在左侧列表中勾选需要发送的邮件(不支持多选)。
6. 打开配置页面,按需调整参数配置,单击 `Send` 按钮,随后立即打开 IONOS 邮箱页面,自动化程序开始运行。
> [!WARNING]
> 自动化程序运行时请避免最小化程序窗口、直接关闭窗口或在 IONOS 邮箱页面进行任何操作。
> [!NOTE]
> 无论是执行 `Send` 或是 `Resume` 操作,必须及时打开 IONOS 邮箱页面,否则自动化程序可能无法正常工作。
7. 自动化程序运行时,用户可以打开**配置页面**,单击页面上的操作按钮执行[相应操作](#用户界面说明)。
8. 自动化程序运行结束,程序自动跳转到配置页面,在页面上方有弹窗显示已发送数量、警告数和错误数。此时可关闭程序窗口,或进行下一轮发送操作。
### 用户界面说明
<details>
<summary>基本信息 (Basic Information)</summary>
| 显示名称 | 说明 | 备注 |
| --- | --- | --- |
| **File Name** | 文件名称 | |
| **From** | 发件人 | |
| **Progress** | 当前程序进度(已发送数量) | |
| **Uptime** | 程序运行时长 | |
| **Status** | 程序当前状态 | `Idle`, `Ready`, `Running`, `Standby` |
| **Subject** | 邮件主题 | |
| **Recipient** | 收件人 | |
| **Timezone** | 当前时区 | 由 `Locale` 参数控制 |
| **Remaining** | 待发送数量 | |
| **Open** | 打开文件 | 仅在 `Idle` 状态下可见 |
| **Send** | 开始发送 | 仅在 `Ready` 状态下可见 |
| **Pause** | 暂停 | 仅在 `Running` 状态下可见 |
| **Resume** | 恢复 | 仅在 `Standby` 状态下可见 |
| **Skip** | 跳过 | |
| **Cancel** | 取消 | |
</details>
<details>
<summary>基本参数 (Parameters)</summary>
| 显示名称 | 说明 | 备注 |
| --- | --- | --- |
| **Timeout** | 超时时长 | 单位:秒 |
| **Interval** | 间隔时长 | 单位:秒 |
| **Max Occurrence** | 最大允许重复次数 | 由 `Avoid spamming` 选项控制 |
| **Attempts** | 操作最大尝试次数 | |
| **Locale** | 语言和时区 | 仅当 `Greet recipients` 选项启用时有效果 |
| **Greet recipients** | 使用问候语 | |
| **Enable task slicing** | 启用任务切片功能 | |
| **Avoid spamming** | 避免重复发送 | |
| **Save to file** | 保存发送记录 | 默认启用 |
</details>
<details>
<summary>列表字段配置 (Columns)</summary>
| 显示名称 | 说明 | 备注 |
| --- | --- | --- |
| **Recipient Address** | 收件人邮箱地址 | 必填 |
| **Recipient Name** | 收件人名称 | |
| **Reference Code** | 客户编号 | |
| **Country** | 国家地区 | |
| **Remarks** | 发送标记 | |
| **Variables** | 自定义变量 | |
</details>
<details>
<summary>任务切片 (Slicing)</summary>
| 显示名称 | 说明 | 备注 |
| --- | --- | --- |
| **Group By** | 分组依据 | |
| **Chunk Size** | 块大小 | 最小值500 |
| **Offset** | 偏移量 | 最小值0 |
| **Max** | 最大值 | |
| **Subcategory** | 子类目 | 由 `Group By` 选项控制 |
</details>
## 可选功能
### 使用问候语 (Greet Recipients)
### 任务切片 (Task Slicing)
### 避免重复发送 (Avoid Spamming)
### 保存发送记录 (Save to File)
### 列表字段调整

View File

@@ -1,12 +1,7 @@
<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>
<title>Mailer</title>
<form class="pure-form pure-form-stacked pure-u-11-12 pure-u-lg-3-4 pure-u-xl-2-3">
<fieldset>
@@ -50,28 +45,30 @@
</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>
<div id="actions" class="inline-flex">
<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>
</div>
</fieldset>
<br>
<fieldset>
@@ -79,19 +76,19 @@
<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"/>
<input id="timeout" class="pure-u-23-24 params" 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"/>
<input id="interval" class="pure-u-23-24 params" 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/>
<label for="occurrence">Max Occurrence</label>
<input id="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="attempts">Attempts</label>
<input id="attempts" class="pure-u-23-24" type="number" min="1"/>
<input id="attempts" class="pure-u-23-24 params" type="number" min="1"/>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="locale">Locale</label>
@@ -115,77 +112,75 @@
<label for="save" class="pure-checkbox">
<input id="save" type="checkbox" checked/> Save to file
</label>
<label for="logs" class="pure-checkbox">
<input id="logs" class="local-only" type="checkbox"/> Show logs
</label>
<textarea id="messages" name="messages" rows="30" hidden readonly></textarea>
</fieldset>
<br>
<fieldset>
<legend>Columns</legend>
<div class="pure-g">
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_address">Recipient Address</label>
<select id="column_address" class="columns pure-u-23-24" required>
<option value="">&nbsp;</option>
<label for="email">Recipient Address</label>
<select id="email" class="columns pure-u-23-24" required>
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_name">Recipient Name</label>
<select id="column_name" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
<label for="recipient">Recipient Name</label>
<select id="recipient" class="pure-u-23-24 columns">
</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>
<label for="code">Reference Code</label>
<select id="code" class="pure-u-23-24 columns">
</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>
<label for="region">Region</label>
<select id="region" class="pure-u-23-24 columns">
</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>
<label for="sent">Sent</label>
<select id="sent" class="pure-u-23-24 columns">
</select>
</div>
<div class="pure-u-1 pure-u-md-1-3">
<label for="column_vars">Variables</label>
<select id="column_vars" class="columns pure-u-23-24">
<option value="">&nbsp;</option>
<label for="variables">Variables</label>
<select id="variables" class="pure-u-23-24 columns">
</select>
</div>
</div>
</fieldset>
<br>
<fieldset>
<legend>Slicing</legend>
<legend>Advanced</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>
<label for="groupby">Group by</label>
<select id="groupby" class="slicing local-only pure-u-23-24" disabled>
<option value="none">None</option>
<option value="locale">Locale</option>
<option value="country">Country</option>
<option value="region">Region</option>
</select>
<label for="subcategory">Subcategory</label>
<select id="subcategory" class="slicing inactive localonly pure-u-23-24" multiple disabled>
<select id="subcategory" class="slicing inactive local-only 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/>
<label for="chunksize">Chunk Size</label>
<input id="chunksize" class="slicing local-only 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/>
<input id="offset" class="slicing local-only 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/>
<input id="limit" class="slicing local-only pure-u-23-24" type="number" readonly disabled/>
</div>
</div>
</div>
@@ -193,178 +188,157 @@
</fieldset>
</form>
<script type="text/javascript">
dayjs.extend(dayjs_plugin_utc);
dayjs.extend(dayjs_plugin_timezone);
dayjs.extend(dayjs_plugin_duration);
dayjs.extend(dayjs_plugin_relativeTime);
<script type="module">
import { default as $, LogRecord, Rpc2 } from '/';
if (performance.getEntriesByType('navigation')[0].type === 'reload') await Rpc2.invoke('exit');
const Timer = {
setTimestamp: (reset=false) => {
$('#uptimeLabel').dataset.timestamp = dayjs().unix();
if (reset) $('#uptimeLabel').dataset.timedelta = 0;
},
setTimedelta: () => {
$('#uptimeLabel').dataset.timedelta = Timer.getTimedelta().asSeconds();
},
getTimedelta: () => {
let timedelta = dayjs.duration(dayjs().diff(dayjs.unix($('#uptimeLabel').dataset.timestamp)));
let sum = dayjs.duration($('#uptimeLabel').dataset.timedelta||0, 'second').add(timedelta);
return sum;
},
};
let limit = 0;
let length = 0;
let busy = false;
let status = null;
let locale = null;
let unique = null;
let writable = null;
let filereader = null;
let subcategories = [];
const $ = (selectors) => {
return document.querySelector(selectors);
};
let { locales, mapping, parameters } = await Rpc2.invoke('context');
let account = new String(parameters.account);
let name = account.split('@', 1).pop();
name = name.charAt(0).toLocaleUpperCase() + name.slice(1);
document.title += ` (${name})`;
const $$ = (selectors) => {
return document.querySelectorAll(selectors);
};
async function handlePrimaryButtonClick() {
switch (status) {
case 'IDLE':
let PickerOptions = {
types: [
{
description: "Excel Spreadsheet",
accept: {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ['.xlsx']
}
}
]
};
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);
}));
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').innerText = 'Pause';
$('#send > span.icon').removeAttribute('hidden');
$('#send').disabled = true;
break;
case 'RUNNING':
Connection.send('ONHOLD');
$('#send > span.text').innerText = 'Resume';
$('#send > span.icon').setAttribute('hidden', '');
$('#send').disabled = true;
let [file, buffer] = await filereader().catch(() => []);
writable = await handle.createWritable().catch(() => null);
limit = await Rpc2.invoke('ready', file.name, new Uint8Array(buffer).toBase64());
$.get('#fileLabel').innerText = file.name;
$.get('#fromLabel').innerText = parameters.account;
$.all('.columns').forEach(e => e.dispatchEvent(new Event('change')));
break;
case 'READY':
let parameters = {};
let locale = $('#locale').value;
let options = { locale, mapping: new Object()};
for (let element of $$("input[type='number']:not([disabled]):not(.localonly)")) {
parameters[element.id] = element.valueAsNumber;
for (let element of $.all("input[type='number'].params")) {
parameters[element.id] = element.disabled ? null : element.valueAsNumber;
}
for (let element of $$("select:not([disabled]):not(.localonly)")) {
parameters[element.id] = element.value || element.dataset.default;
for (let element of $.all("input[type='number']:not(.local-only):not(.params)")) {
options[element.id] = element.disabled ? null : element.valueAsNumber;
}
for (let element of $$("input[type='checkbox']:not([disabled])")) {
parameters[element.id] = element.checked;
for (let element of $.all("select:not(.local-only).columns")) {
options.mapping[element.id] = element.value;
}
if (parameters.slice && $('#subcategory').dataset.values) {
parameters.subcategory = $('#subcategory').dataset.values.split(',');
for (let element of $.all("select:not(.local-only):not(.columns)")) {
options[element.id] = element.disabled ? null : element.value;
}
for (let element of $$("input:not(.inactive), select:not(.inactive)")) {
element.disabled = true;
for (let element of $.all("input[type='checkbox']:not([disabled]):not(.local-only)")) {
options[element.id] = element.checked;
}
Connection.send(JSON.stringify(parameters));
Status = 'RUNNING';
Timer.setTimestamp(true);
if (options.slice) {
options.start = $('#offset').valueAsNumber * $('#chunksize').valueAsNumber + 2;
options.limit = length;
options.subcategories = subcategories.length > 0 ? subcategories: null;
}
$('#send > span.text').innerText = 'Pause';
$('#send > span.icon').removeAttribute('hidden');
$('#skip').disabled = false;
await Rpc2.invoke('begin', options, parameters);
break;
case 'RUNNING':
await Rpc2.invoke('pause');
break;
case 'STANDBY':
await Rpc2.invoke('resume');
break;
default:
try {
let PickerOptions = {
types: [
{
description: "Excel Spreadsheet",
accept: {
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": ['.xlsx']
}
}
]
};
$('#send > span.icon').removeAttribute('hidden');
let [handle] = await showOpenFilePicker(PickerOptions);
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').innerText = file.name;
$('#send > span.text').innerText = 'Send';
$('#cancel').disabled = false;
$('#send').disabled = true;
} finally {
$('#send > span.icon').setAttribute('hidden', '');
}
}
}
$.set('#send', 'click', async () => {
$('#send > span.icon').removeAttribute('hidden');
$('#send > span.text').innerText = '';
$('#send').classList.remove('pulse');
$('#send').disabled = true;
busy = true;
busy = await handlePrimaryButtonClick().then(() => false, () => false);
});
$$$('#skip', 'click', (e) => {
Connection.send('BYPASS');
if (Status === 'STANDBY') Connection.send('ONHOLD');
$('#skip').disabled = true;
});
$$$('#cancel', 'click', (e) => {
Connection.send('CANCEL');
$.set('#cancel', 'click', async () => {
$('#cancel').disabled = true;
await Rpc2.notify('cancel');
});
$$$('#locale', 'change', (e) => {
$('#group_by').dispatchEvent(new Event('change'));
$('#timezoneLabel').dataset.timezone = Locales.find((x) => x.locale == e.target.value).timezone;
$.set('#skip', 'click', async () => {
$('#skip').disabled = true;
await Rpc2.notify('skip');
});
$$$('#slice', 'change', (e) => {
for (let element of $$('.slicing:not(.inactive)')) {
$.set('#locale', 'change', (e) => {
locale = Array.from(locales).find(o => o.name == e.target.value);
$('#groupby').dispatchEvent(new Event('change'));
});
$.set('#logs', 'change', (e) => {
if (e.target.checked) $('#messages').removeAttribute('hidden');
else $('#messages').setAttribute('hidden', '');
$('#messages').scrollIntoView({ block: 'center' });
$('#messages').scrollTop = $('#messages').scrollHeight;
});
$.set('#slice', 'change', (e) => {
for (let element of $.all('.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;
$.set('#nospam', 'change', (e) => {
$('#occurrence').classList.toggle('inactive');
$('#occurrence').disabled = !e.target.checked;
$('#code').required = e.target.checked;
});
$$$('#column_pays', 'change', (e) => {
$('#group_by').dispatchEvent(new Event('change'));
$.set('#region', 'change', async (e) => {
unique = await Rpc2.invoke('unique', e.target.value).catch(() => new Object());
$('#groupby').dispatchEvent(new Event('change'));
});
$$$('#group_by', 'change', (e) => {
let columns = Columns ? Columns[$('#column_pays').value] : null;
let countries = Object.assign({}, columns);
let results = {};
$.set('#groupby', 'change', (e) => {
let countries = Object.assign({}, unique);
let results = new Object();
switch (e.target.value) {
case 'none':
case 'country':
results = countries;
case 'region':
Object.assign(results, countries);
break;
case 'locale':
let locale = $('#locale').value;
let targets = Locales.toReversed();
for (let target of targets) {
for (let target of locales.toReversed()) {
for (let key of Object.keys(countries)) {
if (target.predicate.length == 0 || target.predicate.includes(key)) {
if (target.locale === locale) {
if (target.keywords.length == 0 || target.keywords.includes(key)) {
if (target.name === locale.name) {
results[key] = countries[key];
}
delete countries[key];
@@ -379,7 +353,7 @@ $$$('#group_by', 'change', (e) => {
$('#subcategory').classList.add('inactive');
$('#subcategory').disabled = true;
break;
case 'country':
case 'region':
case 'locale':
$('#subcategory').classList.remove('inactive');
$('#subcategory').disabled = false;
@@ -389,172 +363,118 @@ $$$('#group_by', 'change', (e) => {
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'));
$.set('#subcategory', 'change', (e) => {
if (!unique) return;
subcategories = Array.from(e.target.options).filter(o => o.selected).map(o => o.value);
length = Object.entries(unique).filter(([k]) => subcategories.includes(k)).reduce((o, [_, v]) => o + v, 0);
$('#chunksize').dispatchEvent(new Event('change'));
});
$$$('#chunk_size', 'change', (e) => {
let limit = Number($('#subcategory').dataset.limit) || Limit;
let size = Math.floor(limit / e.target.value);
$.set('#chunksize', 'change', (e) => {
let n = length || limit;
let size = Math.floor(n / e.target.valueAsNumber);
$('#limit').value = size;
$('#offset').max = size;
$('#offset').value = 0;
$('#offset').dispatchEvent(new Event('change'));
});
$$$('#offset', 'change', (e) => {
let limit = Number($('#subcategory').dataset.limit) || Limit;
let size = Number($('#chunk_size').value) || 0;
if ($('#slice').checked) {
limit = Math.min(size, limit - size * Number(e.target.value));
}
e.target.dataset.limit = limit;
$.set('#offset', 'change', (e) => {
let n = length || limit;
let size = $('#chunksize').valueAsNumber;
let offset = n - size * e.target.valueAsNumber;
length = Math.min(size, offset);
});
function main(url, parameters, locales) {
Connection = new WebSocket(url);
Locales = JSON.parse(locales);
for (let item of Array.from(locales)) {
$('#locale').add(new Option(item.name.toUpperCase(), item.name));
$('#locale').dispatchEvent(new Event('change'));
}
for (let [key, value] of Object.entries(parameters)) {
$(`input#${key}`)?.setAttribute('value', value);
for (let element of $.all('.columns')) {
for (let [key, value] of Object.entries(mapping)) {
element.options.add(new Option(value, value, false, element.id === key));
}
}
for (let element of $.all('.params')) {
element.value = parameters[element.id];
}
while (await new Promise(o => setTimeout(o, 1000, true))) {
let history = await Rpc2.invoke('history').catch(() => []);
let logs = Array.from(history);
for (let record of logs) {
if ($('#messages').childNodes.length >= 500) $('#messages').childNodes.item(0)?.remove();
if (record.levelno >= 40) alert(`(${record.levelname}) ` + [record.msg, record.exc_text].filter(Boolean).join('\n'));
let message = LogRecord.format(record);
let node = document.createTextNode(new String(message).concat('\n'));
$('#messages').appendChild(node);
$('#messages').scrollTop = $('#messages').scrollHeight;
}
for (let item of Locales) {
$('#locale').add(new Option(item.locale.toUpperCase(), item.locale));
$('#locale').dispatchEvent(new Event('change'))
if (busy) continue;
status = await Rpc2.invoke('status').catch(() => null);
$('#statusLabel').innerText = status.charAt(0).toUpperCase() + status.slice(1).toLowerCase();
switch (status) {
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';
$('#send').classList.remove('pulse');
$('#progressLabel').innerText = '';
$('#remainingLabel').innerText = '';
break;
case 'CLOSING':
try {
if (writable === null) break;
let data = await filereader().then(([_, b]) => Rpc2.invoke('merge', new Uint8Array(b).toBase64()));
await writable.write(Uint8Array.fromBase64(data));
await writable.close();
await Rpc2.notify('close');
} catch (e) {
alert('(ERROR) ' + String(e ?? 'File operation failed'));
await writable.abort().catch(() => void 0);
}
writable = null;
case 'RUNNING':
$('#send > span.text').innerText = 'Pause';
$('#send').classList.add('pulse');
$('#timezoneLabel').innerText = Temporal.Now.zonedDateTimeISO(locale.timezone).toLocaleString(locale.name);
let { done, email, subject, recipient } = await Rpc2.invoke('progress').catch(() => new Object());
$('#progressLabel').innerText = `${parseFloat(done / (length || limit) * 100).toFixed(2)}% (${done}/${length || limit})`
$('#subjectLabel').innerText = subject ?? '';
$('#recipientLabel').innerText = recipient ? `${recipient} <${email}>` : email ?? '';
let uptime = await Rpc2.invoke('uptime').catch(() => 0);
$('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: uptime }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' });
let rate = done / uptime
let remaining = done ? Math.floor(((length || limit) - done) / rate) : 0;
$('#remainingLabel').innerText = remaining ? `${Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en')} (${rate.toFixed(2)}/min)` : '';
break;
case 'STANDBY':
$('#send > span.text').innerText = 'Resume';
$('#send').classList.remove('pulse');
break;
}
$('#send > span.icon').setAttribute('hidden', '');
$('#send').disabled = false;
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').innerText = 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 [index, name, recipient, sent, warnings, errors, attempt] = args;
let label = name ? `${name}<${recipient}>` : recipient;
$('#recipientLabel').innerText = label;
$('#progressLabel').dataset.sent = sent;
$('#progressLabel').dataset.index = index;
$('#progressLabel').dataset.errors = errors;
$('#progressLabel').dataset.attempt = attempt;
$('#progressLabel').dataset.warnings = warnings;
break;
case 'setStatus':
switch (args[0]) {
case 'RESUME':
Timer.setTimestamp();
case 'BYPASS':
Status = 'RUNNING';
$('#send').disabled = false;
$('#skip').disabled = false;
break;
case 'ONHOLD':
Status = 'STANDBY';
$('#send').disabled = false;
Timer.setTimedelta();
break;
case 'FAILED':
Status = 'STANDBY';
$('#send > span.text').innerText = 'Resume';
$('#send > span.icon').setAttribute('hidden', '');
Timer.setTimedelta();
break;
case 'FINISH':
let {sent, warnings, errors} = $('#progressLabel').dataset;
alert(`${sent||0} Sent; ${warnings||0} Warning(s); ${errors||0} Error(s)`);
case 'CANCEL':
Status = null;
$('#send > span.text').innerText = 'Open';
$('#send > span.icon').setAttribute('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;
}
});
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').innerText = `${date} (${city})`;
$('#statusLabel').innerText = Status ? Status.charAt(0).toUpperCase() + Status.slice(1).toLowerCase() : 'Idle';
if (Status === 'RUNNING') {
let index = Number($('#progressLabel').dataset.index) || 0;
let limit = Number($('#offset').dataset.limit) || 0;
let percentage = parseFloat((index / limit * 100).toFixed(2));
$('#progressLabel').innerText = `${index} / ${limit} (${percentage} %)`;
let uptime = Timer.getTimedelta();
$('#uptimeLabel').innerText = uptime.format("HH:mm:ss");
let rate = Number($('#progressLabel').dataset.sent) / uptime.asSeconds();
let spm = parseFloat(Number(rate*60).toFixed(2));
$('#remainingLabel').innerHTML = rate > 0 ? `<span>${dayjs.duration((limit - index) / rate, 'second').humanize()}</span>` : '';
$('#remainingLabel').innerHTML += spm >= 1 ? `<span class="${spm > 8.33 ? 'rate warning' : 'rate'}">${spm} per minute</span>` : '';
}
}, 500);
let actions = await Rpc2.invoke('actions').catch(() => new Object());
$('#cancel').disabled = !actions['Cancel'];
$('#skip').disabled = !actions['Skip'];
}
</script>
@@ -570,15 +490,33 @@ form {
padding: 0 1em 0 1em;
}
select, option, input[type='checkbox'] {
cursor: pointer;
}
label {
user-select: none;
width: fit-content;
}
.rate:not(:first-child)::before {
padding: 0em 0.4em;
content: '';
color: #666;
#actions {
max-height: 2.5em;
column-gap: 0.4em;
}
#actions > button {
height: stretch;
}
#messages {
resize: none;
white-space: pre-wrap;
margin-top: 24px;
width: 100%;
}
#mesages:focus {
outline: none;
}
.inline-flex {
@@ -592,10 +530,6 @@ label {
white-space: nowrap;
}
.warning {
color: orangered;
}
.gaps {
row-gap: 0.4em;
}
@@ -613,9 +547,19 @@ label {
animation: spin 1s linear infinite;
}
.pulse {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>
@keyframes pulse {
50% {
opacity: 0.6;
}
}
</style>

1002
main.py

File diff suppressed because it is too large Load Diff

Binary file not shown.