mirror of
https://github.com/geekgeekrun/geekgeekrun.git
synced 2026-05-26 10:40:18 +08:00
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:
30
packages/sqlite-plugin/src/entity/CandidateContactLog.ts
Normal file
30
packages/sqlite-plugin/src/entity/CandidateContactLog.ts
Normal 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;
|
||||
}
|
||||
73
packages/sqlite-plugin/src/entity/CandidateInfo.ts
Normal file
73
packages/sqlite-plugin/src/entity/CandidateInfo.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"sourceMap": false,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"exclude": ["node_modules"],
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user