Compare commits

...

11 Commits

Author SHA1 Message Date
abae0a5fdb fix: 'common' version 2026-06-03 17:23:18 +08:00
a3e3c2a8fa fix: input cursor pointer 2026-05-27 17:48:54 +08:00
7cb59b5122 update: bump 'common' version to 0.1.15 2026-05-26 11:59:46 +08:00
b3fadc99f3 minor fix 2026-05-08 15:58:15 +08:00
c693d624fc minor fix 2026-05-08 15:28:12 +08:00
760b88faee minor fix 2026-05-08 15:10:16 +08:00
acb5607e2c minor fix 2026-05-08 14:26:17 +08:00
3007993ad2 minor fix 2026-05-08 10:35:47 +08:00
e578c31c28 minor fix 2026-05-08 10:18:12 +08:00
28a4252852 minor fix 2026-05-08 10:11:45 +08:00
eb6df01c06 fix: handling invoice pagination 2026-05-05 19:16:41 +08:00
4 changed files with 329 additions and 298 deletions

View File

@@ -128,46 +128,39 @@
</fieldset> </fieldset>
</form> </form>
<script type="text/javascript"> <script type="module">
const $ = (selectors, fn) => { import { default as $, Rpc2, LogRecord } from '/';
let e = document.querySelector(selectors); if (performance.getEntriesByType('navigation')[0].type === 'reload') await Rpc2.invoke('exit');
return fn && e ? fn(e) : e;
};
const $$ = (selectors, fn) => { let error = null;
let e = document.querySelectorAll(selectors); let status = null;
return fn && e ? fn(e) : e; let { profiles, parameters } = await Rpc2.invoke('context');
};
const $$$ = (selectors, event, listener) => { let date = new Date();
$(selectors).addEventListener(event, listener); let day = date.getDay() || 7;
}; $('#datefrom').valueAsNumber = date.setHours(-24 * (day - 1)) - date.getTimezoneOffset() * 60 * 1000;
$('#dateto').valueAsNumber = date.setHours(24 * 7) + date.getTimezoneOffset() * 60 * 1000;
$('#all').dispatchEvent(new Event('change'));
let Profiles = new Array(); $.set('#begin', 'click', async () => {
let Latest = null; switch (status) {
let Status = null;
function main(profiles, args) {
$$$('#begin', 'click', async () => {
switch (Status) {
case 'READY': case 'READY':
let name = $('#name').value; let name = $('#name').value;
let options = new Map(); let options = { profile: name };
for (let element of $$("input[type='checkbox']:not(.local-only)")) { for (let element of $.all("input[type='checkbox']:not(.local-only)")) {
options[element.id] = element.checked; options[element.id] = element.checked;
} }
for (let element of $$("input[type='date']")) { for (let element of $.all("input[type='date']")) {
options[element.id] = element.value; options[element.id] = element.value;
} }
for (let element of $$("input[type='number']")) { for (let element of $.all("input[type='number']")) {
args[element.id] = element.valueAsNumber; parameters[element.id] = element.valueAsNumber;
} }
options['profile'] = name; await Rpc2.invoke('begin', options, parameters);
await Rpc2.invoke('begin', options, args);
break; break;
case 'RUNNING': case 'RUNNING':
await Rpc2.invoke('pause'); await Rpc2.invoke('pause');
@@ -176,78 +169,74 @@ function main(profiles, args) {
await Rpc2.invoke('resume'); await Rpc2.invoke('resume');
break; break;
} }
}); });
$$$('#begin', 'click', (e) => { $.set('#begin', 'click', () => {
$('#begin > span.icon').removeAttribute('hidden'); $('#begin > span.icon').removeAttribute('hidden');
$('#begin > span.text').innerText = ''; $('#begin > span.text').innerText = '';
$('#begin').classList.remove('pulse'); $('#begin').classList.remove('pulse');
$('#begin').disabled = true; $('#begin').disabled = true;
}); });
$$$('#cancel', 'click', async (e) => { $.set('#cancel', 'click', async () => {
$('#cancel').disabled = true; $('#cancel').disabled = true;
await Rpc2.invoke('cancel'); await Rpc2.invoke('cancel');
}); });
$$$('#skip', 'click', async (e) => { $.set('#skip', 'click', async () => {
$('#skip').disabled = true; $('#skip').disabled = true;
await Rpcs.invoke('skip'); await Rpcs.invoke('skip');
}); });
$$$('#logs', 'change', (e) => { $.set('#logs', 'change', (e) => {
if (e.target.checked) $('#messages').removeAttribute('hidden'); if (e.target.checked) $('#messages').removeAttribute('hidden');
else $('#messages').setAttribute('hidden', ''); else $('#messages').setAttribute('hidden', '');
}); });
$$$('#name', 'change', (e) => { $.set('#name', 'change', (e) => {
let p = Profiles.find(o => o.name === e.target.value); let p = profiles.find(o => o.name === e.target.value);
for (let k of Object.keys(p)) $(`#${k}`, e => e.value = p[k] ?? ''); for (let k of Object.keys(p)) $(`#${k}`)?.setAttribute('value', p[k] ?? '');
}); });
$$$('#all', 'change', (e) => { $.set('#all', 'change', (e) => {
$('#name').disabled = e.target.checked; $('#name').disabled = e.target.checked;
}); });
for (let item of JSON.parse(profiles)) { if (parameters['account'] && parameters['password']) {
Profiles.push(item); let account = new String(parameters['account']);
$('#name').add(new Option(item.name, item.name)); let name = account.split('@', 1).pop();
$('#name').dispatchEvent(new Event('change'));
}
for (let item of $$("input[type='number']")) {
item.value = args[item.id];
}
let date = new Date();
let day = date.getDay() || 7;
$('#datefrom').valueAsNumber = date.setHours(-24 * (day - 1)) - date.getTimezoneOffset() * 60 * 1000;
$('#dateto').valueAsNumber = date.setHours(24 * 7) + date.getTimezoneOffset() * 60 * 1000;
$('#all').dispatchEvent(new Event('change'));
let account = new String(args['account']);
let name = account.split('@', 1).pop() ?? 'unknown';
name = name.charAt(0).toLocaleUpperCase() + name.slice(1); name = name.charAt(0).toLocaleUpperCase() + name.slice(1);
document.title += ` (${name})`; document.title += ` (${name})`;
}
setInterval(async () => { for (let item of profiles) {
let history = await Rpc2.invoke('history'); $('#name').add(new Option(item.name, item.name));
$('#name').dispatchEvent(new Event('change'));
}
for (let item of $.all("input[type='number']")) {
item.value = parameters[item.id];
}
while (await new Promise(o => setTimeout(o, 1000, true))) {
let history = await Rpc2.invoke('history').catch(() => []);
let logs = Array.from(history); let logs = Array.from(history);
for (let record of logs) { for (let record of logs) {
if (record.levelno >= 40) Latest = record; if ($('#messages').childNodes.length >= 500) $('#messages').childNodes.item(0)?.remove();
if (record.levelno >= 40) error = record;
let message = LogRecord.format(record); let message = LogRecord.format(record);
let node = document.createTextNode(new String(message).concat('\n')); let node = document.createTextNode(new String(message).concat('\n'));
$('#messages').appendChild(node); $('#messages').appendChild(node);
$('#messages').scrollTop = $('#messages').scrollHeight; $('#messages').scrollTop = $('#messages').scrollHeight;
} }
Status = await Rpc2.invoke('status'); status = await Rpc2.invoke('status').catch(() => null);
$('#statusLabel').innerText = Status.charAt(0).toUpperCase() + Status.slice(1).toLowerCase(); $('#statusLabel').innerText = status ? status.charAt(0).toUpperCase() + status.slice(1).toLowerCase() : '';
switch (Status) { switch (status) {
case 'IDLE': case 'IDLE':
return; continue;
case 'READY': case 'READY':
$('#begin > span.text').innerText = 'Begin'; $('#begin > span.text').innerText = 'Begin';
$('#begin').classList.remove('pulse'); $('#begin').classList.remove('pulse');
@@ -258,24 +247,21 @@ function main(profiles, args) {
$('#begin > span.text').innerText = 'Pause'; $('#begin > span.text').innerText = 'Pause';
$('#begin').classList.add('pulse'); $('#begin').classList.add('pulse');
let progress = await Rpc2.invoke('progress'); let progress = await Rpc2.invoke('progress').catch(() => new Object());
let { task, number, index, limit } = progress; let { task, number, index, limit } = progress;
$('#numberLabel').innerText = number ?? ''; $('#numberLabel').innerText = number ?? '';
$('#progressLabel').innerText = limit ? `${task}, ${parseFloat((index / limit * 100).toFixed(2))}% (${index}/${limit})` : task; $('#progressLabel').innerText = limit ? `${task}, ${parseFloat((index / limit * 100).toFixed(2))}% (${index}/${limit})` : task;
let uptime = await Rpc2.invoke('uptime'); let [t1, t2] = await Rpc2.invoke('uptime').catch(() => []);
$('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: uptime }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' }); $('#uptimeLabel').innerText = Temporal.Duration.from({ seconds: t1 ?? 0 }).round({ largestUnit: 'hours' }).toLocaleString('en', { style: 'digital' });
if (index && limit && uptime) { let remaining = index && limit && t2 ? Math.floor((limit - index) / (index / t2)) : 0;
let rate = index / uptime; $('#remainingLabel').innerHTML = remaining ? Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en') : '';
let remaining = Math.floor((limit - index) / rate);
$('#remainingLabel').innerHTML = Temporal.Duration.from({ seconds: remaining }).round({ largestUnit: 'hours' }).toLocaleString('en');
}
break; break;
case 'STANDBY': case 'STANDBY':
if (Latest !== null) { if (error !== null) {
alert(`(${Latest.levelname}) ${Latest.msg}\n${Latest.exc_text ?? ''}`); alert(`(${error.levelname}) ${error.msg}\n${error.exc_text ?? ''}`);
Latest = null; error = null;
} }
$('#begin > span.text').innerText = 'Resume'; $('#begin > span.text').innerText = 'Resume';
$('#begin').classList.remove('pulse'); $('#begin').classList.remove('pulse');
@@ -284,10 +270,9 @@ function main(profiles, args) {
$('#begin > span.icon').setAttribute('hidden', ''); $('#begin > span.icon').setAttribute('hidden', '');
$('#begin').disabled = false; $('#begin').disabled = false;
let actions = await Rpc2.invoke('actions'); let actions = await Rpc2.invoke('actions').catch(() => new Object());
$('#cancel').disabled = !actions['Cancel']; $('#cancel').disabled = !actions['Cancel'];
$('#skip').disabled = !actions['Skip']; $('#skip').disabled = !actions['Skip'];
}, 1000);
} }
</script> </script>
@@ -299,6 +284,10 @@ body {
justify-content: center; justify-content: center;
} }
select, option, input[type='checkbox'], input[type='date'] {
cursor: pointer;
}
label { label {
user-select: none; user-select: none;
width: fit-content; width: fit-content;

342
main.py
View File

@@ -11,8 +11,9 @@ from selenium.common.exceptions import TimeoutException, NoSuchElementException
from selenium.webdriver.common.by import By from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys from selenium.webdriver.common.keys import Keys
from common import jsonrpc2
from common.utils import * from common.utils import *
from common.timer import Timer
from common.jsonrpc2 import ServiceProvider
from common.actionflow import Action, ActionFlow from common.actionflow import Action, ActionFlow
from enum import Enum from enum import Enum
@@ -37,8 +38,9 @@ WEBURL = "https://crm.xiaoman.cn/%s"
APIURL = "https://%s.vosfactures.fr" APIURL = "https://%s.vosfactures.fr"
def main(driver: WebDriver, logger = logging.getLogger('main')): def main(driver: WebDriver, logger = logging.getLogger('main')):
http = PoolManager()
parameters = vars(args) parameters = vars(args)
http = PoolManager()
sp = ServiceProvider.default()
class Status(Enum): class Status(Enum):
IDLE = 0 IDLE = 0
@@ -60,68 +62,67 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
result = ''.join(filter(bool, [self.prefix, number, self.suffix])) result = ''.join(filter(bool, [self.prefix, number, self.suffix]))
return result return result
timer = Timer() t1 = Timer()
t2 = Timer()
options = dict() options = dict()
profiles: list[Profile] = list()
status = Status.IDLE status = Status.IDLE
def begin(opts: dict, args: dict): def begin(opts: dict, args: dict):
nonlocal options, status nonlocal status
options.update(opts) options.update(opts)
status = Status.RUNNING status = Status.RUNNING
parameters.update(args) parameters.update(args)
timer.clear() t1.clear()
timer.start() t1.start()
def pause(): def pause():
nonlocal status nonlocal status
status = Status.STANDBY status = Status.STANDBY
timer.pause() t1.pause()
t2.pause()
def resume(): def resume():
nonlocal status nonlocal status
status = Status.RUNNING status = Status.RUNNING
driver.switch_to.window(driver.current_window_handle) driver.switch_to.window(driver.current_window_handle)
timer.start() t1.start()
t2.start()
jsonrpc2.define('begin', begin) sp.set('begin', begin)
jsonrpc2.define('pause', pause) sp.set('pause', pause)
jsonrpc2.define('resume', resume) sp.set('resume', resume)
jsonrpc2.define('status', lambda: status.name) sp.set('status', lambda: status.name)
jsonrpc2.define('uptime', lambda: timer.delta()) sp.set('uptime', lambda: [t1.delta()])
try: try:
for source, profile in zip(parameters.get('profile'), repeat(dict())): profiles = [
for key, value in map(lambda o: str.split(o, '=', 2), source): Profile(**{ k.lower().strip(): v.strip() for k, v in map(lambda o: str.split(o, '=', 2), p) })
profile[key.lower().strip()] = value.strip() for p in parameters['profile']
]
item = Profile(**profile)
profiles.append(item)
except Exception as e: except Exception as e:
logger.critical('Unable to load profiles', exc_info=e) logger.critical('Unable to load profiles', exc_info=e)
return 2 return 2
try: try:
manifest = json.dumps(profiles, default=lambda o: vars(o)) sp.set('context', lambda: { 'profiles': list(map(vars, profiles)), 'parameters': parameters })
driver.get(str(Path('index.html').resolve())) driver.get(sp.run())
driver.execute_script(jsonrpc2.prelude(), manifest, parameters)
except Exception as e: except Exception as e:
logger.critical('Unable to load starup page', exc_info=e) logger.critical('Unable to load starup page', exc_info=e)
return 3 return 3
try: try:
driver.switch_to.new_window('tab') driver.switch_to.new_window('tab')
driver.set_page_load_timeout(parameters.get('timeout')) driver.set_page_load_timeout(parameters['timeout'])
driver.get(WEBURL % 'product') driver.get(WEBURL % 'product')
except TimeoutException: except TimeoutException:
logger.warning('Timeout') logger.warning('Timeout')
driver.execute_script("window.stop();") driver.execute_script("window.stop();")
setup(driver, parameters.get('attempts'), parameters.get('timeout'), parameters.get('interval')) setup(driver, parameters)
until(lambda x: 'loginProgress' in x.find_element(By.TAG_NAME, "body").get_attribute('class'), watch=False) until(lambda x: 'loginProgress' in x.find_element(By.TAG_NAME, "body").get_attribute('class'), watch=False)
logger.info('Waiting for authentication to complete...') logger.info('Waiting for authentication to complete...')
if (account := parameters.get('account')) and (password := parameters.get('password')): if (account := parameters['account']) and (password := parameters['password']):
try: try:
logger.info('Logging in as %s (%s)', str.split(account, '@', 1).pop(0).capitalize(), account) logger.info('Logging in as %s (%s)', str.split(account, '@', 1).pop(0).capitalize(), account)
locate("input.account").send_keys(account) locate("input.account").send_keys(account)
@@ -138,7 +139,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Done') logger.info('Done')
break break
except: except:
sleep(parameters.get('interval')) sleep(parameters['interval'])
class ProductInfo: class ProductInfo:
def __init__(self, file): def __init__(self, file):
@@ -165,7 +166,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Downloading product list...') logger.info('Downloading product list...')
click("header .okki-space .okki-space-item:nth-child(1) button") click("header .okki-space .okki-space-item:nth-child(1) button")
click(".okki-dropdown button") click(".okki-dropdown button")
sleep(parameters.get('interval')) sleep(parameters['interval'])
click(".okki-modal.product-export-wrap .mm-selector-rendered") click(".okki-modal.product-export-wrap .mm-selector-rendered")
click(".mm-outside.ui-field-selector-popper .selector-area:nth-child(1) button") click(".mm-outside.ui-field-selector-popper .selector-area:nth-child(1) button")
click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary") click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary")
@@ -173,14 +174,14 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
while True: while True:
try: try:
click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary") click(".okki-modal.product-export-wrap .okki-modal-footer button.okki-btn-primary")
sleep(parameters.get('interval')) sleep(parameters['interval'])
click(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) button.okki-btn-link", wait=False) click(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) button.okki-btn-link", wait=False)
filename = locate(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) > div > div:nth-child(1) span").get_attribute('title') filename = locate(".okki-modal.product-export-wrap .okki-modal-body .virtual-list-wrap .vue-recycle-scroller__item-wrapper > div:nth-child(1) > div > div:nth-child(1) span").get_attribute('title')
break break
except: except:
sleep(parameters.get('interval')) sleep(parameters['interval'])
file = Path(parameters.get('directory')).joinpath(filename) file = Path(parameters['directory']).joinpath(filename)
until(lambda _: file.exists(), watch=False) until(lambda _: file.exists(), watch=False)
p = ProductInfo(file) p = ProductInfo(file)
driver.close() driver.close()
@@ -191,30 +192,16 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.critical('Unable to load products', exc_info=e) logger.critical('Unable to load products', exc_info=e)
return 4 return 4
def wait(seconds: float): def fetch(url: str, method = 'GET', retry = parameters['attempts']):
while not sleep(seconds):
if status == Status.RUNNING:
break
def ready(driver: WebDriver):
html = driver.find_element(By.TAG_NAME, 'html')
return 'nprogress-busy' in html.get_attribute('class')
def fetch(url: str, method = 'GET', retry = parameters.get('attempts')):
for attempt in range(1, retry + 1): for attempt in range(1, retry + 1):
try: try:
response = http.request(method, url) response = http.request(method, url)
result = response.json() result = response.json()
return result return result
except Exception as e: except Exception as e:
logger.error('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e) logger.warning('Error while fetching data from %s, retrying... (%d)', url, attempt, exc_info=e)
assert attempt < retry assert attempt < retry
flow = ActionFlow()
profile = None
progress = { 'task': '' }
selection = 0
class Wait(Action): class Wait(Action):
@classmethod @classmethod
def prepare(cls): def prepare(cls):
@@ -222,8 +209,18 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
@classmethod @classmethod
def perform(cls): def perform(cls):
flow.do(cls) if status == Status.RUNNING: return False
wait(1) sleep(0.2); return True
class Sleep(Action):
@classmethod
def prepare(cls):
return True
@classmethod
def perform(cls):
sleep(parameters['interval'])
return False
class Cancel(Action): class Cancel(Action):
@classmethod @classmethod
@@ -232,39 +229,55 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
status = Status.RUNNING status = Status.RUNNING
return True return True
@classmethod
def perform(cls):
nonlocal status
status = Status.READY
driver.switch_to.window(driver.window_handles[0])
raise cls
class Skip(Action): class Skip(Action):
@classmethod @classmethod
def prepare(cls): def prepare(cls):
return True return True
flow.append(Wait) @classmethod
flow.allow(Wait) def perform(cls):
flow.do(Wait) driver.switch_to.window(driver.current_window_handle)
raise cls
flow = ActionFlow()
flow.append(Wait)
flow.append(Sleep)
flow.append(Cancel) flow.append(Cancel)
flow.append(Skip) flow.append(Skip)
jsonrpc2.define('actions', lambda: flow.capabilities()) profile = None
jsonrpc2.define('cancel', lambda: flow.do(Cancel)) progress = { 'task': '' }
jsonrpc2.define('skip', lambda: flow.do(Skip)) selection = 0
jsonrpc2.define('progress', lambda: progress)
while not wait(1): sp.set('actions', lambda: flow.capabilities())
sp.set('cancel', lambda: flow.do(Cancel, force=True))
sp.set('skip', lambda: flow.do(Skip, force=True))
sp.set('progress', lambda: progress)
while True:
try: try:
progress.clear() flow.allow(Wait)
progress['task'] = 'Task 1 of 4' flow.allow(Sleep)
flow.allow(Cancel, False) flow.do(Wait)
flow.allow(Skip, False) flow.react()
for i in range(len(driver.window_handles), 1, -1):
driver.switch_to.window(driver.window_handles[i-1])
driver.close()
driver.switch_to.window(driver.window_handles[i-2])
if options.get('all'): if options.get('all'):
profile = profiles[selection] profile = profiles[selection]
selection += 1
else: else:
name = options.pop('profile') name = options.pop('profile')
profile = next(filter(lambda o: o.name == name, profiles)) profile = next(filter(lambda o: o.name == name, profiles))
for i in range(len(driver.window_handles), 0):
driver.close()
driver.switch_to.window(driver.window_handles[i])
except (IndexError, KeyError): except (IndexError, KeyError):
logger.info('Done') logger.info('Done')
status = Status.READY status = Status.READY
@@ -273,6 +286,17 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.error("Invalid profile '%s'", name) logger.error("Invalid profile '%s'", name)
status = Status.STANDBY status = Status.STANDBY
continue continue
except Exception as e:
logger.error('Unexpected error', exc_info=e)
status = Status.STANDBY
continue
progress.clear()
progress['task'] = 'Task 1 of 4'
t2.clear()
t2.start()
sp.pop('uptime')
sp.set('uptime', lambda: [t1.delta(), t2.delta()])
base = APIURL % profile.subdomain base = APIURL % profile.subdomain
data = list() data = list()
@@ -284,6 +308,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Profile selected: %s', profile.name) logger.info('Profile selected: %s', profile.name)
logger.info('Date from %s to %s', df, dt) logger.info('Date from %s to %s', df, dt)
flow.allow(Cancel) flow.allow(Cancel)
flow.allow(Skip, False)
for page in count(1): for page in count(1):
try: try:
@@ -294,25 +319,27 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Downloading invoices (%d)', page) logger.info('Downloading invoices (%d)', page)
data.extend(result) data.extend(result)
flow.do(Wait)
flow.do(Sleep)
flow.react() flow.react()
wait(parameters.get('interval'))
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.error('Error while fetching data from %s', base, exc_info=e) logger.error('Error while fetching data from %s', base, exc_info=e)
break break
if len(data) == 0: if len(data) == 0:
logger.warning('Server returned empty response') logger.warning('Server returned an empty response')
selection += 1
continue continue
logger.info('Initializing Workbook...') logger.info('Initializing Workbook...')
progress['task'] = 'Task 2 of 4' progress['task'] = 'Task 2 of 4'
progress['limit'] = len(data) progress['limit'] = len(data)
progress['index'] = 0
t2.clear()
t2.start()
workbook = openpyxl.Workbook() workbook = openpyxl.Workbook()
sheet = workbook.active sheet = workbook.active
@@ -358,21 +385,26 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
try: try:
for i, item in enumerate(data, 1): for i, item in enumerate(data, 1):
flow.do(Wait)
flow.react()
number: str = item['number'] number: str = item['number']
logger.info('[%d/%d] Preprocessing data for %s', i, len(data), number) logger.info('[%d/%d] Preprocessing data for %s', i, len(data), number)
progress['number'] = number progress['number'] = number
progress['index'] = i-1 progress['index'] = i-1
if (id := item['category_id']) not in categories: if (category := categories.get(o := item['category_id'])) is None:
flow.react() if 'error' in (category := fetch(f'{base}/categories/{o}.json?api_token={profile.token}')):
categories[id] = fetch(f'{base}/categories/{id}.json?api_token={profile.token}') error = category['error']
code = category['code']
logger.warning("Error while fetching 'category' (code: %s, message: %s); skipping", code, error)
continue
if (id := item['client_id']) not in clients: if (client := clients.get(o := item['client_id'])) is None:
flow.react() if 'error' in (client := fetch(f'{base}/clients/{o}.json?api_token={profile.token}')):
clients[id] = fetch(f'{base}/clients/{id}.json?api_token={profile.token}') error = client['error']
code = client['code']
category = categories.get(item['category_id']) logger.warning("Error while fetching 'client' (code: %s, message: %s); skipping", code, error)
client = clients.get(item['client_id']) continue
identity = client['external_id'] if client['company'] else profile.person identity = client['external_id'] if client['company'] else profile.person
date: str = item['issue_date'] date: str = item['issue_date']
@@ -417,11 +449,9 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
if record[9] is None: logger.warning("Could not identify product '%s'", product) if record[9] is None: logger.warning("Could not identify product '%s'", product)
sheet.append(record.data) sheet.append(record.data)
flow.react()
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
continue continue
except Exception as e: except Exception as e:
logger.error('Error while processing data', exc_info=e) logger.error('Error while processing data', exc_info=e)
@@ -430,14 +460,14 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
try: try:
filename = f'Order-Import-{profile.name}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.xlsx' filename = f'Order-Import-{profile.name}-{datetime.now().strftime('%Y%m%d-%H%M%S-%f')}.xlsx'
file = Path(parameters.get('directory')).joinpath(filename) file = Path(parameters['directory']).joinpath(filename)
logger.info('Saving excel file to %s', str(file)) logger.info('Saving excel file to %s', str(file))
flow.do(Wait)
flow.react() flow.react()
workbook.save(file) workbook.save(file)
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
continue continue
except Exception as e: except Exception as e:
logger.error('Error while saving excel file', exc_info=e) logger.error('Error while saving excel file', exc_info=e)
@@ -448,6 +478,8 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.info('Uploading...') logger.info('Uploading...')
progress.clear() progress.clear()
progress['task'] = 'Task 3 of 4' progress['task'] = 'Task 3 of 4'
t2.clear()
t2.start()
driver.switch_to.new_window('tab') driver.switch_to.new_window('tab')
driver.get(WEBURL % 'order/importOrder') driver.get(WEBURL % 'order/importOrder')
@@ -455,6 +487,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
click(".product-import-img-box .mm-selector-rendered") click(".product-import-img-box .mm-selector-rendered")
click(".mm-outside.mm-select-dropdown ul li:nth-child(%d) span" % (1 if options.get('draft') else 6)) click(".mm-outside.mm-select-dropdown ul li:nth-child(%d) span" % (1 if options.get('draft') else 6))
flow.do(Wait)
flow.react() flow.react()
locate(".big-file-upload input", wait=False).send_keys(str(file)) locate(".big-file-upload input", wait=False).send_keys(str(file))
click(".product-import-img-footer button") click(".product-import-img-footer button")
@@ -465,7 +498,8 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
innerText = driver.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText') innerText = driver.find_element(By.CSS_SELECTOR, ".img-box-result-title").get_attribute('innerText')
if innerText == '导入完成': break if innerText == '导入完成': break
except: except:
wait(parameters.get('interval')) flow.do(Sleep)
flow.react()
click(".mm-notification-container .mm-icon-close") click(".mm-notification-container .mm-icon-close")
click(".product-import-img-footer button.mm-button__primary") click(".product-import-img-footer button.mm-button__primary")
@@ -474,16 +508,16 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
if err.get_attribute('disabled') is None: if err.get_attribute('disabled') is None:
logger.warning('Incomplete import detected; downloaded 1 related document') logger.warning('Incomplete import detected; downloaded 1 related document')
err.click() err.click()
sleep(1) flow.do(Sleep)
flow.react()
click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a") click(".mm-tbody table tbody tr:nth-child(1) td:nth-child(3) a")
driver.switch_to.window(driver.window_handles[2]) flow.do(Sleep)
until(ready) flow.react()
logger.info('Done') logger.info('Done')
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
continue continue
except Exception as e: except Exception as e:
logger.error('Error while uploading excel file', exc_info=e) logger.error('Error while uploading excel file', exc_info=e)
@@ -513,19 +547,29 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
progress['task'] = 'Task 4 of 4' progress['task'] = 'Task 4 of 4'
progress['limit'] = len(data) progress['limit'] = len(data)
t2.clear()
t2.start()
index = 0 index = 0
attempts = 0 attempts = 0
while index < len(data): while index < len(data):
try: try:
attempts += 1
driver.switch_to.window(driver.window_handles[2])
item = data[index] item = data[index]
kind = item['kind']
title = re.search(r'O\d+', item['title']) title = re.search(r'O\d+', item['title'])
number = profile.format(item['number']) number = profile.format(item['number'])
positions = item['positions'] positions = item['positions']
opportunity = None opportunity = None
attempts += 1
if attempts > parameters.get('attempts'): if kind != 'vat':
logger.info('[%d/%d] Skipping %s', index+1, len(data), number)
index += 1
attempts = 0
continue
if attempts > parameters['attempts']:
logger.warning('Exhausted all allowed attempts; skipping %s', number) logger.warning('Exhausted all allowed attempts; skipping %s', number)
index += 1 index += 1
attempts = 0 attempts = 0
@@ -535,6 +579,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
progress['index'] = index progress['index'] = index
flow.allow(Cancel) flow.allow(Cancel)
flow.allow(Skip) flow.allow(Skip)
flow.do(Wait)
flow.react() flow.react()
try: try:
@@ -553,17 +598,20 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
'array_flag': 0 'array_flag': 0
}]) }])
driver.get(url.encode()) driver.get(url.encode())
wait(parameters.get('interval')) flow.do(Wait)
flow.do(Sleep)
flow.react() flow.react()
url = Parse(driver.current_url) url = Parse(driver.current_url)
if url.get('page') != page: raise Exception(number) if url.get('page') != page: raise Exception(number)
wait(parameters.get('interval')) flow.do(Sleep)
flow.react()
link = locate(".virtual-list-wrap .vue-recycle-scroller .vue-recycle-scroller__item-wrapper > div:nth-child(1) .cell[data-cci='1'] a", wait=False) link = locate(".virtual-list-wrap .vue-recycle-scroller .vue-recycle-scroller__item-wrapper > div:nth-child(1) .cell[data-cci='1'] a", wait=False)
if link.text != number: continue if link.text != number: continue
logger.info('[%d/%d] Processing %s...', index+1, len(data), number) logger.info('[%d/%d] Processing %s...', index+1, len(data), number)
link.click() link.click()
driver.switch_to.window(driver.window_handles[3]) driver.switch_to.window(driver.window_handles[3])
flow.do(Wait)
flow.react() flow.react()
click(".sticky .okki-space-item:nth-child(1) button") click(".sticky .okki-space-item:nth-child(1) button")
break break
@@ -572,7 +620,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except NoSuchElementException: except NoSuchElementException:
logger.warning("Could not find invoice '%s'; skipping", number) logger.warning("Could not find invoice '%s'; skipping", number)
@@ -589,7 +636,8 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
driver.switch_to.new_window('tab') driver.switch_to.new_window('tab')
url = Parse(WEBURL % 'crm/business/list') url = Parse(WEBURL % 'crm/business/list')
driver.get(url.encode({ 'mode': 'list' })) driver.get(url.encode({ 'mode': 'list' }))
wait(parameters.get('interval')) flow.do(Wait)
flow.do(Sleep)
flow.react() flow.react()
try: click(".new-wrapper .paas-next-invoice-list-filter-line-wrapper .okki-btn-background-ghost", wait=False) try: click(".new-wrapper .paas-next-invoice-list-filter-line-wrapper .okki-btn-background-ghost", wait=False)
@@ -602,12 +650,14 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
url.set('curPage', page) url.set('curPage', page)
url.set('pageSize', 1) url.set('pageSize', 1)
driver.get(url.encode({ 'keyword': match, 'search_field': 'serial_keyword' })) driver.get(url.encode({ 'keyword': match, 'search_field': 'serial_keyword' }))
wait(parameters.get('interval')) flow.do(Wait)
flow.do(Sleep)
flow.react() flow.react()
url = Parse(driver.current_url) url = Parse(driver.current_url)
if url.get('curPage') != page: raise Exception(match) if url.get('curPage') != page: raise Exception(match)
wait(parameters.get('interval')) flow.do(Sleep)
flow.react()
cell = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(3) .ow-serial-read-pretty_ellipsis", wait=False) cell = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(3) .ow-serial-read-pretty_ellipsis", wait=False)
if cell.text != match: continue if cell.text != match: continue
link = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(6) a", wait=False) link = locate(".virtual-list-wrap .vue-recycle-scroller .row-item > .cell:nth-child(6) a", wait=False)
@@ -618,7 +668,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except NoSuchElementException: except NoSuchElementException:
logger.warning("Could not find opportunity '%s'; skipping", match) logger.warning("Could not find opportunity '%s'; skipping", match)
@@ -632,6 +681,7 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
if opportunity is not None: if opportunity is not None:
try: try:
flow.do(Wait)
flow.react() flow.react()
dropdown = locate("#rc_select_1") dropdown = locate("#rc_select_1")
dropdown.clear() dropdown.clear()
@@ -648,86 +698,95 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.warning('Error while selecting opportunity; skipping', exc_info=e) logger.warning('Could not select opportunity; skipping', exc_info=e)
try: try:
pagination = 10
click(".okki-pagination-options-size-changer") click(".okki-pagination-options-size-changer")
click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)") click(".okki-select-dropdown .rc-virtual-list-holder-inner > div:nth-child(1)")
except: except:
logger.warning('Unable to setup pagination; this may cause issues') logger.warning('Unable to setup pagination; this may cause issues')
pids = list()
wrapper = locate(".paas-order-product-list .row-items", condition=None)
class Eureka(Exception): pass
try: try:
ids = list()
wrapper = locate(".paas-order-product-list .row-items", condition=None)
for page in count(1): for page in count(1):
hits = 0
iteration = 0
flow.do(Wait)
flow.react() flow.react()
tail = next(reversed(pids), None)
while hits < pagination and iteration < parameters['attempts']:
iteration += 1
height = int(wrapper.get_attribute('clientHeight')) if iteration > 1 else 0
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper) driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, 0) driver.execute_script("arguments[0].scrollTo(0, arguments[1]);", wrapper, height)
rows = wrapper.find_elements(By.CSS_SELECTOR, ".row-item")
for item in wrapper.find_elements(By.CSS_SELECTOR, ".row-item"): for row in reversed(rows) if iteration > 1 else rows:
if len(pids) >= len(positions): raise Eureka() flow.do(Wait)
div = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='2'] .cell-inner div")
if (serial := int(div.text)) and serial not in pids:
flow.react() flow.react()
base = (serial - tail) if tail is not None else index
height = int(item.get_attribute('clientHeight'))
offset = (base - 1) * height
driver.execute_script("arguments[0].scrollTo(arguments[1], arguments[2]);", wrapper, 0, offset)
#span = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='3'] .product-info-group-product-info:nth-child(3) span")
#code = span.text[6:]
value = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='4'] input").get_attribute('value')
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper) driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
driver.execute_script("arguments[0].scroll(arguments[1], arguments[2]);", wrapper, 400, offset) driver.execute_script("arguments[0].scrollTo(0, arguments[1]);", wrapper, height)
wait(parameters.get('interval')) serial = row.text.split('\n', 1)[0].strip()
target = item.find_element(By.CSS_SELECTOR, ".cell[data-cci='6'] input") if not serial or serial in ids: continue
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", row)
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
value = row.find_element(By.CSS_SELECTOR, ".cell[data-cci='4'] input").get_attribute('value')
driver.execute_script("arguments[0].scroll(400, 0);", wrapper)
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", row)
driver.execute_script("arguments[0].scrollIntoView({ block: 'center' });", wrapper)
flow.do(Sleep)
flow.react()
target = row.find_element(By.CSS_SELECTOR, ".cell[data-cci='6'] input")
if (target.get_attribute('value') == '0'): if (target.get_attribute('value') == '0'):
target.send_keys(Keys.BACKSPACE) target.send_keys(Keys.BACKSPACE)
target.send_keys(value) target.send_keys(value)
pids.append(serial) ids.append(serial)
hits += 1
if len(ids) >= len(positions): break
button = locate(".text-right li.okki-pagination-next button", condition=None) button = locate(".text-right li.okki-pagination-next button", condition=None)
if button.get_attribute('disabled') is not None and len(pids) < len(positions): if button.get_attribute('disabled') is not None and len(ids) < len(positions):
raise Exception('Product list imcomplete; expected %d, got %d', len(pids), len(positions)) raise Exception('Product list imcomplete; expected %d, got %d' % (len(positions), len(ids)))
flow.do(Wait)
flow.react() flow.react()
click(button) click(button)
except Eureka:
pass
except Skip: except Skip:
index += 1 index += 1
attempts = 0 attempts = 0
continue continue
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.error('Error while modifying invoice', exc_info=e) logger.error('Error while modifying invoice', exc_info=e)
status = Status.STANDBY status = Status.STANDBY
continue continue
try: click(".ow-box button.okki-btn-round", wait=False) try:
except: pass click(".ow-box button.okki-btn-round", wait=False)
flow.do(Sleep)
flow.react()
except Exception as e:
logger.warning('Unable to unset additional fees; skipping', exc_info=e)
try: try:
wait(parameters.get('interval')) flow.do(Wait)
flow.react() flow.react()
flow.allow(Cancel, False) flow.allow(Cancel, False)
flow.allow(Skip, False) flow.allow(Skip, False)
click(".sticky.bottom-0 button.okki-btn-primary", condition=None) click(".sticky.bottom-0 button.okki-btn-primary", condition=None)
wait(parameters.get('interval')) flow.do(Sleep)
flow.react()
except Skip: except Skip:
pass pass
except Cancel: except Cancel:
status = Status.READY
break break
except Exception as e: except Exception as e:
logger.error('Error while saving document', exc_info=e) logger.error('Error while saving document', exc_info=e)
@@ -735,7 +794,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
continue continue
finally: finally:
driver.close() driver.close()
driver.switch_to.window(driver.window_handles[2])
index += 1 index += 1
attempts = 0 attempts = 0
@@ -743,16 +801,6 @@ def main(driver: WebDriver, logger = logging.getLogger('main')):
logger.error('Unexpected error', exc_info=e) logger.error('Unexpected error', exc_info=e)
status = Status.STANDBY status = Status.STANDBY
try:
driver.close()
driver.switch_to.window(driver.window_handles[1])
driver.close()
driver.switch_to.window(driver.window_handles[0])
selection += 1
except Exception as e:
logger.error('Unexpected error', exc_info=e)
status = Status.STANDBY
if __name__ == '__main__': if __name__ == '__main__':
try: try:
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M") logging.basicConfig(level=logging.INFO, format="[%(asctime)s] [%(levelname)s] [%(name)s] %(message)s", datefmt="%Y-%m-%d %H:%M")
@@ -760,14 +808,6 @@ if __name__ == '__main__':
level = logging.getLevelNamesMapping().get(args.log_level, 'INFO') level = logging.getLevelNamesMapping().get(args.log_level, 'INFO')
logger.setLevel(level) logger.setLevel(level)
logger.info('Initializing...')
history = jsonrpc2.History()
logger.addHandler(history)
opts = jsonrpc2.Options()
jsonrpc2.define('history', lambda: history.truncate())
jsonrpc2.run(opts)
logger.info('Creating automation instance')
opts = ChromeOptions() opts = ChromeOptions()
opts.enable_downloads = True opts.enable_downloads = True
opts.add_argument('--deny-permission-prompts') opts.add_argument('--deny-permission-prompts')
@@ -776,6 +816,8 @@ if __name__ == '__main__':
with keep.presenting(): with keep.presenting():
driver = Chrome(options=opts) driver = Chrome(options=opts)
status = main(driver) status = main(driver)
except KeyboardInterrupt:
status = 0
except Exception as e: except Exception as e:
logger.critical('Fatal error', exc_info=e) logger.critical('Fatal error', exc_info=e)
status = 1 status = 1

View File

@@ -1,4 +1,4 @@
cd /D .\..\ cd /D .\..\
set SE_PROXY=http://127.0.0.1:10809 set SE_PROXY=http://127.0.0.1:10809
.\venv\Scripts\pythonw.exe .\main.py "user@example.com" "example" --profile "Example-1" --token "TOKEN" --subdomain "eg" --remise "100" .\venv\Scripts\pythonw.exe .\main.py "user@example.com" "example" --profile "name=example" "token=TOKEN" "subdomain=eg"
@pause @pause

Binary file not shown.