diff --git a/project/xianyu-hunter/lib/login.mjs b/project/xianyu-hunter/lib/login.mjs index f5ab137..a65f8a6 100644 --- a/project/xianyu-hunter/lib/login.mjs +++ b/project/xianyu-hunter/lib/login.mjs @@ -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) { diff --git a/project/xianyu-hunter/public/app.js b/project/xianyu-hunter/public/app.js index 027e8eb..90a61b2 100644 --- a/project/xianyu-hunter/public/app.js +++ b/project/xianyu-hunter/public/app.js @@ -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; diff --git a/releases/SHA256SUMS.txt b/releases/SHA256SUMS.txt index 8944b69..3820599 100755 --- a/releases/SHA256SUMS.txt +++ b/releases/SHA256SUMS.txt @@ -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 diff --git a/releases/xianyu-hunter-hermes-skill.tar.gz b/releases/xianyu-hunter-hermes-skill.tar.gz index 6c03b87..9d9a249 100644 Binary files a/releases/xianyu-hunter-hermes-skill.tar.gz and b/releases/xianyu-hunter-hermes-skill.tar.gz differ diff --git a/releases/xianyu-hunter-hermes-skill.zip b/releases/xianyu-hunter-hermes-skill.zip index d2a05e2..4e6b66c 100644 Binary files a/releases/xianyu-hunter-hermes-skill.zip and b/releases/xianyu-hunter-hermes-skill.zip differ diff --git a/skill/xianyu-hunter-monitor/SKILL.md b/skill/xianyu-hunter-monitor/SKILL.md index 275d242..eb50704 100644 --- a/skill/xianyu-hunter-monitor/SKILL.md +++ b/skill/xianyu-hunter-monitor/SKILL.md @@ -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`. diff --git a/skill/xianyu-hunter-monitor/references/existing-task-schedule-api-2026-05.md b/skill/xianyu-hunter-monitor/references/existing-task-schedule-api-2026-05.md new file mode 100644 index 0000000..36d3ffe --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/existing-task-schedule-api-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='' +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. diff --git a/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md b/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md index 76b48e1..fb0cf18 100644 --- a/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md +++ b/skill/xianyu-hunter-monitor/references/gitea-portable-package-sync-2026-05.md @@ -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 diff --git a/skill/xianyu-hunter-monitor/references/max-notify-items-config-2026-05.md b/skill/xianyu-hunter-monitor/references/max-notify-items-config-2026-05.md index e057a6d..ac377c5 100644 --- a/skill/xianyu-hunter-monitor/references/max-notify-items-config-2026-05.md +++ b/skill/xianyu-hunter-monitor/references/max-notify-items-config-2026-05.md @@ -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`。 diff --git a/skill/xianyu-hunter-monitor/references/qr-login-tiny-icon-fix-2026-05-07.md b/skill/xianyu-hunter-monitor/references/qr-login-tiny-icon-fix-2026-05-07.md new file mode 100644 index 0000000..ef5debb --- /dev/null +++ b/skill/xianyu-hunter-monitor/references/qr-login-tiny-icon-fix-2026-05-07.md @@ -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.