Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 34 additions & 1 deletion chrome-extension/background.js
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ const PLATFORM_SHARED_SCAN_KEYS = {
boss: ["__GET_JOBS_BOSS_SHARED_SCAN_TASK__", "__GET_JOBS_BOSS_SHARED_SCAN_CANCEL__"],
zhilian: ["__GET_JOBS_ZHILIAN_SHARED_SCAN_TASK__", "__GET_JOBS_ZHILIAN_SHARED_SCAN_CANCEL__"]
};
const BACKGROUND_VERSION = "2026-09-07-modern-collection";
const BACKGROUND_VERSION = "2026-09-08-scan-controls";
let zhilianPagePreparation = null;
const CONTENT_READY_RETRIES = 12;
const CONTENT_READY_INTERVAL_MS = 250;
const TAB_LOAD_TIMEOUT_MS = 10000;
Expand Down Expand Up @@ -1873,6 +1874,13 @@ async function postPlatformProgress(pageTabId, payload) {
}

async function queryZhilianPageStatus(message, pageTabId) {
if (message.openIfMissing === true) {
if (!zhilianPagePreparation) {
zhilianPagePreparation = prepareZhilianPageStatus(message, pageTabId)
.finally(() => { zhilianPagePreparation = null; });
}
return await zhilianPagePreparation;
}
const tabs = (await chrome.tabs.query({}))
.filter(tab => isSupportedUrl(tab.url || tab.pendingUrl || "", PLATFORM_CONFIG.zhilian))
.sort((a, b) => Number(b.lastAccessed || 0) - Number(a.lastAccessed || 0));
Expand All @@ -1889,6 +1897,31 @@ async function queryZhilianPageStatus(message, pageTabId) {
|| statuses[0];
}

async function prepareZhilianPageStatus(message, pageTabId) {
const passiveMessage = { ...message, openIfMissing: false };
try {
let tabs = (await chrome.tabs.query({}))
.filter(tab => isSupportedUrl(tab.url || tab.pendingUrl || "", PLATFORM_CONFIG.zhilian));
if (!tabs.length) {
// Use a fixed official URL; page messages cannot choose an arbitrary destination.
tabs = [await chrome.tabs.create({ url: "https://www.zhaopin.com/jobs?jl=489", active: true })];
}
await Promise.all(tabs.filter(tab => tab.status === "loading").map(tab =>
waitForSupportedTab(tab.id, PLATFORM_CONFIG.zhilian).catch(() => null)
));
const startedAt = Date.now();
let status;
do {
status = await queryZhilianPageStatus(passiveMessage, pageTabId);
if (status.chromePageReady || status.hasLoginPrompt || status.hasSecurityPrompt || status.pageState === "NO_TAB") return status;
await sleep(CONTENT_READY_INTERVAL_MS);
} while (Date.now() - startedAt < 8000);
return { ...status, message: status.message || "智联页面已打开,但尚未就绪,请等待页面加载完成后重试" };
} catch (error) {
return { success: false, chromePageReady: false, message: `自动打开智联页面失败:${error?.message || String(error)}` };
}
}

async function queryPassivePlatformStatus(tabId, platform, message, pageTabId) {
if (message?.type === "BOSS_PAGE_STATUS" || platform === "zhilian") {
try {
Expand Down
2 changes: 1 addition & 1 deletion chrome-extension/manifest.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"manifest_version": 3,
"name": "投递牛马 Chrome Bridge",
"version": "1.6.7",
"version": "1.6.8",
"key": "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzzdIlNVOv76Y/cSWrjD5Tg2Vlsha8yWHzsn46PBsg724/2dftOUzIIr2n70VRaRgGwEd8FjO/Y768Ori443zF4pQpWvuxXxm05YO25ILQ/+aJLmUycAEdWbkdhcagr4YXnXJdYlSCGSAToSQBjk+owQOdlBLQn5wofPoshrqayoJjRQ5aAUj1SuSlnNv9iimle8GMA1IaA1l5rw6K/chfcgwMTg6HxRAIoludt5JGbIBryi2Lu1hOJRMaDnL7A57ofBnn3qx3H2HIGWGkkTW9EMkls0XMXwx8+mJVIj5HSYl0EeuCvEoTa1W3i1CbOf3kY2yCPKS3Qz3lOvJiwJ4ZQIDAQAB",
"description": "Use signed-in Chrome tabs to scan jobs, confirm deliveries, and review BOSS HR reply drafts for 投递牛马.",
"icons": {
Expand Down
35 changes: 35 additions & 0 deletions chrome-extension/tests/background-tab-routing.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,41 @@ test("Zhilian missing and loading pages remain unavailable without opening a tab
assert.equal(loading.sentMessages.length, 0);
});

test("explicit Zhilian preflight opens one official page for concurrent requests and reuses it", async () => {
const harness = loadBackground({ tabs: [], statuses: { 1: { success: true, chromePageReady: true } } });
const message = { type: "ZHILIAN_PAGE_STATUS", platform: "zhilian", openIfMissing: true, startUrl: "https://example.com/" };
const results = await Promise.all([harness.context.queryZhilianPageStatus(message, 20), harness.context.queryZhilianPageStatus(message, 20)]);
assert.ok(results.every(result => result.chromePageReady));
assert.equal(harness.tabList.length, 1);
assert.equal(harness.tabList[0].url, "https://www.zhaopin.com/jobs?jl=489");
await harness.context.queryZhilianPageStatus(message, 20);
assert.equal(harness.tabList.length, 1);
assert.equal(harness.tabUpdates.length, 0);
});

test("explicit Zhilian preflight waits for loading pages without navigating or bypassing login", async () => {
const harness = loadBackground({ tabs: [{ id: 1, url: "https://www.zhaopin.com/jobs", status: "loading" }], statuses: { 1: { success: true, chromePageReady: false, hasLoginPrompt: true } } });
let waited = false;
harness.context.waitForSupportedTab = async () => { waited = true; harness.tabList[0].status = "complete"; };
const status = await harness.context.queryZhilianPageStatus({ type: "ZHILIAN_PAGE_STATUS", openIfMissing: true }, 20);
assert.equal(waited, true);
assert.equal(status.hasLoginPrompt, true);
assert.equal(status.chromePageReady, false);
assert.equal(harness.tabList.length, 1);
assert.equal(harness.tabUpdates.length, 0);
});

test("auto-opened Zhilian page retains security checks and returns creation failures", async () => {
const harness = loadBackground({ tabs: [], statuses: { 1: { success: true, chromePageReady: false, hasSecurityPrompt: true } } });
const message = { type: "ZHILIAN_PAGE_STATUS", openIfMissing: true };
assert.equal((await harness.context.queryZhilianPageStatus(message, 20)).hasSecurityPrompt, true);
const failed = loadBackground({ tabs: [] });
failed.context.chrome.tabs.create = async () => { throw new Error("cannot create tab"); };
const status = await failed.context.queryZhilianPageStatus(message, 20);
assert.equal(status.success, false);
assert.match(status.message, /cannot create tab/);
});


test("rejects job navigation originating from a chat tab", async () => {
const { context, tabUpdates }=loadBackground({tabs:[{id:7,url:"https://www.zhipin.com/web/geek/chat",status:"complete"}]});
Expand Down
2 changes: 1 addition & 1 deletion chrome-extension/tests/boss-hr-assistant.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ test("manifest loads the direct HR bridge and one-minute alarm capability", () =
const bossScripts = manifest.content_scripts.find((entry) => entry.matches.some((value) => value.includes("zhipin.com"))).js;
assert.deepEqual(bossScripts.slice(-3), ["boss-hr-support.js", "boss-hr-bridge.js", "boss-hr-assistant.js"]);
assert.ok(manifest.permissions.includes("alarms"));
assert.equal(manifest.version, "1.6.7");
assert.equal(manifest.version, "1.6.8");
});

test("assistant exposes policy-gated dedicated watch and preserves explicit manual send", () => {
Expand Down
2 changes: 1 addition & 1 deletion chrome-extension/tests/manifest-id.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ function extensionIdFromKey(key) {
test('manifest public key derives the backend allowlisted extension id', () => {
const manifestPath = path.join(__dirname, '..', 'manifest.json');
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
assert.equal(manifest.version, '1.6.7');
assert.equal(manifest.version, '1.6.8');
assert.equal(extensionIdFromKey(manifest.key), EXPECTED_EXTENSION_ID);

const publicKey = crypto.createPublicKey({
Expand Down
4 changes: 2 additions & 2 deletions chrome-extension/tests/profile-scoped-scan-contract.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ test("extension release and both content scripts use the profile-scoped contract
const boss = source("boss-content.js");
const zhilian = source("zhilian-content.js");

assert.equal(manifest.version, "1.6.7");
assert.match(background, /BACKGROUND_VERSION = "2026-09-07-modern-collection"/);
assert.equal(manifest.version, "1.6.8");
assert.match(background, /BACKGROUND_VERSION = "2026-09-08-scan-controls"/);
assert.match(background, /REQUIRED_BOSS_CONTENT_VERSION = "2026-09-06-hr-profile-guard"/);
assert.match(boss, /EXTENSION_VERSION = "2026-09-06-hr-profile-guard"/);
assert.match(zhilian, /EXTENSION_VERSION = "2026-09-07-modern-collection"/);
Expand Down
34 changes: 34 additions & 0 deletions docs/zhilian-scan-controls-repair.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# 智联扫描配置与自动开页修复

## 行为变化

- 共用 Select 原先固定在按钮下方展开。现在根据可见视口上下空间决定方向,限制菜单宽高,并在滚动、窗口与可见视口变化时重新定位。
- 每关键词后台 AI 分析岗位数改为 5 到 200、步长 5 的下拉菜单。原先保存的非 5 倍数值以“已保存”选项保留,选择新值后随扫描请求提交。
- 仅“开始扫描”预检发送 `openIfMissing: true`。扩展没有智联标签页时打开固定官方搜索页,等待加载和页面状态;有页面则复用,不重定向已有页面。并发预检复用同一 Promise,避免重复开页。
- 首页和定时状态检查仍为被动检查。登录提示、安全验证、未知页面状态仍阻止扫描。
- Chrome Bridge 版本升为 1.6.8。没有数据库、配置迁移或投递逻辑变更。

## 验证

在本修复工作树根目录执行:

```powershell
node scripts/validate-chrome-extension.mjs
node --test chrome-extension/tests/*.test.cjs
pnpm --dir front test
pnpm --dir front build
```

- 扩展静态校验:20 个引用文件、16 个 JavaScript 文件通过。
- 扩展测试:121 项通过,包括无页面自动开页、并发去重、已有页面复用、加载等待、登录和安全验证拦截、开页失败,以及被动检查不创建标签页。
- 前端测试:25 个文件、84 项通过,包括菜单底部翻转、小视口限制、选择 25 后扫描请求携带 25 和主动预检标记。
- Next.js 生产构建及 TypeScript 检查通过。
- 独立只读数据预览(18666)使用构建产物,在 1280 × 720 浏览器视口验证底部薪资菜单完全位于视口内,并实际选择岗位数 25。该预览没有连接生产数据库或扩展,不能代替更新后 Chrome 自动开页的实机验收。

## 发布与回滚

本修复以已部署的 PR #53 内容为基线,PR 延续其集成分支 `codex/fix-zhilian-analysis-login-20260907`,避免把未相关的历史功能带入 main。

合并和部署须由用户针对本次修复明确授权。部署时保留现有 RunDock 托管记录及完整配置;更新前端和扩展后,需重新加载 Chrome Bridge 1.6.8 并刷新工作台。在无智联页面的情况下点击开始扫描,验证自动开页后仍通过真实登录检查。

回滚可 revert 本次修复提交,并恢复之前前端产物和 Chrome Bridge 1.6.7;此修复不改数据库,无需回滚岗位或投递记录。
9 changes: 8 additions & 1 deletion front/app/zhilian/page.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import Page from './page'
import { sendChromeBridgeMessage } from '@/lib/chromeBridge'
import { validateSetupForPlatform } from '@/lib/setupChecklist'

vi.mock('@/lib/zhilian-page-status', () => ({ getZhilianPageStatus: async () => ({ connected: true, ready: true, message: 'Chrome 智联可用' }) }))
vi.mock('@/lib/setupChecklist', () => ({ validateSetupForPlatform: async () => ({ ready: true, missing: [] }), formatSetupMissingMessage: () => '' }))
vi.mock('@/lib/setupChecklist', () => ({ validateSetupForPlatform: vi.fn(async () => ({ ready: true, missing: [] })), formatSetupMissingMessage: () => '' }))
vi.mock('@/lib/chromeBridge', () => ({ sendChromeBridgeMessage: vi.fn(async () => ({ success: true })), subscribeChromeBridgeEvents: () => () => {} }))
afterEach(() => vi.unstubAllGlobals())

Expand All @@ -22,8 +23,14 @@ it('配置页不再嵌入分析;启动后提供带档案与批次的独立结
expect(screen.getByRole('link', { name: '智联分析' })).toHaveAttribute('href', '/zhilian/analysis')
const start = await screen.findByRole('button', { name: '开始扫描' })
await waitFor(() => expect(start).toBeEnabled())
fireEvent.click(screen.getByRole('button', { name: '20' }))
const counts = screen.getAllByRole('option').map(option => Number(option.textContent))
expect(counts).toEqual(Array.from({ length: 40 }, (_, index) => (index + 1) * 5))
fireEvent.click(screen.getByRole('option', { name: '25' }))
fireEvent.click(start)
const link = await screen.findByRole('link', { name: '查看本次扫描结果' })
expect(link.getAttribute('href')).toMatch(/\/zhilian\/analysis\?profileId=4&scanRunId=zhilian-/)
expect(vi.mocked(sendChromeBridgeMessage).mock.calls.filter(([message]) => message.type === 'ZHILIAN_SCAN_START')).toHaveLength(1)
expect(validateSetupForPlatform).toHaveBeenCalledWith('zhilian', { openPlatformPageIfMissing: true })
expect(sendChromeBridgeMessage).toHaveBeenCalledWith(expect.objectContaining({ type: 'ZHILIAN_SCAN_START', config: expect.objectContaining({ searchJobLimit: 25 }) }))
})
29 changes: 11 additions & 18 deletions front/app/zhilian/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { API_BASE } from '@/lib/api'
import { BiSave, BiBriefcase, BiPlay, BiStop, BiLinkExternal, BiCodeAlt } from 'react-icons/bi'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select } from '@/components/ui/select'
import Link from 'next/link'
Expand Down Expand Up @@ -423,7 +422,7 @@ export default function ZhilianPage() {
appendProgressLog({ type: 'error', message: '当前档案 ID 无效,请刷新档案后重试。' })
return
}
const setup = await validateSetupForPlatform('zhilian')
const setup = await validateSetupForPlatform('zhilian', { openPlatformPageIfMissing: true })
if (!setup.ready) {
const message = formatSetupMissingMessage('智联招聘', setup.missing)
appendProgressLog({ type: 'error', message })
Expand Down Expand Up @@ -775,24 +774,18 @@ export default function ZhilianPage() {
</div>
<div className="space-y-2">
<Label>每关键词后台 AI 分析岗位数</Label>
<Input
type="number"
min={1}
max={200}
step={1}
placeholder="20"
<Select
value={searchJobLimitInput}
onChange={(e) => {
const rawValue = e.target.value
setSearchJobLimitInput(rawValue)
const parsed = Number(rawValue)
if (Number.isFinite(parsed) && parsed >= 1) {
setConfig((c) => ({ ...c, searchJobLimit: Math.min(Math.floor(parsed), 200) }))
}
}}
onBlur={() => commitSearchJobLimit(searchJobLimitInput)}
onChange={(e) => commitSearchJobLimit(e.target.value)}
disabled={!hasProfile}
/>
>
{Number(searchJobLimitInput) % 5 !== 0 && (
<option value={searchJobLimitInput}>{searchJobLimitInput}(已保存)</option>
)}
{Array.from({ length: 40 }, (_, index) => (index + 1) * 5).map((limit) => (
<option key={limit} value={String(limit)}>{limit}</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label>薪资范围</Label>
Expand Down
38 changes: 38 additions & 0 deletions front/components/ui/select.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { Select } from './select'

afterEach(() => vi.unstubAllGlobals())

it('底部菜单向上展开,并随窗口变化重新定位;选择选项后关闭', () => {
vi.stubGlobal('innerHeight', 600)
vi.stubGlobal('innerWidth', 800)
const onChange = vi.fn()
render(<Select value="20" onChange={onChange}><option value="20">20</option><option value="25">25</option></Select>)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ top: 540, bottom: 580, left: 20, width: 500 } as DOMRect)
fireEvent.click(trigger)
const panel = screen.getByRole('listbox').parentElement!
expect(Number.parseFloat(panel.style.top) + Number.parseFloat(panel.style.maxHeight)).toBeLessThanOrEqual(540)
vi.stubGlobal('innerHeight', 900)
fireEvent.resize(window)
expect(panel.style.top).toBe('588px')
fireEvent.click(screen.getByRole('option', { name: '25' }))
expect(onChange).toHaveBeenCalledWith({ target: { value: '25' } })
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})

it('小窗口菜单限制高度和宽度,并支持 Escape 关闭', () => {
vi.stubGlobal('innerHeight', 180)
vi.stubGlobal('innerWidth', 300)
render(<Select value="20"><option value="20">20</option></Select>)
const trigger = screen.getByRole('button')
vi.spyOn(trigger, 'getBoundingClientRect').mockReturnValue({ top: 50, bottom: 90, left: 260, width: 500 } as DOMRect)
fireEvent.click(trigger)
const panel = screen.getByRole('listbox').parentElement!
expect(panel.style.maxHeight).toBe('74px')
expect(panel.style.width).toBe('284px')
expect(panel.style.left).toBe('8px')
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('listbox')).not.toBeInTheDocument()
})
Loading
Loading