Files
santifer--career-ops/reply-watch.mjs
T
wehub-resource-sync d083df1fdb
CodeQL Analysis / Analyze (javascript-typescript) (push) Failing after 2s
Web CI / web typecheck + build (push) Failing after 1s
Release Please / release-please (push) Failing after 1s
CodeQL Analysis / Analyze (go) (push) Failing after 16s
chore: import upstream snapshot with attribution
2026-07-13 12:02:43 +08:00

267 lines
8.5 KiB
JavaScript

#!/usr/bin/env node
/**
* reply-watch.mjs — Classify employer replies and generate a review digest (RFC #1585).
*
* Reads candidate replies from a JSON file, matches them against the application tracker,
* classifies the reply types (e.g. Interview, Rejected, Noise), and prints a concise
* review digest. Prompts the user to approve recommended tracker status updates.
*
* Usage:
* node reply-watch.mjs [path/to/candidates.json]
*/
import fs from 'node:fs';
import path from 'node:path';
import readline from 'node:readline';
import { fileURLToPath } from 'node:url';
import { matchCandidates, classifyReply } from './reply-matcher.mjs';
import { resolveColumns, parseTrackerRow } from './tracker-parse.mjs';
import { rebuildRow } from './tracker-utils.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_CANDIDATES_PATH = path.join(__dirname, 'data', 'reply-candidates.json');
const APPS_FILE = path.join(__dirname, 'data', 'applications.md');
const FOLLOWUPS_FILE = path.join(__dirname, 'data', 'follow-ups.md');
// Helper to ask a question in the CLI
function askQuestion(query) {
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
return new Promise((resolve) => rl.question(query, (ans) => {
rl.close();
resolve(ans);
}));
}
// Generate custom signal description based on keywords
function getSignalDesc(text, signal) {
const parts = [];
if (text.includes('简历通过')) {
parts.push('resume passed');
}
if (text.includes('微信小程序') || text.includes('WeChat mini-program') || text.includes('AI微信小程序')) {
parts.push('AI WeChat mini-program interview');
}
if (parts.length > 0) {
return parts.join(' + ');
}
return signal || 'none';
}
// Create a default set of mock candidates if the file doesn't exist
function ensureCandidatesFile(filePath) {
if (fs.existsSync(filePath)) return;
const mockCandidates = [
{
message_id: 'msg1',
from: 'recruiter@wingyun.com',
subject: '恭喜简历通过,杭州赢云贸易有限公司邀您面试',
body_snippet: '您的首轮面试是AI微信小程序面试。面试形式:AI微信小程序面试,面试时长:约15~30分钟',
signal: 'interview_invite'
},
{
message_id: 'msg2',
from: 'hr@examplelabs.com',
subject: 'Update on your application for Full-stack Engineer',
body_snippet: '很遗憾地通知您,您的简历与我们当前岗位的需求暂不匹配,不合适我司的要求,未能进入下一轮。',
signal: 'rejection'
},
{
message_id: 'msg3',
from: 'alerts@zhaopin.com',
subject: 'Zhaopin job alert',
body_snippet: '我们为您推荐了以下职位:邀请投递测试工程师岗位,现在沟通,抢面试先机!近期热招职位,立即投递!',
signal: null
},
{
message_id: 'msg4',
from: 'hr@somecompany.com',
subject: '补充信息',
body_snippet: '邀请您在面试/入职之前更新或补充最新的应聘信息。',
signal: null
}
];
fs.mkdirSync(path.dirname(filePath), { recursive: true });
fs.writeFileSync(filePath, JSON.stringify(mockCandidates, null, 2), 'utf-8');
console.log(`Created default mock candidates file at ${filePath}`);
}
// Load applications tracker rows
function loadTrackerApps() {
if (!fs.existsSync(APPS_FILE)) {
return [];
}
const content = fs.readFileSync(APPS_FILE, 'utf-8');
const lines = content.split('\n');
const colmap = resolveColumns(lines);
const apps = [];
for (const line of lines) {
const row = parseTrackerRow(line, colmap);
if (row) {
apps.push(row);
}
}
return apps;
}
// Load followups history
function loadFollowups() {
if (!fs.existsSync(FOLLOWUPS_FILE)) {
return [];
}
const content = fs.readFileSync(FOLLOWUPS_FILE, 'utf-8');
const lines = content.split('\n');
const followups = [];
for (const line of lines) {
if (!line.startsWith('|')) continue;
const parts = line.split('|').map(s => s.trim());
if (parts.length < 8) continue;
const num = parseInt(parts[1], 10);
const appNum = parseInt(parts[2], 10);
if (isNaN(num) || isNaN(appNum)) continue;
followups.push({
num,
appNum,
date: parts[3],
company: parts[4],
role: parts[5],
channel: parts[6],
contact: parts[7],
notes: parts[8] || ''
});
}
return followups;
}
// Update the status of a specific row in the tracker markdown file
function updateTrackerStatus(appNum, newStatus) {
const content = fs.readFileSync(APPS_FILE, 'utf-8');
const lines = content.split('\n');
const colmap = resolveColumns(lines);
let updated = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const row = parseTrackerRow(line, colmap);
if (row && row.num === appNum) {
const parts = line.split('|').map(s => s.trim());
parts[colmap.status] = newStatus;
lines[i] = rebuildRow(parts);
updated = true;
break;
}
}
if (updated) {
fs.writeFileSync(APPS_FILE, lines.join('\n'), 'utf-8');
}
return updated;
}
async function main() {
const candidatesPath = process.argv[2] || DEFAULT_CANDIDATES_PATH;
ensureCandidatesFile(candidatesPath);
if (!fs.existsSync(candidatesPath)) {
console.error(`Error: candidates file not found at ${candidatesPath}`);
process.exit(1);
}
let candidates;
try {
candidates = JSON.parse(fs.readFileSync(candidatesPath, 'utf-8'));
} catch (e) {
console.error(`Error parsing candidates JSON: ${e.message}`);
process.exit(1);
}
const apps = loadTrackerApps();
const followups = loadFollowups();
const matched = matchCandidates(candidates, apps, followups);
console.log(`\nToday: ${candidates.length} application updates need review\n`);
const recommendations = [];
matched.forEach((match, index) => {
const cand = candidates.find(c => c.message_id === match.message_id);
const classification = classifyReply(cand);
let headerStr = '';
if (match.application_num !== null) {
const app = apps.find(a => a.num === match.application_num);
headerStr = `${app.company}${app.role}`;
} else {
headerStr = cand.subject || match.company_hint || cand.from || 'Unknown';
}
console.log(`${index + 1}. ${headerStr}`);
console.log(` Type: ${classification.type}`);
// Print Signal for Interview classification when meaningful
const signalDesc = getSignalDesc(cand.subject + ' ' + cand.body_snippet, cand.signal);
if (classification.type === 'Interview' && signalDesc && signalDesc !== 'none') {
console.log(` Signal: ${signalDesc}`);
}
if (classification.evidence && classification.evidence.length > 0) {
console.log(` Evidence: ${classification.evidence.join('; ')}`);
}
console.log(` Suggested tracker update: ${classification.suggestedTrackerUpdate}`);
console.log('');
if (match.application_num !== null && classification.suggestedTrackerUpdate !== 'none' && classification.suggestedTrackerUpdate !== 'Needs Review') {
const app = apps.find(a => a.num === match.application_num);
if (app && app.status !== classification.suggestedTrackerUpdate) {
recommendations.push({
num: app.num,
company: app.company,
role: app.role,
oldStatus: app.status,
newStatus: classification.suggestedTrackerUpdate
});
}
}
});
if (recommendations.length > 0) {
console.log('Suggested status updates to apply:');
recommendations.forEach(r => {
console.log(` #${r.num} ${r.company} (${r.role}): ${r.oldStatus}${r.newStatus}`);
});
console.log('');
const answer = await askQuestion('Apply recommended status updates to data/applications.md? (y/N): ');
if (answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes') {
for (const r of recommendations) {
updateTrackerStatus(r.num, r.newStatus);
console.log(`Updated #${r.num} to ${r.newStatus}`);
}
console.log('\n✅ All updates written to data/applications.md');
// Sync tracker DB if tracker.mjs exists
try {
const { execSync } = await import('child_process');
execSync('node tracker.mjs sync', { stdio: 'ignore' });
console.log('Synced database index (applications.db).');
} catch (e) {
// ignore
}
} else {
console.log('Updates skipped.');
}
}
}
main().catch(err => {
console.error('Fatal:', err);
process.exit(1);
});