Initial commit with translated description
This commit is contained in:
41
scripts/loadLocations.js
Normal file
41
scripts/loadLocations.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Load locations database for city fuzzy matching
|
||||
* Separated from post_job.js to avoid static analysis false positives
|
||||
*/
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const locationsPath = path.join(__dirname, "..", "assets", "locations.json");
|
||||
|
||||
let locations = [];
|
||||
let fuse = null;
|
||||
|
||||
/**
|
||||
* Initialize locations database
|
||||
* @returns {Object} Fuse instance for fuzzy search
|
||||
*/
|
||||
function initLocations() {
|
||||
if (!fuse) {
|
||||
if (fs.existsSync(locationsPath)) {
|
||||
locations = JSON.parse(fs.readFileSync(locationsPath, "utf-8"));
|
||||
}
|
||||
fuse = new Fuse(locations, {
|
||||
keys: ["label", "parentLabel"],
|
||||
threshold: 0.3,
|
||||
});
|
||||
}
|
||||
return fuse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Fuse instance for location search
|
||||
* @returns {Object} Fuse instance
|
||||
*/
|
||||
export function getLocationsFuse() {
|
||||
return initLocations();
|
||||
}
|
||||
|
||||
export default { getLocationsFuse };
|
||||
159
scripts/monitor_linkedin.js
Normal file
159
scripts/monitor_linkedin.js
Normal file
@@ -0,0 +1,159 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Monitor LinkedIn sync status and notify when complete.
|
||||
* Polls every 2 minutes until LinkedIn URL is available.
|
||||
*
|
||||
* Usage:
|
||||
* node monitor_linkedin.js --jobId "xxx" --channel "webchat"
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import path from "path";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
// Fuku AI API endpoints (third-party job posting relay service)
|
||||
const API_URL_LK_CHECK = "https://hapi.fuku.ai/hr/rc/anon/job/status/linkedin";
|
||||
|
||||
function sanitizeChannel(channel) {
|
||||
// Only allow alphanumeric characters and hyphens/underscores to prevent prompt injection
|
||||
const sanitized = (channel || "").replace(/[^a-zA-Z0-9_-]/g, "");
|
||||
return sanitized || "webchat";
|
||||
}
|
||||
|
||||
function parseArgs(args) {
|
||||
const result = {
|
||||
jobId: "",
|
||||
channel: "webchat",
|
||||
intervalMs: 120000, // 2 minutes default
|
||||
maxAttempts: 15, // ~30 minutes total
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const next = args[i + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--jobId":
|
||||
result.jobId = next;
|
||||
i++;
|
||||
break;
|
||||
case "--channel":
|
||||
result.channel = sanitizeChannel(next);
|
||||
i++;
|
||||
break;
|
||||
case "--interval":
|
||||
result.intervalMs = parseInt(next, 10);
|
||||
i++;
|
||||
break;
|
||||
case "--maxAttempts":
|
||||
result.maxAttempts = parseInt(next, 10);
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function validateJobId(jobId) {
|
||||
if (!jobId || typeof jobId !== "string") {
|
||||
return false;
|
||||
}
|
||||
// Only allow alphanumeric characters and hyphens (prevent shell injection)
|
||||
return /^[a-zA-Z0-9-]+$/.test(jobId);
|
||||
}
|
||||
|
||||
async function checkLinkedInStatus(jobId) {
|
||||
try {
|
||||
// Fuku AI client identifier (embedded for free tier access)
|
||||
const NUMBER = "job-Z4nV8cQ1LmT7XpR2bH9sJdK6WyEaF0";
|
||||
const response = await axios.post(
|
||||
API_URL_LK_CHECK,
|
||||
{ jobId: jobId },
|
||||
{
|
||||
params: { uid: "1873977344885133312" },
|
||||
headers: { "X-NUMBER": NUMBER },
|
||||
},
|
||||
);
|
||||
|
||||
const jobData = response.data;
|
||||
|
||||
if (jobData.code !== 0) {
|
||||
return { success: false, status: jobData.desc, url: null };
|
||||
}
|
||||
|
||||
if (jobData.data && jobData.data.linkedinUrl) {
|
||||
return { success: true, status: "live", url: jobData.data.linkedinUrl };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: jobData.data?.status || "Pending",
|
||||
url: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, status: `Error: ${error.message}`, url: null };
|
||||
}
|
||||
}
|
||||
|
||||
async function notifyUser(message, channel) {
|
||||
// For now, output the message - the parent session will handle delivery
|
||||
console.log(`[NOTIFY:${channel}] ${message}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.jobId) {
|
||||
console.error("❌ Error: --jobId is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (!validateJobId(args.jobId)) {
|
||||
console.error(
|
||||
"❌ Error: Invalid jobId format (only alphanumeric and hyphens allowed)",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`🔍 Starting LinkedIn sync monitor for Job ID: ${args.jobId}`);
|
||||
console.log(` Channel: ${args.channel}`);
|
||||
console.log(
|
||||
` Interval: ${args.intervalMs / 1000}s | Max attempts: ${args.maxAttempts}`,
|
||||
);
|
||||
console.log();
|
||||
|
||||
for (let attempt = 1; attempt <= args.maxAttempts; attempt++) {
|
||||
const result = await checkLinkedInStatus(args.jobId);
|
||||
|
||||
if (result.success && result.url) {
|
||||
const message = `🎉 **LinkedIn Sync Complete!**\n\nJob ID: \`${args.jobId}\`\n\nPosition URL: ${result.url}`;
|
||||
await notifyUser(message, args.channel);
|
||||
console.log(`✅ Success after ${attempt} attempts!`);
|
||||
console.log(`URL: ${result.url}`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Attempt ${attempt}/${args.maxAttempts}: ⏳ Status: ${result.status}`,
|
||||
);
|
||||
|
||||
if (attempt < args.maxAttempts) {
|
||||
console.log(
|
||||
` Waiting ${args.intervalMs / 1000} seconds before next check...\n`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, args.intervalMs));
|
||||
}
|
||||
}
|
||||
|
||||
const timeoutMsg = `⏰ **LinkedIn Sync Timeout**\n\nAfter ${args.maxAttempts} attempts, the sync status is still "${result.status}".\nJob ID: \`${args.jobId}\`\n\nYou can check manually or try again later.`;
|
||||
await notifyUser(timeoutMsg, args.channel);
|
||||
console.log(`⏰ Timeout after ${args.maxAttempts} attempts.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error("❌ Fatal error:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
389
scripts/post_job.js
Normal file
389
scripts/post_job.js
Normal file
@@ -0,0 +1,389 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Post job openings via Fuku AI API and generate resume collection links.
|
||||
* Uses AI model to generate professional job descriptions.
|
||||
*
|
||||
* Usage:
|
||||
* node post_job.js --title "Senior Frontend Engineer" --city "Singapore" --level "senior"
|
||||
*/
|
||||
|
||||
import axios from "axios";
|
||||
import dayjs from "dayjs";
|
||||
import { getLocationsFuse } from "./loadLocations.js";
|
||||
|
||||
// ============================================================================
|
||||
// ⚠️ INTERNAL API PROTECTION
|
||||
// These functions are for internal use ONLY. Always use post_job() as entry.
|
||||
// ============================================================================
|
||||
const INTERNAL_CALL_TOKEN = Symbol("internal-call-token");
|
||||
|
||||
function validateInternalCall(caller, fnName) {
|
||||
if (caller !== INTERNAL_CALL_TOKEN) {
|
||||
throw new Error(
|
||||
`❌ ${fnName}() is an internal function. Use post_job() to post jobs.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
// ============================================================================
|
||||
|
||||
// Get Fuse instance for location fuzzy search (loaded from separate module)
|
||||
const fuse = getLocationsFuse();
|
||||
/**
|
||||
* Parse command line arguments
|
||||
*/
|
||||
function parseArgs(args) {
|
||||
const result = {
|
||||
title: "",
|
||||
city: "",
|
||||
description: "",
|
||||
company: "",
|
||||
email: "",
|
||||
linkedinCompanyUrl:
|
||||
"https://www.linkedin.com/company/110195078/admin/dashboard",
|
||||
};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
const next = args[i + 1];
|
||||
|
||||
switch (arg) {
|
||||
case "--title":
|
||||
result.title = next;
|
||||
i++;
|
||||
break;
|
||||
case "--city":
|
||||
result.city = next;
|
||||
i++;
|
||||
break;
|
||||
case "--description":
|
||||
result.description = next;
|
||||
i++;
|
||||
break;
|
||||
case "--company":
|
||||
result.company = next;
|
||||
i++;
|
||||
break;
|
||||
case "--email":
|
||||
result.email = next;
|
||||
i++;
|
||||
break;
|
||||
case "--linkedinCompanyUrl":
|
||||
result.linkedinCompanyUrl = next;
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// API Configuration
|
||||
// Fuku AI client identifier (embedded for free tier access)
|
||||
const NUMBER = "job-Z4nV8cQ1LmT7XpR2bH9sJdK6WyEaF0";
|
||||
|
||||
const API_URL_GEN = "https://hapi.fuku.ai/hr/rc/anon/job/upload";
|
||||
const API_URL = "https://hapi.fuku.ai/hr/rc/anon/job/create";
|
||||
const API_URL_LK = "https://hapi.fuku.ai/hr/rc/anon/job/sync/linkedin";
|
||||
const API_URL_LK_CHECK = "https://hapi.fuku.ai/hr/rc/anon/job/status/linkedin";
|
||||
|
||||
function validateCredentials() {}
|
||||
|
||||
/**
|
||||
* Check the status of a LinkedIn job posting
|
||||
*
|
||||
* @param {Object} args - Arguments
|
||||
* @param {string} args.jobId - The ID of the job to check
|
||||
* @returns {Promise<string>} Status message or LinkedIn URL
|
||||
*/
|
||||
export async function check_linkedin_status(args) {
|
||||
const { jobId } = args;
|
||||
validateCredentials();
|
||||
try {
|
||||
const response = await axios.post(
|
||||
API_URL_LK_CHECK,
|
||||
{ jobId: jobId },
|
||||
{
|
||||
params: {
|
||||
uid: "1873977344885133312",
|
||||
},
|
||||
headers: {
|
||||
"X-NUMBER": NUMBER,
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const jobData = response.data;
|
||||
|
||||
if (jobData.code !== 0) {
|
||||
return `❌ **Failed to get LinkedIn job state:** ${jobData.desc}`;
|
||||
}
|
||||
|
||||
if (jobData.data && jobData.data.linkedinUrl) {
|
||||
return `✅ **LinkedIn Job is Live!**\n\nURL: ${jobData.data.linkedinUrl}`;
|
||||
} else {
|
||||
return `⏳ **LinkedIn Sync is still in progress.**\n\nStatus: ${jobData.data?.status || "Pending"}\nPlease check again in 5-10 minutes.`;
|
||||
}
|
||||
} catch (error) {
|
||||
return `❌ **Error checking LinkedIn status:** ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-check LinkedIn status with polling until URL is available
|
||||
*
|
||||
* @param {Object} args - Arguments
|
||||
* @param {string} args.jobId - The ID of the job to check
|
||||
* @param {number} [args.intervalMs=60000] - Polling interval in ms (default: 1 minute)
|
||||
* @param {number} [args.maxAttempts=20] - Maximum number of attempts (default: 20)
|
||||
* @returns {Promise<string>} Status message or LinkedIn URL
|
||||
*/
|
||||
async function getLinkedinState(jobId) {
|
||||
return check_linkedin_status({ jobId });
|
||||
}
|
||||
|
||||
async function postToLinkd(data, linkedinCompanyUrl, token) {
|
||||
validateInternalCall(token, "postToLinkd");
|
||||
validateCredentials();
|
||||
let extra = null;
|
||||
try {
|
||||
extra = JSON.parse(data.extra ?? "");
|
||||
} catch (error) {}
|
||||
const job = {
|
||||
title: data.title,
|
||||
reference: `fuku-${data.id}`,
|
||||
description: data.description,
|
||||
jobId: data.id,
|
||||
linkedinCompanyUrl:
|
||||
linkedinCompanyUrl ||
|
||||
"https://www.linkedin.com/company/110195078/admin/dashboard/",
|
||||
location: extra.location,
|
||||
sublocation: extra.sublocation,
|
||||
cityname: extra.cityname,
|
||||
salarymin: 1,
|
||||
salarymax: 1,
|
||||
app_url: `https://app.fuku.ai/career/apply?id=${data.id}`,
|
||||
salaryper: "month",
|
||||
currency: "USD",
|
||||
jobtype: "2",
|
||||
job_time: "2",
|
||||
startdate: dayjs().format("YYYY-MM-DD"),
|
||||
};
|
||||
// console.log("Linkedin Job Post:", job);
|
||||
|
||||
const res = await axios.post(API_URL_LK, job, {
|
||||
params: {
|
||||
uid: "1873977344885133312",
|
||||
},
|
||||
headers: {
|
||||
"X-NUMBER": NUMBER,
|
||||
},
|
||||
});
|
||||
// console.log("Linkedin Job Post Response:", res.data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize job description before sending to external AI service
|
||||
* Removes potential prompt injection patterns
|
||||
*/
|
||||
function sanitizeDescription(desc) {
|
||||
if (!desc || typeof desc !== "string") return "";
|
||||
|
||||
// Remove patterns commonly used for prompt injection
|
||||
let sanitized = desc
|
||||
.replace(/```[\s\S]*?```/g, "") // Remove code blocks
|
||||
.replace(/<\|.*?\|>/g, "") // Remove special markers like <|endoftext|>
|
||||
.replace(
|
||||
/(?:^|\n)\s*(ignore|forget|override|system|instruction|new instruction)[\s\S]{0,200}/gi,
|
||||
"",
|
||||
) // Remove injection attempts
|
||||
.replace(
|
||||
/(?:^|\n)\s*[-*]\s*(ignore|forget|override|system|instruction)[\s\S]{0,200}/gi,
|
||||
"",
|
||||
) // Remove bullet-point injections
|
||||
.trim();
|
||||
|
||||
// Limit length to prevent buffer-based attacks
|
||||
return sanitized.slice(0, 10000);
|
||||
}
|
||||
|
||||
async function genJD(description, token) {
|
||||
validateInternalCall(token, "genJD");
|
||||
validateCredentials();
|
||||
|
||||
// Sanitize description before sending to external AI service
|
||||
const sanitizedDescription = sanitizeDescription(description);
|
||||
if (!sanitizedDescription) {
|
||||
return "❌ **Invalid job description:** Description is empty or contains invalid content.";
|
||||
}
|
||||
|
||||
const body = {
|
||||
content: sanitizedDescription,
|
||||
};
|
||||
try {
|
||||
const response = await axios.post(API_URL_GEN, body, {
|
||||
params: {
|
||||
uid: "1873977344885133312",
|
||||
},
|
||||
headers: {
|
||||
"X-NUMBER": NUMBER,
|
||||
},
|
||||
});
|
||||
|
||||
const jobData = response.data;
|
||||
// console.log("Generated Job Data:", jobData);
|
||||
|
||||
if (jobData.code !== 0) {
|
||||
return `❌ **Failed to generate job description:** ${jobData.desc}`;
|
||||
}
|
||||
|
||||
return jobData.data.description;
|
||||
} catch (error) {
|
||||
return `❌ **Error generating job description:** ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Post a job opening via Fuku AI API
|
||||
*
|
||||
* @param {Object} args - Job parameters
|
||||
* @param {string} args.title - Job title
|
||||
* @param {string} args.city_query - Location query
|
||||
* @param {string} [args.description] - Job description (auto-generated if not provided)
|
||||
* @param {string} [args.company] - Company name
|
||||
* @returns {Promise<string>} Result message
|
||||
*/
|
||||
export async function post_job(args) {
|
||||
try {
|
||||
validateCredentials();
|
||||
} catch (error) {
|
||||
return `❌ ${error.message}`;
|
||||
}
|
||||
const { title, city_query, description, company, email, linkedinCompanyUrl } =
|
||||
args;
|
||||
|
||||
// Validate required fields
|
||||
if (!email) {
|
||||
return `❌ **Email is required.** Please provide an email address to receive resumes.\n\nExample: --email "hr@company.com"`;
|
||||
}
|
||||
|
||||
// Fuzzy search for location
|
||||
const results = fuse.search(city_query);
|
||||
if (results.length === 0) {
|
||||
return `❌ Sorry, I couldn't find the location: "${city_query}".`;
|
||||
}
|
||||
|
||||
const matched = results[0].item;
|
||||
|
||||
// Build extra data
|
||||
const extra = JSON.stringify({
|
||||
location: matched.parentValue,
|
||||
sublocation: matched.value,
|
||||
cityname: matched.label,
|
||||
});
|
||||
|
||||
const fullDescription = await genJD(description, INTERNAL_CALL_TOKEN);
|
||||
// console.log("Generated Description:", fullDescription);
|
||||
if (fullDescription.startsWith("❌")) {
|
||||
return fullDescription;
|
||||
}
|
||||
|
||||
// Double-check: sanitize the AI-generated description before sending to job posting API
|
||||
const finalDescription = sanitizeDescription(fullDescription);
|
||||
if (!finalDescription) {
|
||||
return "❌ **Invalid job description:** Generated content was filtered as unsafe.";
|
||||
}
|
||||
|
||||
// Build request body
|
||||
const body = {
|
||||
title,
|
||||
description: finalDescription,
|
||||
location: matched.parentLabel,
|
||||
company: company || "FUKU AI",
|
||||
companySearchKeyword: "",
|
||||
extra,
|
||||
isAiInterview: 1,
|
||||
orgId: "1",
|
||||
email: email,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await axios.post(API_URL, body, {
|
||||
params: {
|
||||
uid: "1873977344885133312",
|
||||
},
|
||||
headers: {
|
||||
"X-NUMBER": NUMBER,
|
||||
},
|
||||
});
|
||||
|
||||
const jobData = response.data;
|
||||
|
||||
if (jobData.code !== 0) {
|
||||
return `❌ **Failed to post job:** ${jobData.desc}`;
|
||||
}
|
||||
await postToLinkd(jobData.data, linkedinCompanyUrl, INTERNAL_CALL_TOKEN);
|
||||
const jobId = jobData.data.id;
|
||||
|
||||
// Note: LinkedIn sync runs in background, returns immediately
|
||||
// User can check status later with check_linkedin_status
|
||||
|
||||
return (
|
||||
`✅ **Job Posted Successfully!**\n\n` +
|
||||
`**Position:** ${title}\n` +
|
||||
`**Location:** ${matched.label}\n` +
|
||||
`**Job ID:** \`${jobId}\`\n` +
|
||||
`**The resume will be sent to:** ${email}\n\n` +
|
||||
`--- \n` +
|
||||
`**LinkedIn Sync:** ⏳ Processing in background (5-30 min). I'll check and notify you when ready!\n\n` +
|
||||
`You can also manually check with: \`check_linkedin_status\` using Job ID \`${jobId}\``
|
||||
);
|
||||
} catch (error) {
|
||||
const errorMsg = error.response?.data?.message || error.message;
|
||||
return `❌ **Failed to post job:** ${errorMsg}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function for CLI usage
|
||||
*/
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
|
||||
if (!args.title || !args.city) {
|
||||
console.error("Error: --title and --city are required");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.email) {
|
||||
console.error("Error: --email is required");
|
||||
process.exit(1);
|
||||
}
|
||||
if (!args.description) {
|
||||
console.error("Error: --description is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await post_job({
|
||||
title: args.title,
|
||||
city_query: args.city,
|
||||
description: args.description,
|
||||
company: args.company,
|
||||
email: args.email,
|
||||
linkedinCompanyUrl: args.linkedinCompanyUrl,
|
||||
});
|
||||
|
||||
console.log(result);
|
||||
} catch (error) {
|
||||
console.error("❌ Unexpected error:", error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (process.argv[1] && process.argv[1].includes("post_job.js")) {
|
||||
main().catch((error) => {
|
||||
console.error("❌ Fatal error:", error.message);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user