Fix xianyu QR login refresh

Exclude tiny QR-login icon candidates, expose captured QR dimensions in the Web UI, and refresh the portable Hermes skill release archives.
This commit is contained in:
chick
2026-05-07 15:29:05 +08:00
parent 6c8fdc861f
commit efd6eb8fb9
10 changed files with 264 additions and 22 deletions
+73 -13
View File
@@ -141,27 +141,53 @@ async function clickIfVisible(page, selector, timeout = 1000) {
return false;
}
async function isUsableQrCandidate(loc) {
try {
const box = await loc.boundingBox({ timeout: 1200 });
if (!box) return false;
// Goofish/Taobao pages contain tiny QR icons (for switching login modes).
// Those are visible canvases/images too, but screenshotting them produces a
// 24x24 icon instead of the actual scannable QR code. Only accept elements
// large enough to be a real QR/QR container.
return box.width >= 120 && box.height >= 120;
} catch {
return false;
}
}
async function findQrLikeLocator(page) {
const selectors = [
'canvas',
'img[src^="data:image"]',
'img[src*="qr"]',
'img[src*="qrcode"]',
'[class*="qrcode"] canvas',
'[class*="qrcode"] img',
'[class*="qr-code"] canvas',
'[class*="qr-code"] img',
'[class*="qrCode"] canvas',
'[class*="qrCode"] img',
'[class*="qr"] canvas',
'[class*="qr"] img',
'[class*="login"] canvas',
'[class*="login"] img',
'canvas',
'img[src^="data:image"]',
'img[src*="qr"]',
'img[src*="qrcode"]',
];
for (const selector of selectors) {
const loc = page.locator(selector).first();
if (await loc.isVisible({ timeout: 1200 }).catch(() => false)) return loc;
const locs = page.locator(selector);
const count = Math.min(await locs.count().catch(() => 0), 8);
for (let i = 0; i < count; i += 1) {
const loc = locs.nth(i);
if (await loc.isVisible({ timeout: 800 }).catch(() => false) && await isUsableQrCandidate(loc)) return loc;
}
}
for (const frame of page.frames()) {
for (const selector of selectors) {
const loc = frame.locator(selector).first();
if (await loc.isVisible({ timeout: 800 }).catch(() => false)) return loc;
const locs = frame.locator(selector);
const count = Math.min(await locs.count().catch(() => 0), 8);
for (let i = 0; i < count; i += 1) {
const loc = locs.nth(i);
if (await loc.isVisible({ timeout: 500 }).catch(() => false) && await isUsableQrCandidate(loc)) return loc;
}
}
}
return null;
@@ -197,16 +223,50 @@ export async function getLoginQr(emitLog = () => {}) {
let screenshotKind = '';
if (!status.loggedIn) {
const qrLike = await findQrLikeLocator(page);
const modal = page.locator('[class*="login-modal"], [class*="Login"], [class*="qrcode"], [class*="qr"], iframe').first();
const target = qrLike || (await modal.isVisible({ timeout: 1500 }).catch(() => false) ? modal : page.locator('body'));
screenshotKind = qrLike ? '二维码区域' : '登录面板截图';
const modalCandidates = [
'[class*="login-modal"]',
'[class*="Login"]',
'[class*="qrcode"]',
'[class*="qr-code"]',
'[class*="qrCode"]',
'[class*="qr"]',
'iframe',
];
let modal = null;
for (const selector of modalCandidates) {
const locs = page.locator(selector);
const count = Math.min(await locs.count().catch(() => 0), 8);
for (let i = 0; i < count; i += 1) {
const loc = locs.nth(i);
if (await loc.isVisible({ timeout: 500 }).catch(() => false) && await isUsableQrCandidate(loc)) {
modal = loc;
break;
}
}
if (modal) break;
}
const target = qrLike || modal || page.locator('body');
screenshotKind = qrLike ? '二维码区域' : (modal ? '登录面板截图' : '当前登录页面截图');
const buf = await target.screenshot({ type: 'png', timeout: 8000 }).catch(() => null);
if (buf) qrImage = `data:image/png;base64,${buf.toString('base64')}`;
}
let qrImageWidth = 0;
let qrImageHeight = 0;
if (qrImage) {
const match = qrImage.match(/^data:image\/png;base64,(.+)$/);
if (match) {
const raw = Buffer.from(match[1], 'base64');
if (raw.length >= 24 && raw.toString('ascii', 1, 4) === 'PNG') {
qrImageWidth = raw.readUInt32BE(16);
qrImageHeight = raw.readUInt32BE(20);
}
}
}
if (status.loggedIn) emitLog('✅ 当前账号已登录,无需扫码');
else emitLog(`已从${redirectedProvider}生成${screenshotKind || '登录截图'}${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请扫码后点“刷新账号状态”。当前 URL:${currentUrl}`);
return { ...status, qrImage, qrCheckedAt: new Date().toISOString(), qrSource: redirectedProvider, qrUrl: currentUrl, qrScreenshotKind: screenshotKind };
else emitLog(`已从${redirectedProvider}生成${screenshotKind || '登录截图'}${qrImageWidth && qrImageHeight ? `${qrImageWidth}×${qrImageHeight}` : ''}${clicked ? '' : '(未找到明确登录按钮,已截取当前页面)'};请扫码后点“刷新账号状态”。当前 URL:${currentUrl}`);
return { ...status, qrImage, qrCheckedAt: new Date().toISOString(), qrSource: redirectedProvider, qrUrl: currentUrl, qrScreenshotKind: screenshotKind, qrImageWidth, qrImageHeight };
}
export async function getLoginStatus(existingPage = null) {
+2 -1
View File
@@ -240,7 +240,8 @@ function renderLoginQr(data) {
const redirected = data.qrUrl && /login\.taobao\.com|havanaone/.test(data.qrUrl)
? '(已从闲鱼入口跳转到淘宝/阿里统一登录,这是官方登录链路)'
: '';
sub.textContent = `来源${source}${redirected}二维码过期就重新获取。`;
const sizeHint = data.qrImageWidth && data.qrImageHeight ? ` 截图尺寸${data.qrImageWidth}×${data.qrImageHeight}` : '';
sub.textContent = `来源:${source}${redirected}。二维码过期就重新获取。${sizeHint}`;
}
if (data.qrImage) {
img.src = data.qrImage;
+2 -2
View File
@@ -1,2 +1,2 @@
99b481d82309ef94dd2ad457dbe6ba189d2747d308d2b00bea4abd03ed8a3752 xianyu-hunter-hermes-skill.zip
16013f7bde07a95438471b17d40f1da45310298457b0520cfa85858c8fa2a75c xianyu-hunter-hermes-skill.tar.gz
843573dcab04d50d4a3c2f9d91b84f9f9412081b601ebb4c7954a4d854ecace9 xianyu-hunter-hermes-skill.tar.gz
75799d321e7dfbe068f2e39f457bfed00091625108e855e86b9680f045ef4208 xianyu-hunter-hermes-skill.zip
Binary file not shown.
Binary file not shown.
+3 -1
View File
@@ -330,7 +330,7 @@ Session-specific implementation notes are captured in `references/web-login-cook
Cookie JSON import timeout and verification notes are captured in `references/cookie-json-import-timeout-2026-05.md`.
Web dashboard QR-login and account status panel implementation notes are captured in `references/web-qr-login-account-panel-2026-05.md`.
Web dashboard QR-login returning a tiny 24x24 icon instead of the real QR, and the minimum-size selector fix, are captured in `references/qr-login-tiny-icon-fix-2026-05-07.md`.
BOSS later simplified the preferred login UX: the visible Web login modal should expose only **获取/刷新登录二维码** and **刷新账号状态**; hide legacy buttons such as manual browser open, Cookie JSON upload, wait-detect, and close-session from the normal flow. Details are captured in `references/web-login-simplified-buttons-2026-05.md`.
@@ -338,6 +338,8 @@ Login UX, persistent-profile lock recovery (`ProcessSingleton` / `SingletonLock`
Web task editing, manual save-only tasks, scheduling, `maxItems`, AI-optimize fallback, API smoke tests, and stale port restart pitfalls are captured in `references/web-task-editing-scheduling-ai-optimize-2026-05.md`.
Existing task schedule updates through the running API, including the 6-hour monitor pattern and API/disk verification, are captured in `references/existing-task-schedule-api-2026-05.md`.
Web AI optimize fallback and automatic Hermes analysis/notification are captured in `references/web-ai-fallback-and-auto-analysis-2026-05.md`.
Web cache cleanup implementation notes are captured in `references/web-cache-cleanup-2026-05.md`.
@@ -0,0 +1,88 @@
# Existing task schedule update via API (2026-05)
## Trigger
BOSS asks to change an existing xianyu-hunter monitor task to run periodically, e.g. “把闲鱼监控中的显示器任务改为每6个小时运行一次”.
## Proven workflow
Use the running local service API first so in-memory scheduler state and disk persistence stay consistent. Do **not** edit `data/tasks.json` directly unless the service is stopped or API is unavailable.
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
python3 - <<'PY'
import json, time, urllib.request
base='http://127.0.0.1:3000'
needle='显示器'
interval_minutes=360
tasks=json.loads(urllib.request.urlopen(base+'/api/tasks', timeout=5).read().decode())
matches=[t for t in tasks if needle in t.get('name','') or any(needle in str(q) for q in t.get('config',{}).get('queries',[]))]
if not matches:
raise SystemExit(f'未找到任务: {needle}')
matches.sort(key=lambda t: t.get('updatedAt') or t.get('createdAt') or 0, reverse=True)
t=matches[0]
config=dict(t.get('config') or {})
config['name']=t.get('name')
config['scheduleEnabled']=True
config['scheduleIntervalMinutes']=interval_minutes
# Let updateTask/_normalizeSchedule compute nextRunAt from now when missing/expired.
config.pop('scheduleNextRunAt', None)
config.pop('scheduleLastRunAt', None)
req=urllib.request.Request(
base+f"/api/tasks/{t['id']}",
data=json.dumps(config).encode(),
headers={'Content-Type':'application/json'},
method='PUT',
)
updated=json.loads(urllib.request.urlopen(req, timeout=10).read().decode())
s=updated.get('config',{}).get('schedule',{})
print(json.dumps({
'id': updated.get('id'),
'name': updated.get('name'),
'stage': updated.get('stage'),
'running': updated.get('running'),
'schedule': s,
'nextRunLocal': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime((s.get('nextRunAt') or 0)/1000)) if s.get('nextRunAt') else None,
'intervalHours': (s.get('intervalMinutes') or 0)/60,
}, ensure_ascii=False, indent=2))
PY
```
## Verification
Verify both the API/in-memory state and `data/tasks.json` disk state:
```bash
python3 - <<'PY'
import json, time, urllib.request
base='http://127.0.0.1:3000'
task_id='<task_id>'
for source, arr in [
('API', json.loads(urllib.request.urlopen(base+'/api/tasks', timeout=5).read().decode())),
('DISK', json.loads(open('data/tasks.json').read())),
]:
for t in arr:
if t.get('id') == task_id:
s=t.get('config',{}).get('schedule',{})
print(source, json.dumps({
'id': t.get('id'),
'name': t.get('name'),
'enabled': s.get('enabled'),
'intervalMinutes': s.get('intervalMinutes'),
'nextRunAt': s.get('nextRunAt'),
'nextRunLocal': time.strftime('%Y-%m-%d %H:%M:%S', time.localtime((s.get('nextRunAt') or 0)/1000)) if s.get('nextRunAt') else None,
}, ensure_ascii=False))
PY
```
Also poll the service/process or check `lsof -nP -iTCP:3000 -sTCP:LISTEN` so the scheduler is actually running.
## Pitfalls
- `scheduleEnabled` and `scheduleIntervalMinutes` are accepted by `updateTask()` and normalized into nested `config.schedule.{enabled,intervalMinutes,nextRunAt,lastRunAt}`.
- The scheduler checks every 30 seconds and triggers due tasks with `startTask(task.id, { reset: true, scheduled: true })`.
- Direct disk edits can be overwritten by the running server and will not update the in-memory scheduler until restart.
- If multiple tasks match the keyword, pick the most recently updated one only after printing matches, or ask BOSS if ambiguity matters.
@@ -110,6 +110,31 @@ git remote set-url origin "$GITEA_URL/$OWNER/$REPO.git"
git remote -v
```
## Additional verified sync: per-item notifications + clickable links
Commit pushed during the notification-link fix:
```text
ad48715664900040e8b92f3d987357694fea2835
fix: make xianyu notifications per-item with clickable links
```
Representative files to raw-verify for this class of update:
```bash
for f in \
project/xianyu-hunter/lib/links.mjs \
project/xianyu-hunter/lib/notifier.mjs \
skill/xianyu-hunter-monitor/references/per-item-notifications-feishu-links-2026-05.md \
project/xianyu-hunter/data/notify-config.example.json; do
curl -sS -L -o /tmp/gitea_raw_check \
-w "$f HTTP %{http_code} size=%{size_download}\n" \
"$GITEA_URL/$OWNER/$REPO/raw/branch/main/$f"
done
```
Also confirm `project/xianyu-hunter/lib/notifier.mjs` raw content contains `perItem`, `maxNotifyItems`, and `打开闲鱼商品`.
## Post-push verification
```bash
@@ -10,11 +10,11 @@ BOSS 希望在每商品单独推送之后,增加一个“单次运行最多推
- 默认:`10`
- 保存时限制范围:`150`
- 位置:`data/notify-config.json`
- 后端:`lib/notifier.mjs`
- `DEFAULT_CONFIG.maxNotifyItems = 10`
- `normalizeNotifyConfig()` 同时用于读取和保存,确保旧配置文件缺少字段时也能返回默认值
- `sendNotifications()` 调用 `buildNotificationMessages()` 时传入 `cfg.maxNotifyItems`
- `selectNotificationItems()` 对符合条件的候选 `.slice(0, maxNotifyItems)`
- 后端:`lib/analyzer.mjs` + `lib/notifier.mjs`
- `buildAnalysis()` 会先按 `hermesAnalysis.score` 从高到低排序:`sort((a, b) => b.score - a.score)`,再生成 `summary.topItems`
- `sendNotifications()`/`selectNotificationItems()` 从已经排序好的 `summary.topItems` 里筛选 `recommend` 或达到 `minScore` 的商品,再 `.slice(0, maxNotifyItems)`
- 因此单次最大推送数语义是:**按评分高到低,最多推送满足通知条件的前 N 个商品**
- 如果未来改动 `summary.topItems` 来源,必须保留“先按评分降序排序,再截断推送数量”的顺序,避免低分商品占用推送名额
- 前端:`public/index.html` + `public/app.js`
- 通知设置弹窗增加输入框:`单次最多推送商品数`
- `loadNotifyConfig()` 回填 `n-max-items`
@@ -0,0 +1,66 @@
# QR login tiny-icon filtering fix — 2026-05-07
## Trigger
BOSS reported that after the xianyu/goofish cookie expired, the Web dashboard failed to correctly refresh the login QR code.
## Root cause
`POST /api/login/qr` returned `qrScreenshotKind: "二维码区域"`, but the returned PNG was only `24x24`. The QR selector logic accepted the first visible `canvas`/QR-looking image, which on Goofish can be a tiny QR-mode switch icon rather than the actual scannable QR code. The frontend then displayed that tiny icon as if it were the login QR.
Evidence from API before fix:
- `qrImage: true`
- PNG size: `24 x 24`
- source URL: `https://www.goofish.com/`
## Fix pattern
In `lib/login.mjs`:
- Add `isUsableQrCandidate(loc)` using `boundingBox()`.
- Reject QR candidates smaller than `120 x 120`.
- Iterate through multiple candidates instead of taking `.first()`.
- Use the same minimum-size filter for modal/container fallback.
- Return `qrImageWidth` and `qrImageHeight` parsed from PNG bytes so the Web UI/logs can expose screenshot dimensions.
In `public/app.js`:
- Display screenshot dimensions in the QR panel hint when present.
## Verification
After killing stale port 3000 server/Chromium profile users and restarting:
```bash
cd /Users/chick/.Hermes/workspace/research/xianyu-hunter
node --check lib/login.mjs
node --check server.mjs
node --check public/app.js
curl -sS -m 120 -X POST http://127.0.0.1:3000/api/login/qr -H 'Content-Type: application/json' -d '{}' > /tmp/xianyu_login_qr_fixed.json
```
Expected key fields:
```json
{
"ok": true,
"loggedIn": false,
"qrScreenshotKind": "二维码区域",
"qrImageWidth": 148,
"qrImageHeight": 148
}
```
Vision/browser verification confirmed the Web modal displays a real black/white QR code with the yellow Xianyu logo, not a tiny icon.
## Operational note
If the API still returns `24x24` after patching, the old Node server is still serving port 3000. `tools/xianyu_ops.mjs stop` may point to stale `.runtime/server.json`; kill the actual listener:
```bash
for p in $(lsof -tiTCP:3000 -sTCP:LISTEN 2>/dev/null); do kill "$p" 2>/dev/null || true; done
pkill -f 'Google Chrome for Testing.*xianyu-hunter/browser-data' || true
```
Then restart the server and verify `/api/login/qr` again.