225 lines
8.5 KiB
Python
Executable File
225 lines
8.5 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""从聚胜算订单利润页面接口抓取页面结果并导入财务系统。
|
||
|
||
使用前需要本机有已登录聚胜算的 Chrome 调试窗口(remote debugging port 9333),
|
||
并且本机 Docker 容器 jiangling_app 运行财务系统。
|
||
|
||
示例:
|
||
# 指定订单
|
||
python3 scripts/order-profit-shengsuan-sync.py --orders 5127581702031023636
|
||
|
||
# 按支付时间拉 7 天
|
||
python3 scripts/order-profit-shengsuan-sync.py --date-fld pay_date \
|
||
--from 2026-11-01 --to 2026-11-07
|
||
|
||
# 只打印 JSON,不导入
|
||
python3 scripts/order-profit-shengsuan-sync.py --orders 5127581702031023636 --no-import
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
|
||
import websocket
|
||
|
||
|
||
API_URL = 'https://pf.erp321.com/WebApi/PF/OrderSku/GetOrderSkuList?uvalue=pfcweb_profit-report.order-profit'
|
||
CHROME_LIST_URL = 'http://127.0.0.1:9333/json/list'
|
||
|
||
|
||
def page_target(port: int):
|
||
with urllib.request.urlopen(f'http://127.0.0.1:{port}/json/list', timeout=5) as response:
|
||
targets = json.load(response)
|
||
pages = [target for target in targets
|
||
if target.get('type') == 'page' and 'ss.erp321.com' in (target.get('url') or '')]
|
||
if not pages:
|
||
raise RuntimeError('未找到已登录的胜算页面,请先扫码登录并打开订单利润页面。')
|
||
return pages[0]
|
||
|
||
|
||
def evaluate(ws: websocket.WebSocket, expression: str, await_promise: bool = False):
|
||
payload = {
|
||
'id': 1,
|
||
'method': 'Runtime.evaluate',
|
||
'params': {
|
||
'expression': expression,
|
||
'returnByValue': True,
|
||
'awaitPromise': await_promise,
|
||
},
|
||
}
|
||
ws.send(json.dumps(payload))
|
||
while True:
|
||
message = json.loads(ws.recv())
|
||
if message.get('id') == 1:
|
||
if 'error' in message:
|
||
raise RuntimeError(json.dumps(message['error'], ensure_ascii=False))
|
||
if 'exceptionDetails' in message.get('result', {}):
|
||
raise RuntimeError(json.dumps(message['result']['exceptionDetails'], ensure_ascii=False))
|
||
return message.get('result', {}).get('result', {}).get('value')
|
||
|
||
|
||
def fetch_page(ws: websocket.WebSocket, condition: dict, current_page: int, page_size: int):
|
||
request = {
|
||
'uid': '', 'coid': '', 'ip': '',
|
||
'page': {'currentPage': current_page, 'pageSize': page_size},
|
||
'data': {'condition': condition, 'isAds': False},
|
||
'ssProjectType': 'SS',
|
||
'ssClientType': 'Web',
|
||
}
|
||
expression = f"""(async () => {{
|
||
const res = await fetch('{API_URL}', {{
|
||
method: 'POST', credentials: 'include',
|
||
headers: {{'Content-Type': 'application/json'}},
|
||
body: JSON.stringify({json.dumps(request, ensure_ascii=False)})
|
||
}});
|
||
const text = await res.text();
|
||
return {{status: res.status, text}};
|
||
}})()"""
|
||
result = evaluate(ws, expression, await_promise=True)
|
||
if result is None or result.get('status') != 200:
|
||
raise RuntimeError(f'接口返回异常:{result}')
|
||
decoded = json.loads(result['text'])
|
||
if decoded.get('code') == 1908:
|
||
raise RuntimeError('RATE_LIMIT')
|
||
if decoded.get('code') not in (0, None):
|
||
raise RuntimeError(f"接口业务错误 code={decoded.get('code')} msg={decoded.get('msg')}")
|
||
return decoded
|
||
|
||
|
||
def condition_from_args(args):
|
||
base = {
|
||
'type': 'soId',
|
||
'dateFld': 'pay_date',
|
||
'combineDate': None,
|
||
'shop': [],
|
||
'multiPriceType': 'User3',
|
||
'multiplePrice': 63,
|
||
'costTypeList': [],
|
||
'orderType': [],
|
||
'drpCoidtos': [],
|
||
'wmsCoidList': [],
|
||
'buIdList': [],
|
||
'skuIn': {'type': 'anyonesku', 'values': [], 'select': 'in'},
|
||
'skuOut': {'type': 'anyonesku', 'values': [], 'select': 'out'},
|
||
'labelIn': {'type': 'anyonelabel', 'values': [], 'select': 'in'},
|
||
'labelout': {'type': 'anyonelabel', 'values': [], 'select': 'out'},
|
||
'creatorList': [], 'cusIdList': [], 'brands': [], 'categorys': [], 'vcNames': [],
|
||
'goodsCostGroup': {
|
||
'skuCostAmountSearchType': {'value': 2, 'label': '按固定值搜索商品成本'},
|
||
'value': '', 'values': ['', ''],
|
||
},
|
||
'backGoodsType': 1,
|
||
'splitCombine': True,
|
||
'groupByType': 1,
|
||
'skuCostAmountSearchType': 2,
|
||
'skuCostAmountList': [],
|
||
'firstfreightList': [''],
|
||
'stateCityList': [],
|
||
'isOL': True,
|
||
'skugroup': 'so_id',
|
||
'isValidateDate': False,
|
||
'isCrossBorder': False,
|
||
}
|
||
if args.orders:
|
||
base['type'] = 'soId'
|
||
base['idType'] = ','.join(args.orders)
|
||
base['dateFld'] = 'pay_date'
|
||
return base
|
||
|
||
base['dateFld'] = args.date_fld
|
||
base['type'] = 'oid' if args.date_fld == 'send_date' else 'soId'
|
||
base['groupByType'] = 1 if base['type'] == 'soId' else 2
|
||
base['combineDate'] = None
|
||
base['beginDate'] = args.begin
|
||
base['endDate'] = args.end
|
||
if args.shop:
|
||
base['shop'] = [{'value': shop, 'label': shop} for shop in args.shop]
|
||
return base
|
||
|
||
|
||
def main() -> int:
|
||
parser = argparse.ArgumentParser()
|
||
parser.add_argument('--orders', nargs='*', default=[])
|
||
parser.add_argument('--date-fld', choices=['pay_date', 'send_date', 'order_date'], default='pay_date')
|
||
parser.add_argument('--from', dest='begin', default=None)
|
||
parser.add_argument('--to', dest='end', default=None)
|
||
parser.add_argument('--shop', nargs='*', default=[])
|
||
parser.add_argument('--page-size', type=int, default=5000)
|
||
parser.add_argument('--max-pages', type=int, default=0)
|
||
parser.add_argument('--page-delay', type=float, default=0.8)
|
||
parser.add_argument('--debug-port', type=int, default=9333)
|
||
parser.add_argument('--no-import', action='store_true')
|
||
parser.add_argument('--json-out', default='/tmp/order-profit-shengsuan.json')
|
||
args = parser.parse_args()
|
||
|
||
if not args.orders and (not args.begin or not args.end):
|
||
parser.error('订单模式需要 --orders,日期模式需要 --from 和 --to')
|
||
|
||
target = page_target(args.debug_port)
|
||
ws = websocket.create_connection(target['webSocketDebuggerUrl'], timeout=180)
|
||
try:
|
||
ws.send(json.dumps({'id': 0, 'method': 'Runtime.enable', 'params': {}}))
|
||
condition = condition_from_args(args)
|
||
current = 1
|
||
collected = []
|
||
heads = None
|
||
total = None
|
||
while True:
|
||
page = None
|
||
for attempt in range(1, 8):
|
||
try:
|
||
page = fetch_page(ws, condition, current, args.page_size)
|
||
break
|
||
except RuntimeError as exception:
|
||
if str(exception) != 'RATE_LIMIT':
|
||
raise
|
||
wait = min(300, 60 * attempt)
|
||
print(f'第 {attempt} 次限流,等待 {wait} 秒后重试……', flush=True)
|
||
time.sleep(wait)
|
||
if page is None:
|
||
raise RuntimeError('聚胜算接口持续限流,本次未完成。')
|
||
data = page.get('data') or {}
|
||
if heads is None:
|
||
heads = data.get('fldHeads') or []
|
||
collected.extend(data.get('dataList') or [])
|
||
page_info = page.get('page') or {}
|
||
total = page_info.get('count', len(collected))
|
||
pages = page_info.get('pages', 1)
|
||
if current >= pages or len(collected) >= total:
|
||
break
|
||
if args.max_pages and current >= args.max_pages:
|
||
break
|
||
current += 1
|
||
time.sleep(args.page_delay)
|
||
|
||
payload = {'data': {'fldHeads': heads, 'dataList': collected}, 'page': {'count': total}}
|
||
text = json.dumps(payload, ensure_ascii=False)
|
||
with open(args.json_out, 'w') as handle:
|
||
handle.write(text)
|
||
print(f'抓取 {len(collected)} 条,JSON:{args.json_out}')
|
||
|
||
if not args.no_import:
|
||
process = subprocess.run(
|
||
['docker', 'exec', '-i', '-u', 'www-data', 'jiangling_app',
|
||
'php', '-d', 'memory_limit=2G', 'artisan',
|
||
'order-profit:import-shengsuan-rows', '--stdin'],
|
||
input=text.encode('utf-8'),
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
check=False,
|
||
)
|
||
sys.stdout.write(process.stdout.decode('utf-8', 'replace'))
|
||
if process.returncode != 0:
|
||
sys.stderr.write(process.stderr.decode('utf-8', 'replace'))
|
||
return process.returncode
|
||
finally:
|
||
ws.close()
|
||
return 0
|
||
|
||
|
||
if __name__ == '__main__':
|
||
raise SystemExit(main())
|