feat: add recruit sign-in automation
No files matched your search
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
|
After Width: | Height: | Size: 634 B |
@@ -78,6 +78,37 @@ class Matcher:
|
|||||||
return 0.0, None
|
return 0.0, None
|
||||||
return float(score), (x1 + x, y1 + y, w, h)
|
return float(score), (x1 + x, y1 + y, w, h)
|
||||||
|
|
||||||
|
def locate_all(self, frame, spec, limit=20):
|
||||||
|
"""Return non-overlapping template matches, ordered from top to bottom."""
|
||||||
|
name = spec['template']
|
||||||
|
if name not in self.cache:
|
||||||
|
p = self.assets / name
|
||||||
|
if not p.is_file():
|
||||||
|
raise WorkflowError('缺少识别模板:' + name)
|
||||||
|
self.cache[name] = np.asarray(Image.open(p).convert('RGB'))
|
||||||
|
template = self.cache[name]
|
||||||
|
x1, y1, x2, y2 = spec['search']
|
||||||
|
area = frame[y1:y2, x1:x2]
|
||||||
|
h, w = template.shape[:2]
|
||||||
|
if area.shape[0] < h or area.shape[1] < w:
|
||||||
|
raise WorkflowError('模板尺寸超出识别区域。')
|
||||||
|
scores = cv2.matchTemplate(area, template, cv2.TM_CCOEFF_NORMED)
|
||||||
|
threshold = spec.get('threshold', 0.86)
|
||||||
|
matches = []
|
||||||
|
while len(matches) < limit:
|
||||||
|
_, score, _, (x, y) = cv2.minMaxLoc(scores)
|
||||||
|
if not math.isfinite(score) or score < threshold:
|
||||||
|
break
|
||||||
|
candidate = area[y:y + h, x:x + w]
|
||||||
|
difference = float(np.abs(candidate.astype(float) - template.astype(float)).mean())
|
||||||
|
# Suppress this neighbourhood regardless of the colour check so
|
||||||
|
# a rejected partial overlap cannot repeat forever.
|
||||||
|
scores[max(0, y - h // 2):min(scores.shape[0], y + h // 2 + 1),
|
||||||
|
max(0, x - w // 2):min(scores.shape[1], x + w // 2 + 1)] = -1
|
||||||
|
if difference <= spec.get('max_color_difference', 28):
|
||||||
|
matches.append((float(score), (x1 + x, y1 + y, w, h)))
|
||||||
|
return sorted(matches, key=lambda item: (item[1][1], item[1][0]))
|
||||||
|
|
||||||
def matches(self, frame, specs):
|
def matches(self, frame, specs):
|
||||||
return all(self.score(frame, s) >= s.get('threshold', 0.86) for s in specs)
|
return all(self.score(frame, s) >= s.get('threshold', 0.86) for s in specs)
|
||||||
|
|
||||||
@@ -116,6 +147,27 @@ class Desktop:
|
|||||||
finally:
|
finally:
|
||||||
self.api.FAILSAFE = saved_failsafe
|
self.api.FAILSAFE = saved_failsafe
|
||||||
|
|
||||||
|
def drag(self, start, end, region, screenshot_size, hold=0.2, duration=0.7):
|
||||||
|
"""Perform a deliberate touch-like list swipe inside the game region."""
|
||||||
|
from .permissions import mouse_permission_granted
|
||||||
|
if not mouse_permission_granted():
|
||||||
|
raise WorkflowError('macOS 辅助功能权限未授予,无法发送有效鼠标拖拽。')
|
||||||
|
start_point = region.mouse_point(start, screenshot_size, tuple(self.api.size()))
|
||||||
|
end_point = region.mouse_point(end, screenshot_size, tuple(self.api.size()))
|
||||||
|
self.api.moveTo(*start_point, duration=0.2)
|
||||||
|
self.api.mouseDown()
|
||||||
|
try:
|
||||||
|
time.sleep(hold)
|
||||||
|
self.api.moveTo(*end_point, duration=duration)
|
||||||
|
time.sleep(hold)
|
||||||
|
finally:
|
||||||
|
saved = self.api.FAILSAFE
|
||||||
|
self.api.FAILSAFE = False
|
||||||
|
try:
|
||||||
|
self.api.mouseUp()
|
||||||
|
finally:
|
||||||
|
self.api.FAILSAFE = saved
|
||||||
|
|
||||||
|
|
||||||
class Runner:
|
class Runner:
|
||||||
def __init__(self, desktop, matcher, region, log=print, stop=None):
|
def __init__(self, desktop, matcher, region, log=print, stop=None):
|
||||||
|
|||||||
@@ -0,0 +1,168 @@
|
|||||||
|
"""商城英雄招募的限时 UP 神灯任务与免费神灯领取。"""
|
||||||
|
import time
|
||||||
|
|
||||||
|
from .engine import WorkflowError
|
||||||
|
|
||||||
|
|
||||||
|
class RecruitRunner:
|
||||||
|
def __init__(self, desktop, matcher, flow, region_provider, log=print, timeout=12):
|
||||||
|
self.desktop = desktop
|
||||||
|
self.matcher = matcher
|
||||||
|
self.flow = flow
|
||||||
|
self.region_provider = region_provider
|
||||||
|
self.log = log
|
||||||
|
self.timeout = timeout
|
||||||
|
|
||||||
|
def wait(self, *names):
|
||||||
|
end = time.monotonic() + self.timeout
|
||||||
|
previous, stable = None, 0
|
||||||
|
while time.monotonic() < end:
|
||||||
|
shot = self.desktop.screenshot()
|
||||||
|
region = self.region_provider(shot)
|
||||||
|
frame = self.matcher.frame(shot, region)
|
||||||
|
found = next((name for name in names
|
||||||
|
if self.matcher.matches(frame, self.flow['states'][name])), None)
|
||||||
|
stable = stable + 1 if found and found == previous else (1 if found else 0)
|
||||||
|
previous = found
|
||||||
|
if stable >= 2:
|
||||||
|
return found, shot, region, frame
|
||||||
|
time.sleep(.25)
|
||||||
|
raise WorkflowError('未识别到预期页面 ' + '/'.join(names) + ',已停止。')
|
||||||
|
|
||||||
|
def observe(self):
|
||||||
|
shot = self.desktop.screenshot()
|
||||||
|
region = self.region_provider(shot)
|
||||||
|
return shot, region, self.matcher.frame(shot, region)
|
||||||
|
|
||||||
|
def click(self, name, observation, hold=.8):
|
||||||
|
shot, region, _ = observation[-3:]
|
||||||
|
if self.region_provider(shot) != region:
|
||||||
|
raise WorkflowError('识别后游戏窗口移动,已停止,请重新运行。')
|
||||||
|
self.desktop.click(self.flow['clicks'][name], region, shot.size, hold=hold)
|
||||||
|
self.log('已执行:' + name)
|
||||||
|
|
||||||
|
def click_location(self, location, observation):
|
||||||
|
shot, region, _ = observation[-3:]
|
||||||
|
x, y, width, height = location
|
||||||
|
self.desktop.click(((x + width / 2) / 562, (y + height / 2) / 1002),
|
||||||
|
region, shot.size, hold=.8)
|
||||||
|
|
||||||
|
def swipe(self, name, observation):
|
||||||
|
shot, region, _ = observation[-3:]
|
||||||
|
start, end = self.flow['swipes'][name]
|
||||||
|
self.desktop.drag(start, end, region, shot.size)
|
||||||
|
time.sleep(self.flow.get('swipe_settle_seconds', .5))
|
||||||
|
|
||||||
|
def has_dot(self, frame, spec_name, search=None):
|
||||||
|
spec = dict(self.flow[spec_name])
|
||||||
|
if search:
|
||||||
|
spec['search'] = search
|
||||||
|
score, _ = self.matcher.locate(frame, spec)
|
||||||
|
return score >= spec.get('threshold', .86)
|
||||||
|
|
||||||
|
def task_buttons_with_dot(self, frame):
|
||||||
|
buttons = self.matcher.locate_all(frame, self.flow['task_button'], limit=8)
|
||||||
|
for _, location in buttons:
|
||||||
|
x, y, width, _ = location
|
||||||
|
# The red dot sits just above the task-button's top-right corner.
|
||||||
|
dot_search = [x + width - 32, max(0, y - 31), x + width + 8, y + 5]
|
||||||
|
if self.has_dot(frame, 'card_task_dot', dot_search):
|
||||||
|
yield location
|
||||||
|
|
||||||
|
def dismiss_reward(self):
|
||||||
|
reward = self.wait('reward')
|
||||||
|
self.click('dismiss_reward', reward, hold=.2)
|
||||||
|
self.wait('lamp')
|
||||||
|
|
||||||
|
def claim_visible_tasks(self):
|
||||||
|
claimed = 0
|
||||||
|
empty_scrolls = 0
|
||||||
|
while claimed < self.flow['limits']['task_claims']:
|
||||||
|
lamp = self.wait('lamp')
|
||||||
|
spec = self.flow['task_claim']
|
||||||
|
matches = self.matcher.locate_all(lamp[3], spec, limit=8)
|
||||||
|
if matches:
|
||||||
|
_, location = matches[0]
|
||||||
|
self.click_location(location, lamp)
|
||||||
|
self.log('已领取神灯任务奖励。')
|
||||||
|
self.dismiss_reward()
|
||||||
|
claimed += 1
|
||||||
|
empty_scrolls = 0
|
||||||
|
continue
|
||||||
|
if empty_scrolls >= self.flow['limits']['task_scrolls']:
|
||||||
|
break
|
||||||
|
self.swipe('task_list_up', lamp)
|
||||||
|
empty_scrolls += 1
|
||||||
|
return claimed
|
||||||
|
|
||||||
|
def process_lamp(self):
|
||||||
|
lamp = self.wait('lamp')
|
||||||
|
if self.has_dot(lamp[3], 'task_tab_dot'):
|
||||||
|
self.click('task_tab', lamp)
|
||||||
|
self.wait('lamp')
|
||||||
|
self.claim_visible_tasks()
|
||||||
|
|
||||||
|
lamp = self.wait('lamp')
|
||||||
|
if self.has_dot(lamp[3], 'gift_tab_dot'):
|
||||||
|
self.click('gift_tab', lamp)
|
||||||
|
gift = self.wait('gift')
|
||||||
|
score, location = self.matcher.locate(gift[3], self.flow['free_button'])
|
||||||
|
if score >= self.flow['free_button'].get('threshold', .86):
|
||||||
|
self.click_location(location, gift)
|
||||||
|
self.log('已领取免费神灯。')
|
||||||
|
self.dismiss_reward()
|
||||||
|
else:
|
||||||
|
self.log('神灯礼包没有可领取的免费神灯,跳过。')
|
||||||
|
|
||||||
|
lamp = self.wait('lamp')
|
||||||
|
self.click('close_lamp', lamp, hold=.2)
|
||||||
|
time.sleep(.5)
|
||||||
|
|
||||||
|
def return_home(self):
|
||||||
|
observation = self.observe()
|
||||||
|
self.click('shop_back', observation, hold=.2)
|
||||||
|
self.wait('home')
|
||||||
|
self.log('已确认返回首页。')
|
||||||
|
|
||||||
|
def run(self, dry_run=False):
|
||||||
|
home = self.wait('home')
|
||||||
|
if dry_run:
|
||||||
|
self.log('首页识别通过。计划:商城 → 英雄招募限时 UP → 神灯任务/礼包 → 返回首页。未点击。')
|
||||||
|
return 'preview'
|
||||||
|
|
||||||
|
self.click('shop', home, hold=.8)
|
||||||
|
shop = self.wait('shop')
|
||||||
|
for _ in range(self.flow['limits']['scroll_to_recruit']):
|
||||||
|
self.swipe('shop_down', shop)
|
||||||
|
shop = self.observe()
|
||||||
|
if self.matcher.locate_all(shop[2], self.flow['task_button'], limit=1):
|
||||||
|
break
|
||||||
|
|
||||||
|
handled, blank_pages = 0, 0
|
||||||
|
while handled < self.flow['limits']['cards']:
|
||||||
|
_, _, frame = self.observe()
|
||||||
|
task_button = next(self.task_buttons_with_dot(frame), None)
|
||||||
|
if task_button:
|
||||||
|
# Click the button itself, rather than its red dot.
|
||||||
|
x, y, width, height = task_button
|
||||||
|
shot, region, current = self.observe()
|
||||||
|
self.desktop.click(((x + width / 2) / 562, (y + height / 2) / 1002),
|
||||||
|
region, shot.size, hold=.8)
|
||||||
|
self.log('已打开限时 UP 卡片的神灯召唤。')
|
||||||
|
self.process_lamp()
|
||||||
|
handled += 1
|
||||||
|
blank_pages = 0
|
||||||
|
continue
|
||||||
|
if blank_pages >= self.flow['limits']['card_pages']:
|
||||||
|
break
|
||||||
|
# The card stack precedes 图腾寻宝. Pulling the list down shows
|
||||||
|
# earlier UP cards without ever touching the treasure buttons.
|
||||||
|
self.swipe('shop_down', self.observe())
|
||||||
|
blank_pages += 1
|
||||||
|
|
||||||
|
self.return_home()
|
||||||
|
if handled:
|
||||||
|
self.log('已处理 %s 张限时 UP 卡片。' % handled)
|
||||||
|
return 'claimed'
|
||||||
|
self.log('没有带红点的限时 UP 任务卡片,未领取奖励。')
|
||||||
|
return 'no_card_dot'
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""领取商城英雄招募限时 UP 的神灯任务与免费神灯。"""
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from automation.engine import Matcher, load_flow
|
||||||
|
from automation.offers import MacGameDesktop
|
||||||
|
from automation.recruit import RecruitRunner
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='英雄招募签到:领取限时 UP 神灯任务和免费神灯')
|
||||||
|
parser.add_argument('--dry-run', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
if sys.platform != 'darwin':
|
||||||
|
print('当前脚本仅完成 macOS 实机适配。', file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
desktop = MacGameDesktop()
|
||||||
|
desktop.activate()
|
||||||
|
print('3 秒后开始;请保持游戏在前台。', flush=True)
|
||||||
|
time.sleep(3)
|
||||||
|
return_code = RecruitRunner(
|
||||||
|
desktop, Matcher(ROOT / 'assets'), load_flow(ROOT / 'workflows/recruit_signin.json'),
|
||||||
|
desktop.region_for).run(args.dry_run)
|
||||||
|
print('流程结果:' + return_code)
|
||||||
|
return 0
|
||||||
|
except Exception as error:
|
||||||
|
print('流程停止:' + str(error), file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
raise SystemExit(main())
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "商城英雄招募限时 UP 神灯签到",
|
||||||
|
"states": {
|
||||||
|
"home": [{"template": "home.png", "search": [150, 730, 410, 865]}],
|
||||||
|
"shop": [{"template": "recruit_shop_header.png", "search": [50, 20, 510, 220], "threshold": 0.94}],
|
||||||
|
"lamp": [{"template": "recruit_lamp_title.png", "search": [130, 100, 430, 240], "threshold": 0.88}],
|
||||||
|
"gift": [{"template": "recruit_gift_label.png", "search": [70, 240, 280, 330], "threshold": 0.88}],
|
||||||
|
"reward": [{"template": "recruit_reward.png", "search": [100, 180, 460, 400], "threshold": 0.78, "max_color_difference": 40}]
|
||||||
|
},
|
||||||
|
"clicks": {
|
||||||
|
"shop": [0.10, 0.94],
|
||||||
|
"task_tab": [0.37, 0.89],
|
||||||
|
"gift_tab": [0.65, 0.89],
|
||||||
|
"dismiss_reward": [0.50, 0.72],
|
||||||
|
"close_lamp": [0.915, 0.205],
|
||||||
|
"shop_back": [0.10, 0.94]
|
||||||
|
},
|
||||||
|
"swipes": {
|
||||||
|
"shop_down": [[0.50, 0.36], [0.50, 0.78]],
|
||||||
|
"shop_up": [[0.50, 0.78], [0.50, 0.30]],
|
||||||
|
"task_list_up": [[0.50, 0.76], [0.50, 0.33]]
|
||||||
|
},
|
||||||
|
"card_task_dot": {"template": "recruit_task_dot.png", "search": [440, 120, 540, 820], "threshold": 0.88, "max_color_difference": 25},
|
||||||
|
"task_button": {"template": "recruit_task_button.png", "search": [430, 120, 540, 820], "threshold": 0.88, "max_color_difference": 25},
|
||||||
|
"task_tab_dot": {"template": "recruit_task_dot.png", "search": [220, 830, 300, 900], "threshold": 0.80, "max_color_difference": 25},
|
||||||
|
"gift_tab_dot": {"template": "recruit_task_dot.png", "search": [390, 830, 480, 900], "threshold": 0.90, "max_color_difference": 25},
|
||||||
|
"task_claim": {"template": "recruit_claim.png", "search": [330, 240, 510, 820], "threshold": 0.90, "max_color_difference": 25},
|
||||||
|
"free_button": {"template": "recruit_free.png", "search": [330, 240, 510, 420], "threshold": 0.90, "max_color_difference": 25},
|
||||||
|
"limits": {"scroll_to_recruit": 3, "cards": 12, "card_pages": 3, "task_claims": 20, "task_scrolls": 3},
|
||||||
|
"swipe_settle_seconds": 0.5
|
||||||
|
}
|
||||||