recruiter: add boss auto browse/chat flows, webhook, and candidate tables

- Add recruiter-side automation core and run-core entry
- Extend sqlite-plugin with candidate info + contact logs
- Add UI routes/pages, IPC handlers, progress + log panel
- Document current status and plans under plan/

Made-with: Cursor
This commit is contained in:
rqi14
2026-03-18 17:37:24 +08:00
parent 4048e3b323
commit 95c1e54c66
73 changed files with 15053 additions and 89 deletions

View File

@@ -0,0 +1,30 @@
import * as typeorm from 'typeorm';
const { Entity, Column, PrimaryGeneratedColumn } = typeorm;
@Entity()
export class CandidateContactLog {
@PrimaryGeneratedColumn()
id: number;
@Column()
encryptGeekId: string;
@Column()
contactType: string;
@Column({
nullable: true
})
message: string | null;
@Column({
nullable: true
})
result: string | null;
@Column()
contactTime: Date;
@Column()
createdAt: Date;
}

View File

@@ -0,0 +1,73 @@
import * as typeorm from 'typeorm';
const { Entity, Column, PrimaryGeneratedColumn } = typeorm;
@Entity()
export class CandidateInfo {
@PrimaryGeneratedColumn()
id: number;
@Column({
unique: true
})
encryptGeekId: string;
@Column()
geekName: string;
@Column({
nullable: true
})
educationLevel: string | null;
@Column({
nullable: true
})
workExpYears: string | null;
@Column({
nullable: true
})
city: string | null;
@Column({
nullable: true
})
jobTitle: string | null;
@Column({
nullable: true
})
salaryExpect: string | null;
@Column({
nullable: true
})
skills: string | null;
@Column({
nullable: true
})
firstContactTime: Date | null;
@Column({
nullable: true
})
lastContactTime: Date | null;
@Column({
default: 'new'
})
status: string;
@Column({
type: 'text',
nullable: true
})
rawData: string | null;
@Column()
createdAt: Date;
@Column()
updatedAt: Date;
}

View File

@@ -12,6 +12,8 @@ import { MarkAsNotSuitLog } from "./entity/MarkAsNotSuitLog";
import { ChatMessageRecord } from "./entity/ChatMessageRecord";
import { LlmModelUsageRecord } from "./entity/LlmModelUsageRecord";
import { JobHireStatusRecord } from "./entity/JobHireStatusRecord";
import { CandidateInfo } from "./entity/CandidateInfo";
import { CandidateContactLog } from "./entity/CandidateContactLog";
function getBossInfoIfIsEqual (savedOne, currentOne) {
if (savedOne === currentOne) {
@@ -387,4 +389,67 @@ export async function getJobHireStatusRecord(
}
})
return result
}
// --- Candidate (recruiter side) handlers ---
export async function createOrUpdateCandidateInfo(
ds: DataSource,
payload: Partial<CandidateInfo>
) {
const repo = ds.getRepository(CandidateInfo);
const now = new Date();
const existing = payload.encryptGeekId
? await repo.findOne({ where: { encryptGeekId: payload.encryptGeekId } })
: null;
if (existing) {
Object.assign(existing, payload, { updatedAt: now });
return await repo.save(existing);
}
const entity = new CandidateInfo();
Object.assign(entity, payload, { createdAt: now, updatedAt: now });
return await repo.save(entity);
}
export async function insertCandidateContactLog(
ds: DataSource,
payload: Partial<CandidateContactLog>
) {
const repo = ds.getRepository(CandidateContactLog);
const entity = new CandidateContactLog();
const now = new Date();
Object.assign(entity, payload, { createdAt: now });
return await repo.save(entity);
}
export async function queryCandidateByEncryptId(
ds: DataSource,
encryptGeekId: string
) {
const repo = ds.getRepository(CandidateInfo);
return await repo.findOne({
where: { encryptGeekId }
});
}
/** Recent contact logs for recruiter webhook "last run" payload. Returns unique encryptGeekIds by most recent contact first. */
export async function getRecentCandidateContactLogs(
ds: DataSource,
limit = 50
): Promise<Array<{ encryptGeekId: string; contactTime: Date }>> {
const repo = ds.getRepository(CandidateContactLog);
const rows = await repo.find({
order: { contactTime: 'DESC' },
take: limit * 2,
select: ['encryptGeekId', 'contactTime']
});
const seen = new Set<string>();
const result: Array<{ encryptGeekId: string; contactTime: Date }> = [];
for (const r of rows) {
if (seen.has(r.encryptGeekId)) continue;
seen.add(r.encryptGeekId);
result.push({ encryptGeekId: r.encryptGeekId, contactTime: r.contactTime });
if (result.length >= limit) break;
}
return result;
}

View File

@@ -20,6 +20,8 @@ import { VMarkAsNotSuitLog } from "./entity/VMarkAsNotSuitLog"
import { ChatMessageRecord } from './entity/ChatMessageRecord'
import { LlmModelUsageRecord } from './entity/LlmModelUsageRecord'
import { JobHireStatusRecord } from './entity/JobHireStatusRecord'
import { CandidateInfo } from './entity/CandidateInfo'
import { CandidateContactLog } from './entity/CandidateContactLog'
import {
saveChatStartupRecord,
@@ -38,6 +40,7 @@ import { AddColumnForMarkAsNotSuitLog1746092370665 } from "./migrations/17460923
import { Init1000000000000 } from "./migrations/1000000000000-Init";
import { AddJobSourceColumnForChatStartupLogAndMarkAsNotSuitLog1752380078526 } from "./migrations/1752380078526-AddJobSourceColumnForChatStartupLogAndMarkAsNotSuitLog";
import { AddJobHireStatusTable1766466476822 } from "./migrations/1766466476822-AddJobHireStatusTable";
import { AddCandidateTables1766466476823 } from "./migrations/1766466476823-AddCandidateTables";
import chunk from 'lodash/chunk'
import * as typeorm from 'typeorm'
@@ -69,6 +72,8 @@ export function initDb(dbFilePath) {
ChatMessageRecord,
LlmModelUsageRecord,
JobHireStatusRecord,
CandidateInfo,
CandidateContactLog,
],
migrations: [
Init1000000000000,
@@ -76,7 +81,8 @@ export function initDb(dbFilePath) {
UpdateBossInfoTable1732032381304,
AddColumnForMarkAsNotSuitLog1746092370665,
AddJobSourceColumnForChatStartupLogAndMarkAsNotSuitLog1752380078526,
AddJobHireStatusTable1766466476822
AddJobHireStatusTable1766466476822,
AddCandidateTables1766466476823
],
migrationsRun: true
});
@@ -95,26 +101,30 @@ export default class SqlitePlugin {
userInfo = null
apply(hooks) {
hooks.pageGotten.tap(
'SqlitePlugin',
(page) => {
page.on('response', async (response) => {
const ds = await this.initPromise;
if (response.url().startsWith('https://www.zhipin.com/wapi/zpgeek/job/detail.json')) {
const data = await response.json()
if (data.code === 0) {
await saveJobInfoFromRecommendPage(await ds, data.zpData)
await saveJobHireStatusRecord(await ds, {
encryptJobId: data.zpData.jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
})
// 仅当调用方提供对应 hook 时才注册geek 端有 pageGotten/userInfoResponse 等,招聘端是 beforeBrowserLaunch/onCandidateListLoaded 等,无则跳过)
if (hooks.pageGotten) {
hooks.pageGotten.tap(
'SqlitePlugin',
(page) => {
page.on('response', async (response) => {
const ds = await this.initPromise;
if (response.url().startsWith('https://www.zhipin.com/wapi/zpgeek/job/detail.json')) {
const data = await response.json()
if (data.code === 0) {
await saveJobInfoFromRecommendPage(await ds, data.zpData)
await saveJobHireStatusRecord(await ds, {
encryptJobId: data.zpData.jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
})
}
}
}
})
}
)
hooks.userInfoResponse.tapPromise(
})
}
)
}
if (hooks.userInfoResponse) {
hooks.userInfoResponse.tapPromise(
"SqlitePlugin",
async (userInfoResponse) => {
if (!userInfoResponse || userInfoResponse.code !== 0) {
@@ -134,7 +144,9 @@ export default class SqlitePlugin {
return await userInfoRepository.save(user);
}
);
hooks.mainFlowWillLaunch.tapPromise(
}
if (hooks.mainFlowWillLaunch) {
hooks.mainFlowWillLaunch.tapPromise(
"SqlitePlugin",
async ({
jobNotMatchStrategy,
@@ -212,40 +224,47 @@ export default class SqlitePlugin {
}
}
);
}
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async (_jobInfo) => {
const ds = await this.initPromise;
await saveJobInfoFromRecommendPage(ds, _jobInfo);
});
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async ({ jobInfo }) => {
const ds = await this.initPromise;
return await saveJobHireStatusRecord(ds, {
encryptJobId: jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
if (hooks.jobDetailIsGetFromRecommendList) {
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async (_jobInfo) => {
const ds = await this.initPromise;
await saveJobInfoFromRecommendPage(ds, _jobInfo);
});
});
hooks.newChatStartup.tapPromise("SqlitePlugin", async (_jobInfo, { chatStartupFrom = ChatStartupFrom.AutoFromRecommendList, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveChatStartupRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
chatStartupFrom,
jobSource
hooks.jobDetailIsGetFromRecommendList.tapPromise("SqlitePlugin", async ({ jobInfo }) => {
const ds = await this.initPromise;
return await saveJobHireStatusRecord(ds, {
encryptJobId: jobInfo.encryptId,
hireStatus: JobHireStatus.HIRING,
lastSeenDate: new Date()
});
});
});
}
hooks.jobMarkedAsNotSuit.tapPromise("SqlitePlugin", async (_jobInfo, { markFrom = ChatStartupFrom.AutoFromRecommendList, markReason = undefined, extInfo = undefined, markOp = undefined, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveMarkAsNotSuitRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
markFrom,
markReason,
extInfo,
markOp,
jobSource
if (hooks.newChatStartup) {
hooks.newChatStartup.tapPromise("SqlitePlugin", async (_jobInfo, { chatStartupFrom = ChatStartupFrom.AutoFromRecommendList, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveChatStartupRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
chatStartupFrom,
jobSource
});
});
});
}
if (hooks.jobMarkedAsNotSuit) {
hooks.jobMarkedAsNotSuit.tapPromise("SqlitePlugin", async (_jobInfo, { markFrom = ChatStartupFrom.AutoFromRecommendList, markReason = undefined, extInfo = undefined, markOp = undefined, jobSource = undefined } = {}) => {
const ds = await this.initPromise;
return await saveMarkAsNotSuitRecord(ds, _jobInfo, this.userInfo, {
autoStartupChatRecordId: this.runRecordId,
markFrom,
markReason,
extInfo,
markOp,
jobSource
});
});
}
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from "typeorm";
export class AddCandidateTables1766466476823 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "candidate_info" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "encryptGeekId" varchar UNIQUE NOT NULL, "geekName" varchar NOT NULL, "educationLevel" varchar, "workExpYears" varchar, "city" varchar, "jobTitle" varchar, "salaryExpect" varchar, "skills" varchar, "firstContactTime" datetime, "lastContactTime" datetime, "status" varchar NOT NULL DEFAULT 'new', "rawData" text, "createdAt" datetime NOT NULL, "updatedAt" datetime NOT NULL);`
);
await queryRunner.query(
`CREATE TABLE IF NOT EXISTS "candidate_contact_log" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "encryptGeekId" varchar NOT NULL, "contactType" varchar NOT NULL, "message" varchar, "result" varchar, "contactTime" datetime NOT NULL, "createdAt" datetime NOT NULL);`
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
}
}

View File

@@ -7,6 +7,7 @@
"emitDecoratorMetadata": true,
"experimentalDecorators": true,
"sourceMap": false,
"skipLibCheck": true
},
"exclude": ["node_modules"],
}