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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
@@ -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`
|
||||
- 保存时限制范围:`1–50`
|
||||
- 位置:`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.
|
||||
Reference in New Issue
Block a user