feat: add recruit sign-in automation

This commit is contained in:
admin committed 2026-10-05 04:18:02 +08:00
1 parent 19c502f659
commit 8b7c5d45da
12 files changed
+288

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

+52
View File
@@ -78,6 +78,37 @@ class Matcher:
return 0.0, None
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):
return all(self.score(frame, s) >= s.get('threshold', 0.86) for s in specs)
@@ -116,6 +147,27 @@ class Desktop:
finally:
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:
def __init__(self, desktop, matcher, region, log=print, stop=None):
+168
View File
@@ -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'
+37
View File
@@ -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())
+31
View File
@@ -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
}